d3ro-voice/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts
Yun Chan 0a4f5aee64 feat(desktop): two-way cloud sync with mobile and web
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.
2026-09-27 14:04:49 +09:00

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')
})
})