// 데스크톱 동기화 엔진 ↔ 실제 Supabase(로컬 스택) 통합 검증. // 모바일 앱이 쓰는 것과 같은 테이블·RPC·RLS를 상대로, 모바일 역할 클라이언트와 데스크톱 엔진이 // 서로의 생성·수정·삭제를 주고받는지 확인한다. // // 실행: 로컬 스택(`supabase start`, server/) 기동 후 // D3RO_SYNC_IT_SUPABASE_URL=http://127.0.0.1:55321 \ // D3RO_SYNC_IT_ANON_KEY= D3RO_SYNC_IT_SERVICE_KEY= \ // vitest run tests/integration/cross-device-sync.supabase.test.ts // 환경변수가 없으면 건너뛴다(CI 기본). import fs from 'fs' import os from 'os' import path from 'path' import { afterAll, beforeAll, describe, expect, it, vi } 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, ragChunks, ragDocuments } from '../../src/main/db/schema' import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService' import { resetRAGServiceForTests } from '../../src/main/services/RAGService' 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 { const client = createClient(URL!, ANON!, noPersist) const { error } = await client.auth.signInWithPassword({ email, password }) if (error) throw error return client } function must(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 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>; meetings: Array> } } 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((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('지식 문서(원문 청크)·설정·녹음이 실제 RLS/저장소 정책을 통과해 오간다', async () => { const db = testDb.db resetRAGServiceForTests() // 로컬 임베딩 서버(Ollama)는 없다고 보고 원문 저장 경로만 확인한다(Supabase 호출은 통과시킨다). const realFetch = globalThis.fetch vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => String(input instanceof Request ? input.url : input).includes('11434') ? Promise.reject(new TypeError('fetch failed')) : realFetch(input, init) ) // 데스크톱 지식 문서 const docId = crypto.randomUUID() db.insert(ragDocuments).values({ id: docId, fileName: 'desk.md', filePath: '', fileType: 'md', chunkCount: 2, indexed: false, addedAt: Date.now() }).run() ;['alpha chunk', 'beta chunk'].forEach((content, chunkIndex) => db.insert(ragChunks).values({ id: crypto.randomUUID(), documentId: docId, content, embedding: '', chunkIndex }).run() ) enqueueChange('knowledge_documents', docId, 'upsert') // 모바일 지식 문서 const phoneDoc = crypto.randomUUID() must(await mobile.from('knowledge_documents').insert({ id: phoneDoc, user_id: userId, title: 'Phone doc', file_name: 'phone.txt', file_type: 'txt', chunk_count: 1 })) must(await mobile.from('knowledge_chunks').insert({ document_id: phoneDoc, chunk_index: 0, content: 'from the phone' })) // 녹음이 있는 데스크톱 기록 const audioDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-it-audio-')) const audioFile = path.join(audioDir, 'clip.wav') fs.writeFileSync(audioFile, Buffer.from('RIFF----WAVEfmt integration audio bytes')) const clipId = crypto.randomUUID() db.insert(history).values({ id: clipId, originalText: 'with audio', duration: 1, audioLocalPath: audioFile, createdAt: Date.now(), updatedAt: Date.now() }).run() enqueueChange('history', clipId, 'upsert') enqueueChange('history_audio', clipId, 'upsert') // 설정: 데스크톱 변경 → 서버, 이후 모바일 변경 → 데스크톱 configSet('theme', 'dark') enqueueChange('user_settings', 'self', 'upsert') const pushed = await engine.flush() expect(pushed.errors).toEqual([]) const remoteChunks = must(await mobile.from('knowledge_chunks').select('content').eq('document_id', docId).order('chunk_index')) expect(remoteChunks.map((c) => c.content)).toEqual(['alpha chunk', 'beta chunk']) const audio = must(await mobile.from('audio_files').select('storage_key,upload_status,mime_type').eq('history_id', clipId)) expect(audio).toHaveLength(1) expect(audio[0].upload_status).toBe('uploaded') const signed = await mobile.storage.from('audio').createSignedUrl(String(audio[0].storage_key), 60) expect(signed.error).toBeNull() const settings = must(await mobile.from('user_settings').select('theme_mode,revision').eq('user_id', userId).single()) expect(settings.theme_mode).toBe('dark') must( await mobile .from('user_settings') .update({ locale: 'en', revision: Number(settings.revision) + 1 }) .eq('user_id', userId) .eq('revision', settings.revision) ) await engine.pull() expect(configGet('language')).toBe('en') const pulledChunks = db.select().from(ragChunks).where(eq(ragChunks.documentId, phoneDoc)).all() expect(pulledChunks.map((c) => c.content)).toEqual(['from the phone']) // 모바일이 지식 문서를 지우면 데스크톱에서도 사라진다(삭제 기록) must(await mobile.from('knowledge_documents').delete().eq('id', phoneDoc)) await engine.pull() expect(db.select().from(ragDocuments).where(eq(ragDocuments.id, phoneDoc)).all()).toEqual([]) // 데스크톱 기록 삭제 → 저장소 녹음도 정리 db.delete(history).where(eq(history.id, clipId)).run() enqueueChange('history', clipId, 'delete') const deleted = await engine.flush() expect(deleted.errors).toEqual([]) expect(must(await mobile.from('audio_files').select('id').eq('user_id', userId).eq('storage_key', String(audio[0].storage_key)))).toEqual([]) fs.rmSync(audioDir, { recursive: true, force: true }) vi.restoreAllMocks() }, 60_000) it('데스크톱 전사가 폰이 그리는 구간으로 올라가고, 고친 프리셋 프롬프트가 폰 명령에 쓰인다', async () => { const db = testDb.db const meetingId = crypto.randomUUID() const now = Date.now() db.insert(meetingSessions).values({ id: meetingId, title: 'segments', status: 'completed', startedAt: now, rawTranscript: '[00:00] one\n[00:04] two\n[00:09] three', createdAt: now, updatedAt: now, }).run() enqueueChange('meetings', meetingId, 'upsert') getCustomInstructionService().update('builtin-summarize', { prompt: '두 줄로 요약' }) enqueueChange('builtin_instructions', 'builtin-summarize', 'upsert') const first = await engine.flush() expect(first.errors).toEqual([]) const segments = must(await mobile.from('transcripts').select('segment_index,text,edited').eq('meeting_id', meetingId).order('segment_index')) expect(segments.map((s) => s.text)).toEqual(['one', 'two', 'three']) db.update(meetingSessions).set({ editedTranscript: '[00:00] [화자 1] one fixed', updatedAt: Date.now() }).where(eq(meetingSessions.id, meetingId)).run() enqueueChange('meetings', meetingId, 'upsert') expect((await engine.flush()).errors).toEqual([]) const edited = must(await mobile.from('transcripts').select('text,speaker,edited').eq('meeting_id', meetingId)) expect(edited).toEqual([{ text: 'one fixed', speaker: '화자 1', edited: true }]) const summarize = must(await mobile.from('custom_instructions').select('prompt').eq('user_id', userId).eq('builtin_key', 'summarize').single()) expect(summarize.prompt).toBe('두 줄로 요약') // 폰은 여전히 프리셋 행을 직접 고칠 수 없다(RLS) const blocked = await mobile.from('custom_instructions').update({ prompt: 'hack' }).eq('user_id', userId).eq('builtin_key', 'summarize').select('id') expect(blocked.data ?? []).toEqual([]) }) 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') }) })