// 데스크톱 ↔ Supabase(모바일·웹) 양방향 동기화 엔진 — 서버 흉내 원격으로 실제 SQLite에 대해 검증한다. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { eq } from 'drizzle-orm' import { createTestDb } from '../../helpers/createTestDb' import { FakeSyncRemote } from '../../helpers/fakeSyncRemote' import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db' import { dictionary, history, meetingDocuments, 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 { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox' import { memoTagKey } from '../../../src/main/services/sync/memo-tag-sync' import { SyncRemoteError } from '../../../src/main/services/sync/sync-types' const USER = '11111111-1111-4111-8111-111111111111' const OTHER = '22222222-2222-4222-8222-222222222222' function uuid(): string { return crypto.randomUUID() } let testDb: ReturnType let remote: FakeSyncRemote let engine: SyncEngine function db(): ReturnType['db'] { return testDb.db } function insertLocalHistory(text: string, at = Date.now()): string { const id = uuid() db().insert(history).values({ id, originalText: text, duration: 1.5, createdAt: at, updatedAt: at }).run() return id } function insertLocalMeeting(title: string, at = Date.now()): string { const id = uuid() db() .insert(meetingSessions) .values({ id, title, status: 'completed', startedAt: at, createdAt: at, updatedAt: at }) .run() return id } function localHistoryIds(): string[] { return db().select({ id: history.id }).from(history).all().map((r) => r.id).sort() } beforeEach(() => { testDb = createTestDb() bindTestDatabase(testDb.db, USER) initInMemoryConfig() resetCustomInstructionServiceForTests() resetDictationTemplateServiceForTests() resetMeetingDocTemplateServiceForTests() getCustomInstructionService().initialize() remote = new FakeSyncRemote(USER) engine = new SyncEngine({ remote, userId: USER }) }) afterEach(() => { engine.dispose() unbindTestDatabase() resetInMemoryConfig() testDb.close() }) describe('최초 동기화', () => { it('모바일에서 만든 기존 기록을 내려받고, 데스크톱 기록을 올린다', async () => { const mobileId = uuid() remote.mobileInsert('history', { id: mobileId, original_text: 'from phone', duration: 2, mode: 'dictation', status: 'completed', is_favorite: true }) const desktopId = insertLocalHistory('from desktop') const result = await engine.runFullSync() expect(result.errors).toEqual([]) expect(localHistoryIds()).toEqual([mobileId, desktopId].sort()) expect(remote.find('history', desktopId)?.original_text).toBe('from desktop') const pulled = db().select().from(history).where(eq(history.id, mobileId)).get() expect(pulled?.isFavorite).toBe(true) expect(result.changed).toContain('history') expect(outboxCounts()).toEqual({ pending: 0, parked: 0 }) }) it('서버와 같은 판은 다시 올리지 않는다 (다른 기기의 편집을 덮지 않는다)', async () => { const id = insertLocalHistory('same', Date.parse('2026-09-26T00:00:00Z')) remote.mobileInsert('history', { id, original_text: 'edited on phone', duration: 1.5, mode: 'dictation', status: 'completed', }) await engine.runFullSync() expect(remote.calls.filter((c) => c.startsWith('upsert:history'))).toEqual([]) expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('edited on phone') }) it('500행이 넘어도 페이지를 넘겨 전부 가져온다', async () => { for (let i = 0; i < 1203; i++) { remote.mobileInsert('history', { id: uuid(), original_text: `r${i}`, duration: 1, mode: 'dictation', status: 'completed' }) } await engine.runFullSync() expect(localHistoryIds()).toHaveLength(1203) }) it('다른 사용자(팀) 행은 가져오지 않는다', async () => { remote.rows('history').push({ id: uuid(), user_id: OTHER, original_text: 'not mine', duration: 1, mode: 'dictation', status: 'completed', created_at: remote.now(), updated_at: remote.now(), }) await engine.runFullSync() expect(localHistoryIds()).toEqual([]) }) }) describe('변경·삭제 전파', () => { it('모바일에서 수정/삭제한 것이 데스크톱에 반영된다', async () => { const a = uuid() const b = uuid() remote.mobileInsert('history', { id: a, original_text: 'a', duration: 1, mode: 'dictation', status: 'completed' }) remote.mobileInsert('history', { id: b, original_text: 'b', duration: 1, mode: 'dictation', status: 'completed' }) await engine.runFullSync() remote.mobileUpdate('history', a, { original_text: 'a edited', title: 'renamed' }) remote.mobileDelete('history', b) const result = await engine.pull() expect(result.deleted).toBe(1) expect(localHistoryIds()).toEqual([a]) const row = db().select().from(history).where(eq(history.id, a)).get() expect(row?.originalText).toBe('a edited') expect(row?.title).toBe('renamed') }) it('데스크톱 삭제가 서버로 전파된다', async () => { const id = insertLocalHistory('bye') await engine.runFullSync() expect(remote.find('history', id)).toBeDefined() db().delete(history).where(eq(history.id, id)).run() enqueueChange('history', id, 'delete') await engine.flush() expect(remote.find('history', id)).toBeUndefined() // 자기 삭제 기록을 다시 받아도 문제없다 const pulled = await engine.pull() expect(pulled.errors).toEqual([]) }) it('아직 올리지 않은 로컬 변경은 pull이 덮지 않는다', async () => { const id = uuid() remote.mobileInsert('history', { id, original_text: 'v1', duration: 1, mode: 'dictation', status: 'completed' }) await engine.runFullSync() db().update(history).set({ originalText: 'local v2', updatedAt: Date.now() }).where(eq(history.id, id)).run() enqueueChange('history', id, 'upsert') remote.mobileUpdate('history', id, { original_text: 'remote v2' }) await engine.pull() expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('local v2') await engine.flush() expect(remote.find('history', id)?.original_text).toBe('local v2') }) it('원격 삭제는 로컬 미전송 변경보다 우선한다', async () => { const id = uuid() remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' }) await engine.runFullSync() enqueueChange('history', id, 'upsert') remote.mobileDelete('history', id) await engine.pull() expect(localHistoryIds()).toEqual([]) expect(pendingOps('history').size).toBe(0) }) it('데스크톱이 내용을 바꿔 올리면 서버 revision이 오른다 (모바일 충돌 감지)', async () => { const id = uuid() remote.mobileInsert('history', { id, original_text: 'x', duration: 1.5, mode: 'dictation', status: 'completed', revision: 1 }) await engine.runFullSync() db().update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, id)).run() enqueueChange('history', id, 'upsert') await engine.flush() expect(remote.find('history', id)?.is_favorite).toBe(true) expect(Number(remote.find('history', id)?.revision)).toBeGreaterThan(1) }) }) describe('회의', () => { it('여러 회의의 메모·문서를 모두 올린다 (첫 회의만 올리던 회귀)', async () => { const m1 = insertLocalMeeting('one') const m2 = insertLocalMeeting('two') const now = Date.now() for (const m of [m1, m2]) { db().insert(meetingMemos).values({ id: uuid(), sessionId: m, content: `memo ${m}`, timestampMs: 5, createdAt: now }).run() db() .insert(meetingDocuments) .values({ id: uuid(), sessionId: m, templateType: 'minutes', title: 't', content: 'c', createdAt: now, updatedAt: now }) .run() } const result = await engine.runFullSync() expect(result.errors).toEqual([]) expect(remote.rows('meeting_memos')).toHaveLength(2) expect(remote.rows('meeting_documents')).toHaveLength(2) }) it('팀에 공유된 회의를 데스크톱이 수정해도 team_id를 지우지 않는다', async () => { const id = insertLocalMeeting('shared') await engine.runFullSync() const teamId = uuid() remote.mobileUpdate('meetings', id, { team_id: teamId }) await engine.pull() db().update(meetingSessions).set({ title: 'renamed', updatedAt: Date.now() }).where(eq(meetingSessions.id, id)).run() enqueueChange('meetings', id, 'upsert') await engine.flush() expect(remote.find('meetings', id)?.team_id).toBe(teamId) expect(remote.find('meetings', id)?.title).toBe('renamed') }) it('모바일에서 수정한 회의 메모를 가져오고, 회의 삭제는 메모·문서까지 지운다', async () => { const meeting = uuid() const memo = uuid() remote.mobileInsert('meetings', { id: meeting, title: 'phone meeting', status: 'completed', started_at: remote.now() }) remote.mobileInsert('meeting_memos', { id: memo, meeting_id: meeting, content: 'first', timestamp_ms: 10 }) await engine.runFullSync() remote.mobileUpdate('meeting_memos', memo, { content: 'edited' }) await engine.pull() expect(db().select().from(meetingMemos).where(eq(meetingMemos.id, memo)).get()?.content).toBe('edited') remote.mobileDelete('meetings', meeting) await engine.pull() expect(db().select().from(meetingSessions).all()).toEqual([]) expect(db().select().from(meetingMemos).all()).toEqual([]) }) it('남의 팀 회의에 단 내 메모는 부모 없는 고아 행으로 만들지 않는다', async () => { const foreignMeeting = uuid() remote.rows('meetings').push({ id: foreignMeeting, user_id: OTHER, status: 'completed', created_at: remote.now(), updated_at: remote.now() }) remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: foreignMeeting, content: 'mine', timestamp_ms: 1 }) const result = await engine.runFullSync() expect(result.errors).toEqual([]) expect(db().select().from(meetingMemos).all()).toEqual([]) }) }) describe('사전', () => { it('대소문자만 다른 단어가 서버에 있으면 서버 행을 채택한다', async () => { const remoteId = uuid() remote.mobileInsert('dictionary', { id: remoteId, word: 'api', category: 'user', usage_count: 1 }) const localId = uuid() const now = Date.now() db().insert(dictionary).values({ id: localId, word: 'API', category: 'user', usageCount: 7, createdAt: now, updatedAt: now }).run() const result = await engine.runFullSync() expect(result.errors).toEqual([]) const rows = db().select().from(dictionary).all() expect(rows.map((r) => r.id)).toEqual([remoteId]) expect(rows[0].usageCount).toBe(7) expect(outboxCounts()).toEqual({ pending: 0, parked: 0 }) }) }) describe('실패 처리', () => { it('네트워크가 끊기면 변경을 보관했다가 다음에 올린다', async () => { await engine.runFullSync() const id = insertLocalHistory('offline') enqueueChange('history', id, 'upsert') remote.networkDown = true const failed = await engine.flush() expect(failed.pushed).toBe(0) expect(outboxCounts().pending).toBe(1) remote.networkDown = false // 백오프 시각을 지난 것으로 본다 const later = new SyncEngine({ remote, userId: USER, now: () => Date.now() + 60_000 }) await later.flush() later.dispose() expect(remote.find('history', id)).toBeDefined() expect(outboxCounts().pending).toBe(0) }) it('서버가 거부한 행만 격리하고 나머지는 올린다', async () => { const good = insertLocalHistory('good') const bad = insertLocalHistory('bad') remote.rejectRow = (table, row) => table === 'history' && row.id === bad ? new SyncRemoteError('check violation', '23514', false) : null const result = await engine.runFullSync() expect(remote.find('history', good)).toBeDefined() expect(remote.find('history', bad)).toBeUndefined() expect(result.errors.some((e) => e.includes(bad))).toBe(true) expect(outboxCounts().pending).toBe(1) }) }) describe('메모 태그', () => { it('데스크톱 태그를 올리고 모바일 태그·삭제를 반영한다', async () => { const h = uuid() remote.mobileInsert('history', { id: h, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' }) await engine.runFullSync() db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'work', createdAt: Date.now() }).run() enqueueChange('memo_tags', memoTagKey(h, 'work'), 'upsert') await engine.flush() expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['work']) await remote.rpc('mobile_add_memo_tag_v1', { p_history_id: h, p_tag: 'Idea Board' }) await remote.rpc('mobile_remove_memo_tag_v1', { p_history_id: h, p_tag: 'work' }) await engine.pull() expect(db().select().from(memoTags).all().map((r) => r.tag)).toEqual(['idea board']) }) it('이력이 아직 서버에 없으면 태그는 대기했다가 이력 뒤에 올라간다', async () => { const h = insertLocalHistory('tagged') db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'later', createdAt: Date.now() }).run() const result = await engine.runFullSync() expect(result.errors).toEqual([]) expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['later']) expect(db().select().from(memoTags).all()).toHaveLength(1) }) }) describe('명령·템플릿', () => { it('사용자 명령을 양방향으로 맞추고 프리셋은 건드리지 않는다', async () => { const service = getCustomInstructionService() const created = service.create({ name: 'Tone down', description: 'd', prompt: 'Make it calm' }) enqueueChange('custom_instructions', created.id, 'upsert') const phone = uuid() remote.mobileInsert('custom_instructions', { id: phone, builtin_key: null, name: 'From phone', description: '', prompt: 'p', icon: 'sparkles', sort_order: 3 }) remote.mobileInsert('custom_instructions', { id: uuid(), builtin_key: 'summarize', name: 'Summarize', description: '', prompt: 'p', icon: 'x', sort_order: 0 }) await engine.runFullSync() expect(remote.find('custom_instructions', created.id)?.prompt).toBe('Make it calm') expect(service.getById(phone)?.name).toBe('From phone') expect(service.getAll().filter((i) => i.name === 'Summarize')).toEqual([]) expect(remote.rows('custom_instructions').filter((r) => String(r.id).startsWith('builtin-'))).toEqual([]) remote.mobileDelete('custom_instructions', phone) await engine.pull() expect(service.getById(phone)).toBeNull() }) it('받아쓰기 템플릿을 같은 id로 올리고 모바일 수정을 반영한다', async () => { const service = getDictationTemplateService() const template = service.create({ name: 'Standup', description: '', fields: [{ id: 'field0', name: 'done', label: 'Done', promptText: 'What did you do?', required: true, maxDurationSec: 60 }], outputFormat: '{{done}}', }) enqueueChange('user_templates', template.id, 'upsert') await engine.runFullSync() expect(remote.find('user_templates', template.id)?.name).toBe('Standup') remote.mobileUpdate('user_templates', template.id, { name: 'Daily standup' }) await engine.pull() expect(service.getById(template.id)?.name).toBe('Daily standup') expect(service.getAll().filter((t) => t.isBuiltin).length).toBeGreaterThan(0) }) })