// 동기화 레드팀 r1-0 회귀 테스트 // - 해제된 엔진이 다른 사용자 DB(로컬 모드)에 쓰지 않는다 // - 서버 시각 updated_at (클라이언트 시각을 보내지 않는다) // - 계정별 명령·템플릿 범위, 템플릿 P0002 보관 // - 미룬 행(부모 없음) 재시도, 부모 실패 시 자식 커서 보존 // - push 전에 원격 삭제 반영(좀비 행 방지) // - 명령 길이 제한(잘라 올리지 않음) // - 엔티티 레지스트리 전수·순서 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 { history, meetingMemos, memoTags } from '../../../src/main/db/schema' import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' import { getCustomInstructionService, INSTRUCTION_LIMITS, resetCustomInstructionServiceForTests, } from '../../../src/main/services/CustomInstructionService' import { getDictationTemplateService, resetDictationTemplateServiceForTests, } from '../../../src/main/services/DictationTemplateService' import { getMeetingDocTemplateService, resetMeetingDocTemplateServiceForTests, } from '../../../src/main/services/MeetingDocTemplateService' import { createMemoryCollectionFactory, resetAccountScopeForTests, type UserScopedCollectionFactory, } from '../../../src/main/services/account-scope' import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' import { SyncAbortedError, dictionaryToRemote, historyToRemote, instructionPushError, meetingDocumentToRemote, meetingMemoToRemote, meetingToRemote, templateUpsertError, } from '../../../src/main/services/sync/sync-adapters' import { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox' import { deleteOrder, SYNC_REGISTRY, upsertOrder } from '../../../src/main/services/sync/sync-registry' import { SYNC_ENTITIES, SYNC_ENTITY_SPECS, SyncRemoteError, parentOf, realtimeTables, toSyncRemoteError, type RemotePageRequest, type RemoteRow, } from '../../../src/main/services/sync/sync-types' const USER = '11111111-1111-4111-8111-111111111111' const USER_B = '33333333-3333-4333-8333-333333333333' function uuid(): string { return crypto.randomUUID() } /** fetchPage 를 테이블별로 가로챌 수 있는 원격 */ class GatedRemote extends FakeSyncRemote { gate: ((request: RemotePageRequest) => Promise | void) | null = null override async fetchPage(request: RemotePageRequest): Promise { const rows = await super.fetchPage(request) if (this.gate) await this.gate(request) return rows } } let dbs: Array> = [] let factory: UserScopedCollectionFactory function openDb(userId: string): ReturnType { const created = createTestDb() dbs.push(created) bindTestDatabase(created.db, userId) return created } beforeEach(() => { resetAccountScopeForTests() initInMemoryConfig() factory = createMemoryCollectionFactory() resetCustomInstructionServiceForTests(factory) resetDictationTemplateServiceForTests(factory) resetMeetingDocTemplateServiceForTests(factory) }) afterEach(() => { unbindTestDatabase() resetInMemoryConfig() for (const d of dbs) d.close() dbs = [] resetCustomInstructionServiceForTests() resetDictationTemplateServiceForTests() resetMeetingDocTemplateServiceForTests() resetAccountScopeForTests() }) describe('해제된 엔진은 다른 사용자 DB에 쓰지 않는다', () => { it('pull 도중 로그아웃(DB 전환)되면 계정 행이 로컬 모드 DB로 들어가지 않고 로컬 태그도 지우지 않는다', async () => { const accountDb = openDb(USER) const remote = new GatedRemote(USER) for (let i = 0; i < 700; i++) { remote.mobileInsert('history', { id: uuid(), original_text: `a${i}`, duration: 1, mode: 'dictation', status: 'completed' }) } const engine = new SyncEngine({ remote, userId: USER }) let release: () => void = () => undefined let pages = 0 remote.gate = (request) => { if (request.table !== 'history') return pages++ if (pages === 2) { return new Promise((resolve) => { release = resolve }) } } const running = engine.runFullSync() // 두 번째 페이지 응답을 기다리는 동안 로그아웃: 엔진 해제 → 로컬 모드 DB로 전환 for (let i = 0; i < 200 && pages < 2; i++) await new Promise((r) => setTimeout(r, 10)) expect(pages).toBe(2) engine.dispose() const localDb = openDb('_local') const localHistory = uuid() const now = Date.now() localDb.db.insert(history).values({ id: localHistory, originalText: 'local', duration: 1, createdAt: now, updatedAt: now }).run() localDb.db.insert(memoTags).values({ id: uuid(), historyId: localHistory, tag: 'mine', createdAt: now }).run() release() await expect(running).rejects.toBeInstanceOf(SyncAbortedError) const localRows = localDb.db.select().from(history).all() expect(localRows.map((r) => r.id)).toEqual([localHistory]) expect(localDb.db.select().from(memoTags).all()).toHaveLength(1) // 계정 DB는 첫 페이지까지만 반영됐고 커서도 로컬 DB에 쓰이지 않았다 expect(accountDb.db.select().from(history).all().length).toBeLessThanOrEqual(500) }) it('해제 뒤에 줄을 선 작업은 시작하지 않는다', async () => { openDb(USER) const remote = new FakeSyncRemote(USER) const engine = new SyncEngine({ remote, userId: USER }) engine.dispose() await expect(engine.pull()).rejects.toBeInstanceOf(SyncAbortedError) expect(remote.calls).toEqual([]) await expect(engine.whenIdle(100)).resolves.toBe(true) }) it('다른 사용자 DB가 열리면 같은 엔진도 멈춘다', async () => { openDb(USER) const remote = new FakeSyncRemote(USER) const engine = new SyncEngine({ remote, userId: USER }) openDb(USER_B) expect(engine.isCurrent()).toBe(false) await expect(engine.runFullSync()).rejects.toBeInstanceOf(SyncAbortedError) }) }) describe('updated_at 은 서버 시각', () => { it('push 페이로드에 클라이언트 updated_at 을 싣지 않는다', () => { const at = Date.parse('2026-01-01T00:00:00Z') const h = historyToRemote( { id: uuid(), title: null, originalText: 'x', polishedText: null, focusedApp: null, focusedAppName: null, focusedAppWindowTitle: null, mode: 'dictation', status: 'completed', errorCode: null, audioLocalPath: null, duration: 1, detectedLanguage: null, micDevice: null, wordCount: 1, sttModel: null, llmModel: null, sttLatencyMs: null, llmLatencyMs: null, createdAt: at, updatedAt: at, appVersion: '1', summaryText: null, isFavorite: false, } as Parameters[0], USER ) expect(h).not.toHaveProperty('updated_at') const d = dictionaryToRemote( { id: uuid(), word: 'w', pronunciation: null, category: 'user', usageCount: 0, lastUsedAt: null, createdAt: at, updatedAt: at } as Parameters[0], USER ) expect(d).not.toHaveProperty('updated_at') const m = meetingToRemote({ id: uuid(), title: 't', status: 'completed', startedAt: at, endedAt: null, createdAt: at, updatedAt: at } as Parameters[0], USER) expect(m).not.toHaveProperty('updated_at') const memo = meetingMemoToRemote({ id: uuid(), sessionId: uuid(), content: 'c', timestampMs: 1, createdAt: at, updatedAt: at } as Parameters[0], USER) expect(memo).not.toHaveProperty('updated_at') const doc = meetingDocumentToRemote({ id: uuid(), sessionId: uuid(), templateType: 'minutes', title: 't', content: '', createdAt: at, updatedAt: at } as Parameters[0], USER) expect(doc).not.toHaveProperty('updated_at') }) it('다른 데스크톱이 늦게 올린 오래된 기록도 이미 동기화된 기기가 내려받는다', async () => { const remote = new FakeSyncRemote(USER) // 데스크톱 B: 이미 동기화돼 커서가 "지금" 근처 const dbB = openDb(USER) remote.mobileInsert('history', { id: uuid(), original_text: 'recent', duration: 1, mode: 'dictation', status: 'completed' }) const engineB = new SyncEngine({ remote, userId: USER }) await engineB.runFullSync() engineB.dispose() for (let i = 0; i < 120; i++) remote.now() // 서버 시계 2분 경과 // 데스크톱 A: 몇 달 전 로컬 기록을 처음 올린다 const dbA = openDb(USER) const old = Date.parse('2026-03-01T00:00:00Z') const oldId = uuid() dbA.db.insert(history).values({ id: oldId, originalText: 'old recording', duration: 1, createdAt: old, updatedAt: old }).run() const engineA = new SyncEngine({ remote, userId: USER }) await engineA.runFullSync() engineA.dispose() expect(remote.find('history', oldId)).toBeDefined() // B의 다음 pull 이 받아야 한다 bindTestDatabase(dbB.db, USER) const engineB2 = new SyncEngine({ remote, userId: USER }) await engineB2.pull() engineB2.dispose() expect(dbB.db.select().from(history).where(eq(history.id, oldId)).get()?.originalText).toBe('old recording') }) }) describe('계정별 명령·템플릿', () => { it('다른 계정으로 로그인하면 이전 계정의 명령·템플릿이 보이지 않고 backfill 로 올라가지 않는다', async () => { openDb(USER) getCustomInstructionService().initialize() const mine = getCustomInstructionService().create({ name: 'A private', description: '', prompt: 'secret prompt of A' }) const dictation = getDictationTemplateService().create({ name: 'A tpl', description: '', fields: [], outputFormat: 'x' }) const meetingTpl = getMeetingDocTemplateService().create({ name: 'A doc', description: '', systemPrompt: 'p' }) // 로그아웃 → 로컬 모드: A의 항목이 보이지 않는다 openDb('_local') expect(getCustomInstructionService().getById(mine.id)).toBeNull() expect(getDictationTemplateService().getById(dictation.id)).toBeNull() expect(getMeetingDocTemplateService().getById(meetingTpl.id)).toBeNull() // B 로그인 openDb(USER_B) const remote = new FakeSyncRemote(USER_B) const engine = new SyncEngine({ remote, userId: USER_B }) await engine.runFullSync() engine.dispose() expect(getCustomInstructionService().getById(mine.id)).toBeNull() expect(remote.find('custom_instructions', mine.id)).toBeUndefined() expect(remote.find('user_templates', dictation.id)).toBeUndefined() expect(remote.find('user_templates', meetingTpl.id)).toBeUndefined() expect(getCustomInstructionService().getAll().filter((i) => i.isBuiltin)).toHaveLength(5) // A 다시 로그인: A의 항목이 돌아온다 openDb(USER) expect(getCustomInstructionService().getById(mine.id)?.prompt).toBe('secret prompt of A') expect(getDictationTemplateService().getById(dictation.id)).not.toBeNull() }) it('로컬 모드에서 만든 명령은 처음 로그인한 계정 하나만 가져간다', () => { openDb('_local') getCustomInstructionService().initialize() const local = getCustomInstructionService().create({ name: 'local', description: '', prompt: 'p' }) openDb(USER) expect(getCustomInstructionService().getById(local.id)).not.toBeNull() openDb(USER_B) expect(getCustomInstructionService().getById(local.id)).toBeNull() }) it('다른 계정 소유 템플릿(P0002)은 재시도 불가로 보관되고, 일반 P0002 는 여전히 재시도한다', () => { const err = templateUpsertError({ code: 'P0002', message: 'template_not_found' }) expect(err.retryable).toBe(false) expect(toSyncRemoteError({ code: 'P0002', message: 'history_not_found' }).retryable).toBe(true) expect(templateUpsertError(new SyncRemoteError('fetch failed', 'network', true)).retryable).toBe(true) }) }) describe('미룬 행과 커서', () => { it('부모 회의가 늦게 온 메모는 커서가 지나가도 다음 pull 에서 반영된다', async () => { openDb(USER) const remote = new FakeSyncRemote(USER) const meetingId = uuid() const memoId = uuid() // 메모가 먼저 서버에 보이고(회의 행은 아직 없음) remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'late parent', timestamp_ms: 1 }) for (let i = 0; i < 120; i++) remote.now() // 더 새 메모(다른 회의)로 memo 커서가 2분 앞으로 간다 const otherMeeting = uuid() remote.mobileInsert('meetings', { id: otherMeeting, title: 'other', status: 'completed', started_at: remote.now() }) remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: otherMeeting, content: 'newer', timestamp_ms: 1 }) const engine = new SyncEngine({ remote, userId: USER }) const first = await engine.runFullSync() expect(first.errors).toEqual([]) expect(testMemo(memoId)).toBeUndefined() // 부모 회의가 도착 remote.mobileInsert('meetings', { id: meetingId, title: 'late', status: 'completed', started_at: remote.now() }) await engine.pull() engine.dispose() expect(testMemo(memoId)?.content).toBe('late parent') }) it('회의 pull 이 실패하면 같은 실행에서 메모 커서를 넘기지 않는다', async () => { openDb(USER) const remote = new GatedRemote(USER) const meetingId = uuid() remote.mobileInsert('meetings', { id: meetingId, title: 'm', status: 'completed', started_at: remote.now() }) const memoId = uuid() remote.mobileInsert('meeting_memos', { id: memoId, meeting_id: meetingId, content: 'child', timestamp_ms: 1 }) let failMeetings = true remote.gate = (request) => { if (request.table === 'meetings' && failMeetings) throw new SyncRemoteError('fetch failed', 'network', true) } const engine = new SyncEngine({ remote, userId: USER }) const failed = await engine.pull() expect(failed.errors.some((e) => e.startsWith('meetings'))).toBe(true) expect(remote.calls.filter((c) => c === 'fetch:meeting_memos')).toEqual([]) failMeetings = false await engine.pull() engine.dispose() expect(testMemo(memoId)?.content).toBe('child') }) }) function testMemo(id: string): { content: string } | undefined { return dbs[dbs.length - 1].db.select().from(meetingMemos).where(eq(meetingMemos.id, id)).get() } describe('push 전에 원격 삭제 반영', () => { it('폰에서 지운 행의 오프라인 편집이 서버에 행을 되살리지 않는다', async () => { const d = openDb(USER) const remote = new FakeSyncRemote(USER) const id = uuid() remote.mobileInsert('history', { id, original_text: 'orig', duration: 1, mode: 'dictation', status: 'completed' }) const engine = new SyncEngine({ remote, userId: USER }) await engine.runFullSync() expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeDefined() // 폰이 지우고, 데스크톱은 오프라인에서 편집해 대기 upsert 가 있다 remote.mobileDelete('history', id) d.db.update(history).set({ title: 'edited offline', updatedAt: Date.now() }).where(eq(history.id, id)).run() enqueueChange('history', id, 'upsert') const result = await engine.flush() engine.dispose() expect(remote.find('history', id)).toBeUndefined() expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined() expect(pendingOps('history').has(id)).toBe(false) expect(result.deleted).toBe(1) }) it('최초 대조(backfill)는 서버에서 지워진 행을 다시 올리지 않는다', async () => { const d = openDb(USER) const remote = new FakeSyncRemote(USER) const id = uuid() remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' }) remote.mobileDelete('history', id) const at = Date.now() d.db.insert(history).values({ id, originalText: 'x', duration: 1, createdAt: at, updatedAt: at }).run() const engine = new SyncEngine({ remote, userId: USER }) await engine.runFullSync() engine.dispose() expect(remote.find('history', id)).toBeUndefined() expect(d.db.select().from(history).where(eq(history.id, id)).get()).toBeUndefined() expect(outboxCounts()).toEqual({ pending: 0, parked: 0 }) }) }) describe('명령 길이 제한', () => { it('서버 제한을 넘는 명령은 만들거나 고칠 수 없다', () => { openDb(USER) const svc = getCustomInstructionService() svc.initialize() expect(() => svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/) expect(() => svc.create({ name: 'n'.repeat(INSTRUCTION_LIMITS.name + 1), description: '', prompt: 'p' })).toThrow(/too long/) const ok = svc.create({ name: 'n', description: '', prompt: 'x'.repeat(INSTRUCTION_LIMITS.prompt) }) expect(() => svc.update(ok.id, { prompt: 'y'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toThrow(/too long/) expect(svc.getById(ok.id)?.prompt).toHaveLength(INSTRUCTION_LIMITS.prompt) }) it('이미 저장된 긴 명령은 잘라 올리지 않고 재시도 불가 오류로 보관된다', async () => { openDb(USER) const svc = getCustomInstructionService() svc.initialize() const longPrompt = 'z'.repeat(6000) const id = uuid() svc.applyRemote({ id, name: 'legacy', description: '', prompt: longPrompt, icon: 'x', isBuiltin: false, order: 9, createdAt: 1, updatedAt: 1 }) const instruction = svc.getById(id) expect(instruction).not.toBeNull() expect(instructionPushError(instruction!)?.retryable).toBe(false) const remote = new FakeSyncRemote(USER) const engine = new SyncEngine({ remote, userId: USER }) await engine.runFullSync() engine.dispose() expect(remote.find('custom_instructions', id)).toBeUndefined() expect(svc.getById(id)?.prompt).toBe(longPrompt) }) }) describe('엔티티 레지스트리', () => { it('모든 엔티티가 정확히 한 번 등록되고 부모가 자식보다 먼저 push 된다', () => { const entities = SYNC_REGISTRY.map((e) => e.entity) expect([...entities].sort()).toEqual([...SYNC_ENTITIES].sort()) expect(new Set(entities).size).toBe(entities.length) for (const spec of SYNC_ENTITY_SPECS) { const parent = parentOf(spec.entity) if (!parent) continue expect(SYNC_ENTITIES).toContain(parent) expect(entities.indexOf(parent)).toBeLessThan(entities.indexOf(spec.entity)) } }) it('push·delete 순서와 Realtime 대상이 이전과 같다', () => { expect(upsertOrder().map((e) => e.entity)).toEqual([ 'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'custom_instructions', 'builtin_instructions', 'user_settings', 'user_templates', 'knowledge_documents', 'memo_tags', 'history_audio', 'meeting_audio', ]) expect(deleteOrder().map((e) => e.entity)).toEqual([ 'memo_tags', 'knowledge_documents', 'user_templates', 'custom_instructions', 'meeting_documents', 'meeting_memos', 'meetings', 'dictionary', 'history', ]) expect([...realtimeTables()].sort()).toEqual( [ 'history', 'dictionary', 'meetings', 'meeting_memos', 'meeting_documents', 'memo_tags', 'custom_instructions', 'user_templates', 'knowledge_documents', 'user_settings', 'sync_tombstones', ].sort() ) }) })