Rewrites the desktop mirror as services/sync/SyncEngine: a persistent outbox, per-account server-clock keyset cursors with paging, pulls that never overwrite unsent local edits, deletions both ways through sync_tombstones and per-row failure isolation. It now covers history titles and favorites, dictionary, every meeting's memos and documents, memo tags, user commands and dictation/meeting templates, and registers the desktop as a device that the phone can disconnect. Fixes shipped defects: the first pull after sign-in fetched nothing, only the first meeting's children were pushed, team meetings leaked into the personal database and lost team_id on re-push, and Realtime never connected because Electron's Node 20 has no global WebSocket (ws is now the transport). Anonymous local-mode records are imported into the first account that signs in. The settings sync section is translated and shows pending/rejected changes; synced screens reload on app:dataChanged.
271 lines
14 KiB
TypeScript
271 lines
14 KiB
TypeScript
// 데스크톱 동기화 엔진 ↔ 실제 Supabase(로컬 스택) 통합 검증.
|
|
// 모바일 앱이 쓰는 것과 같은 테이블·RPC·RLS를 상대로, 모바일 역할 클라이언트와 데스크톱 엔진이
|
|
// 서로의 생성·수정·삭제를 주고받는지 확인한다.
|
|
//
|
|
// 실행: 로컬 스택(`supabase start`, server/) 기동 후
|
|
// D3RO_SYNC_IT_SUPABASE_URL=http://127.0.0.1:55321 \
|
|
// D3RO_SYNC_IT_ANON_KEY=<publishable> D3RO_SYNC_IT_SERVICE_KEY=<secret> \
|
|
// vitest run tests/integration/cross-device-sync.supabase.test.ts
|
|
// 환경변수가 없으면 건너뛴다(CI 기본).
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { eq } from 'drizzle-orm'
|
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
|
import { nodeRealtimeTransport } from '../../src/main/services/sync/realtime-transport'
|
|
import { createTestDb } from '../helpers/createTestDb'
|
|
import { bindTestDatabase, unbindTestDatabase } from '../../src/main/db'
|
|
import { dictionary, history, meetingMemos, meetingSessions, memoTags } from '../../src/main/db/schema'
|
|
import { initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService'
|
|
import {
|
|
getCustomInstructionService,
|
|
resetCustomInstructionServiceForTests,
|
|
} from '../../src/main/services/CustomInstructionService'
|
|
import {
|
|
getDictationTemplateService,
|
|
resetDictationTemplateServiceForTests,
|
|
} from '../../src/main/services/DictationTemplateService'
|
|
import { resetMeetingDocTemplateServiceForTests } from '../../src/main/services/MeetingDocTemplateService'
|
|
import { SyncEngine } from '../../src/main/services/sync/SyncEngine'
|
|
import { SupabaseSyncRemote } from '../../src/main/services/sync/supabase-sync-remote'
|
|
import { enqueueChange, outboxCounts } from '../../src/main/services/sync/sync-outbox'
|
|
import { memoTagKey } from '../../src/main/services/sync/memo-tag-sync'
|
|
import { checkInDesktopDevice } from '../../src/main/services/sync/device-registration'
|
|
|
|
const URL = process.env.D3RO_SYNC_IT_SUPABASE_URL
|
|
const ANON = process.env.D3RO_SYNC_IT_ANON_KEY
|
|
const SERVICE = process.env.D3RO_SYNC_IT_SERVICE_KEY
|
|
const enabled = Boolean(URL && ANON && SERVICE)
|
|
|
|
// 데스크톱 CloudSyncService와 같은 클라이언트 설정 (Node 20에는 전역 WebSocket이 없다)
|
|
const noPersist = {
|
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
realtime: { transport: nodeRealtimeTransport },
|
|
}
|
|
|
|
async function signedInClient(email: string, password: string): Promise<SupabaseClient> {
|
|
const client = createClient(URL!, ANON!, noPersist)
|
|
const { error } = await client.auth.signInWithPassword({ email, password })
|
|
if (error) throw error
|
|
return client
|
|
}
|
|
|
|
function must<T>(result: { data: T; error: { message: string } | null }): T {
|
|
if (result.error) throw new Error(result.error.message)
|
|
return result.data
|
|
}
|
|
|
|
describe.skipIf(!enabled)('cross-device sync against local Supabase', () => {
|
|
let admin: SupabaseClient
|
|
let desktop: SupabaseClient
|
|
let mobile: SupabaseClient
|
|
let userId: string
|
|
let testDb: ReturnType<typeof createTestDb>
|
|
let engine: SyncEngine
|
|
|
|
beforeAll(async () => {
|
|
admin = createClient(URL!, SERVICE!, noPersist)
|
|
const email = `sync-it-${Date.now()}@example.com`
|
|
const password = `Sync-it-${crypto.randomUUID()}`
|
|
const created = await admin.auth.admin.createUser({ email, password, email_confirm: true })
|
|
if (created.error || !created.data.user) throw created.error ?? new Error('no user')
|
|
userId = created.data.user.id
|
|
desktop = await signedInClient(email, password)
|
|
mobile = await signedInClient(email, password)
|
|
|
|
testDb = createTestDb()
|
|
bindTestDatabase(testDb.db, userId)
|
|
initInMemoryConfig()
|
|
resetCustomInstructionServiceForTests()
|
|
resetDictationTemplateServiceForTests()
|
|
resetMeetingDocTemplateServiceForTests()
|
|
getCustomInstructionService().initialize()
|
|
engine = new SyncEngine({ remote: new SupabaseSyncRemote(desktop), userId })
|
|
})
|
|
|
|
afterAll(async () => {
|
|
engine?.dispose()
|
|
unbindTestDatabase()
|
|
resetInMemoryConfig()
|
|
testDb?.close()
|
|
if (userId) await admin.auth.admin.deleteUser(userId)
|
|
})
|
|
|
|
it('양방향 최초 동기화 — 모바일 기존 데이터 수신 + 데스크톱 데이터 업로드', async () => {
|
|
const db = testDb.db
|
|
const now = Date.now()
|
|
|
|
// 모바일이 먼저 만들어 둔 것
|
|
const phoneHistory = crypto.randomUUID()
|
|
must(await mobile.from('history').insert({ id: phoneHistory, user_id: userId, original_text: 'from phone', duration: 2, is_favorite: true }))
|
|
await mobile.rpc('mobile_add_memo_tag_v1', { p_history_id: phoneHistory, p_tag: 'Phone Tag' }).throwOnError()
|
|
must(await mobile.from('dictionary').insert({ id: crypto.randomUUID(), user_id: userId, word: 'kubernetes', category: 'user' }))
|
|
const phoneMeeting = crypto.randomUUID()
|
|
must(await mobile.from('meetings').insert({ id: phoneMeeting, user_id: userId, title: 'phone meeting', status: 'completed' }))
|
|
must(await mobile.from('meeting_memos').insert({ id: crypto.randomUUID(), meeting_id: phoneMeeting, user_id: userId, content: 'phone memo', timestamp_ms: 3 }))
|
|
must(await mobile.from('custom_instructions').insert({ user_id: userId, name: 'Phone command', prompt: 'Be brief', icon: 'sparkles' }))
|
|
await mobile
|
|
.rpc('create_user_template_v1', {
|
|
p_template_kind: 'meeting_document',
|
|
p_name: 'Phone template',
|
|
p_description: null,
|
|
p_fields: [],
|
|
p_output_format: null,
|
|
p_system_prompt: 'Summarise',
|
|
})
|
|
.throwOnError()
|
|
|
|
// 데스크톱에만 있는 것
|
|
const deskHistory = crypto.randomUUID()
|
|
db.insert(history).values({ id: deskHistory, originalText: 'from desktop', duration: 1.5, createdAt: now, updatedAt: now }).run()
|
|
db.insert(memoTags).values({ id: crypto.randomUUID(), historyId: deskHistory, tag: 'desk', createdAt: now }).run()
|
|
db.insert(dictionary).values({ id: crypto.randomUUID(), word: 'Kubernetes', category: 'user', usageCount: 4, createdAt: now, updatedAt: now }).run()
|
|
const m1 = crypto.randomUUID()
|
|
const m2 = crypto.randomUUID()
|
|
for (const m of [m1, m2]) {
|
|
db.insert(meetingSessions).values({ id: m, title: `desk ${m.slice(0, 4)}`, status: 'completed', startedAt: now, createdAt: now, updatedAt: now }).run()
|
|
db.insert(meetingMemos).values({ id: crypto.randomUUID(), sessionId: m, content: 'desk memo', timestampMs: 1, createdAt: now }).run()
|
|
}
|
|
const deskCommand = getCustomInstructionService().create({ name: 'Desk command', description: '', prompt: 'Rewrite formally' })
|
|
const deskTemplate = getDictationTemplateService().create({
|
|
name: 'Desk dictation',
|
|
description: 'd',
|
|
fields: [{ id: 'field0', name: 'body', label: 'Body', promptText: 'Say it', required: true, maxDurationSec: 60 }],
|
|
outputFormat: '{{body}}',
|
|
})
|
|
|
|
const result = await engine.runFullSync()
|
|
expect(result.errors).toEqual([])
|
|
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
|
|
|
|
// 서버(모바일이 보는 것)
|
|
const remoteHistory = must(await mobile.from('history').select('id').eq('user_id', userId))
|
|
expect(remoteHistory.map((r) => r.id).sort()).toEqual([phoneHistory, deskHistory].sort())
|
|
const remoteMemos = must(await mobile.from('meeting_memos').select('meeting_id').eq('user_id', userId))
|
|
expect(new Set(remoteMemos.map((r) => r.meeting_id))).toEqual(new Set([phoneMeeting, m1, m2]))
|
|
const remoteTags = must(await mobile.rpc('mobile_list_memo_tags_v1')) as Array<{ normalized_tag: string }>
|
|
expect(remoteTags.map((r) => r.normalized_tag).sort()).toEqual(['desk', 'phone tag'])
|
|
const remoteDict = must(await mobile.from('dictionary').select('word').eq('user_id', userId))
|
|
expect(remoteDict).toHaveLength(1)
|
|
const remoteCommands = must(await mobile.from('custom_instructions').select('id,name').eq('user_id', userId).is('builtin_key', null))
|
|
expect(remoteCommands.map((r) => r.name).sort()).toEqual(['Desk command', 'Phone command'])
|
|
expect(remoteCommands.some((r) => r.id === deskCommand.id)).toBe(true)
|
|
const remoteTemplate = must(await mobile.from('user_templates').select('id,name').eq('id', deskTemplate.id).maybeSingle())
|
|
expect(remoteTemplate?.name).toBe('Desk dictation')
|
|
|
|
// 데스크톱(로컬)
|
|
const local = db.select().from(history).where(eq(history.id, phoneHistory)).get()
|
|
expect(local?.isFavorite).toBe(true)
|
|
expect(db.select().from(memoTags).all().map((t) => t.tag).sort()).toEqual(['desk', 'phone tag'])
|
|
expect(db.select().from(dictionary).all()).toHaveLength(1)
|
|
expect(db.select().from(meetingMemos).where(eq(meetingMemos.sessionId, phoneMeeting)).all()).toHaveLength(1)
|
|
expect(getCustomInstructionService().getAll().some((i) => i.name === 'Phone command')).toBe(true)
|
|
})
|
|
|
|
it('모바일 수정·삭제 → 데스크톱, 데스크톱 삭제 → 모바일', async () => {
|
|
const db = testDb.db
|
|
const rows = must(await mobile.from('history').select('id,original_text,revision').eq('user_id', userId))
|
|
const phone = rows.find((r) => r.original_text === 'from phone')!
|
|
const desk = rows.find((r) => r.original_text === 'from desktop')!
|
|
|
|
// 모바일식 낙관적 동시성 수정
|
|
must(
|
|
await mobile
|
|
.from('history')
|
|
.update({ original_text: 'phone edited', revision: Number(phone.revision) + 1 })
|
|
.eq('id', phone.id)
|
|
.eq('revision', phone.revision)
|
|
)
|
|
must(await mobile.from('meetings').delete().eq('user_id', userId).eq('title', 'phone meeting'))
|
|
await engine.pull()
|
|
expect(db.select().from(history).where(eq(history.id, phone.id)).get()?.originalText).toBe('phone edited')
|
|
expect(db.select().from(meetingSessions).all().map((m) => m.title)).not.toContain('phone meeting')
|
|
|
|
// 데스크톱 즐겨찾기 → 서버 revision 증가(모바일 충돌 감지)
|
|
db.update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, desk.id)).run()
|
|
enqueueChange('history', desk.id, 'upsert')
|
|
await engine.flush()
|
|
const after = must(await mobile.from('history').select('is_favorite,revision').eq('id', desk.id).single())
|
|
expect(after.is_favorite).toBe(true)
|
|
expect(Number(after.revision)).toBeGreaterThan(Number(desk.revision))
|
|
|
|
// 데스크톱 삭제
|
|
db.delete(memoTags).where(eq(memoTags.historyId, desk.id)).run()
|
|
db.delete(history).where(eq(history.id, desk.id)).run()
|
|
enqueueChange('history', desk.id, 'delete')
|
|
enqueueChange('memo_tags', memoTagKey(desk.id, 'desk'), 'delete')
|
|
const flushed = await engine.flush()
|
|
expect(flushed.errors).toEqual([])
|
|
const gone = must(await mobile.from('history').select('id').eq('id', desk.id))
|
|
expect(gone).toEqual([])
|
|
})
|
|
|
|
it('모바일 데이터 내보내기가 동기화된 데이터로도 성공한다 (v1 키 계약)', async () => {
|
|
const exported = must(await mobile.rpc('export_account_portability')) as Array<{ canonical_payload: string }>
|
|
const payload = JSON.parse(exported[0].canonical_payload) as {
|
|
datasets: { meeting_memos: Array<Record<string, unknown>>; meetings: Array<Record<string, unknown>> }
|
|
}
|
|
for (const memo of payload.datasets.meeting_memos) {
|
|
expect(Object.keys(memo).sort()).toEqual(['content', 'created_at', 'id', 'meeting_id', 'timestamp_ms', 'user_id'])
|
|
}
|
|
for (const meeting of payload.datasets.meetings) {
|
|
expect(meeting).not.toHaveProperty('attendees')
|
|
}
|
|
})
|
|
|
|
it('모바일 생성·삭제가 Realtime(행 + 삭제 기록)으로 데스크톱에 즉시 알려진다', async () => {
|
|
const session = must(await desktop.auth.getSession()).session
|
|
desktop.realtime.setAuth(session!.access_token)
|
|
const seen: string[] = []
|
|
const channel = desktop
|
|
.channel(`it-sync:${userId}`)
|
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'history', filter: `user_id=eq.${userId}` }, (p) =>
|
|
seen.push(`history:${p.eventType}`)
|
|
)
|
|
.on(
|
|
'postgres_changes',
|
|
{ event: 'INSERT', schema: 'public', table: 'sync_tombstones', filter: `user_id=eq.${userId}` },
|
|
() => seen.push('tombstone')
|
|
)
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('realtime subscribe timeout')), 15_000)
|
|
channel.subscribe((status, err) => {
|
|
if (status !== 'SUBSCRIBED') seen.push(`status:${status}:${err?.message ?? ''}`)
|
|
if (status === 'SUBSCRIBED') {
|
|
clearTimeout(timer)
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
// 구독 직후에는 서버의 변경 감시 등록이 끝나지 않았을 수 있다.
|
|
await new Promise((r) => setTimeout(r, 1500))
|
|
const id = crypto.randomUUID()
|
|
must(await mobile.from('history').insert({ id, user_id: userId, original_text: 'rt', duration: 1 }))
|
|
await new Promise((r) => setTimeout(r, 1000))
|
|
// 필터 채널에는 DELETE 이벤트가 오지 않는다(Supabase 제약) — 삭제는 sync_tombstones INSERT로 전달된다.
|
|
must(await mobile.from('history').delete().eq('id', id))
|
|
const deadline = Date.now() + 15_000
|
|
while (Date.now() < deadline && !(seen.includes('history:INSERT') && seen.includes('tombstone'))) {
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
}
|
|
await desktop.removeChannel(channel)
|
|
expect(seen).toContain('history:INSERT')
|
|
expect(seen).toContain('tombstone')
|
|
}, 45_000)
|
|
|
|
it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => {
|
|
const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' }
|
|
const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null)
|
|
expect(first.status).toBe('active')
|
|
const listed = must(await mobile.from('devices').select('id,platform,device_name').eq('user_id', userId))
|
|
expect(listed.some((d) => d.device_name === 'IT-DESKTOP')).toBe(true)
|
|
|
|
const deviceId = first.status === 'active' ? first.deviceId : ''
|
|
await mobile.rpc('revoke_device', { target_device_id: deviceId }).throwOnError()
|
|
const after = await checkInDesktopDevice(desktop, userId, 'heartbeat', info, deviceId)
|
|
expect(after.status).toBe('revoked')
|
|
|
|
// 같은 계정으로 다시 로그인하면 새 설치 id로 재등록된다
|
|
const again = await checkInDesktopDevice(desktop, userId, 'signin', info, deviceId)
|
|
expect(again.status).toBe('active')
|
|
})
|
|
})
|