d3ro-voice/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts
Yun Chan 2aac10fc5d feat(desktop): send meeting transcript segments and preset prompt edits to the phone
The phone draws a meeting from its transcript segments before the edited
transcript, so desktop edits, auto-polish and diarization never showed there.
Every desktop transcript change now rebuilds the meeting's segments from its
[MM:SS] [speaker] lines and trims the rest; the line parser moves to
@d3ro/core/meeting-transcript and the meeting view uses it too.

Prompt edits of the four desktop presets that exist on the phone update the
server preset row (a reset restores its default; {{targetLanguage}} is sent
as English, the only target on both sides), and edits made on another desktop
come back. The free-prompt preset has no phone counterpart and stays local.
2026-09-27 16:24:25 +09:00

385 lines
20 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 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<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('지식 문서(원문 청크)·설정·녹음이 실제 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')
})
})