// 레드팀 r2-0: 원격 삭제(tombstone) 반영의 복원·보존 기간 경계, 지식 문서 청크 완전성. import { afterEach, beforeEach, describe, expect, it, vi } 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, ragChunks, ragDocuments } from '../../../src/main/db/schema' import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' import { getCustomInstructionService, resetCustomInstructionServiceForTests, } from '../../../src/main/services/CustomInstructionService' import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService' import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService' import { resetRAGServiceForTests } from '../../../src/main/services/RAGService' import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' import { completeKnowledgeChunks } from '../../../src/main/services/sync/sync-adapters' import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox' import { TOMBSTONE_RETENTION_MS, earliestDeletedAt, isRestoredAfter, remoteTimeAfter, tombstoneWindowExpired, } from '../../../src/main/services/sync/tombstone-policy' import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port' const USER = '11111111-1111-4111-8111-111111111111' const DAY = 24 * 60 * 60 * 1000 let testDb: ReturnType let remote: FakeSyncRemote let engine: SyncEngine let clock: number const offlineEmbedder: EmbeddingPort = { model: 'test-embed', ensureModel: () => Promise.reject(new Error('no embedding server in tests')), embed: () => Promise.reject(new Error('no embedding server in tests')), } function makeEngine(): SyncEngine { return new SyncEngine({ remote, userId: USER, now: () => clock }) } beforeEach(() => { testDb = createTestDb() bindTestDatabase(testDb.db, USER) initInMemoryConfig() resetCustomInstructionServiceForTests() resetDictationTemplateServiceForTests() resetMeetingDocTemplateServiceForTests() resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 }) getCustomInstructionService().initialize() remote = new FakeSyncRemote(USER) clock = Date.parse('2026-09-27T00:00:00.000Z') engine = makeEngine() }) afterEach(() => { vi.restoreAllMocks() engine.dispose() resetRAGServiceForTests() unbindTestDatabase() resetInMemoryConfig() testDb.close() }) function historyRow(id: string, text: string): Record { return { id, original_text: text, duration: 1, mode: 'dictation', status: 'completed' } } function localHistoryIds(): string[] { return testDb.db.select({ id: history.id }).from(history).all().map((r) => r.id).sort() } describe('tombstone 뒤 계정 보관본 복원', () => { it('삭제를 이미 반영한 데스크톱: 복원된 행을 받고, 이후 pull의 겹쳐 읽기가 다시 지우지 않는다', async () => { const x = crypto.randomUUID() remote.mobileInsert('history', historyRow(x, 'keep me')) await engine.runFullSync() expect(localHistoryIds()).toEqual([x]) remote.mobileDelete('history', x) await engine.pull() expect(localHistoryIds()).toEqual([]) // restore_account_portability: 같은 id로 다시 넣고, 0037 이후 서버가 updated_at을 새로 찍는다. remote.mobileInsert('history', historyRow(x, 'keep me')) await engine.pull() expect(localHistoryIds()).toEqual([x]) // tombstone 커서는 여전히 T1 근처라 다음 pull은 같은 tombstone을 다시 읽는다. await engine.pull() await engine.pull() expect(localHistoryIds()).toEqual([x]) }) it('삭제와 복원 사이 오프라인이던 데스크톱: 첫 pull에서 복원된 행을 지우지 않는다', async () => { const x = crypto.randomUUID() remote.mobileInsert('history', historyRow(x, 'v1')) await engine.runFullSync() remote.mobileDelete('history', x) remote.mobileInsert('history', historyRow(x, 'restored')) const result = await engine.pull() expect(result.deleted).toBe(0) expect(localHistoryIds()).toEqual([x]) expect(testDb.db.select().from(history).where(eq(history.id, x)).get()?.originalText).toBe('restored') }) it('runFullSync(push 전 tombstone + pull 안의 tombstone)도 복원된 행을 지우지 않는다', async () => { const x = crypto.randomUUID() remote.mobileInsert('history', historyRow(x, 'v1')) await engine.runFullSync() remote.mobileDelete('history', x) remote.mobileInsert('history', historyRow(x, 'restored')) await engine.runFullSync() await engine.runFullSync() expect(localHistoryIds()).toEqual([x]) }) it('복원되지 않은 삭제는 그대로 반영하고, 로컬 대기 변경보다 우선한다', async () => { const kept = crypto.randomUUID() const gone = crypto.randomUUID() remote.mobileInsert('history', historyRow(kept, 'k')) remote.mobileInsert('history', historyRow(gone, 'g')) await engine.runFullSync() enqueueChange('history', gone, 'upsert') remote.mobileDelete('history', gone) remote.mobileDelete('history', kept) remote.mobileInsert('history', historyRow(kept, 'k restored')) await engine.pull() expect(localHistoryIds()).toEqual([kept]) expect(pendingOps('history').has(gone)).toBe(false) }) }) describe('tombstone 보존 기간(180일) 초과', () => { function pruneAllTombstones(): void { remote.rows('sync_tombstones').splice(0) } it('보존 기간보다 오래 쉰 기기는 서버에 없는 로컬 행을 지워 전체 대조한다', async () => { const stale = crypto.randomUUID() const alive = crypto.randomUUID() remote.mobileInsert('history', historyRow(stale, 'deleted on phone long ago')) remote.mobileInsert('history', historyRow(alive, 'still here')) await engine.runFullSync() // 다음 tombstone 커서가 생기도록 다른 행 하나를 지워 둔다 const other = crypto.randomUUID() remote.mobileInsert('history', historyRow(other, 'other')) await engine.pull() remote.mobileDelete('history', other) await engine.pull() remote.mobileDelete('history', stale) pruneAllTombstones() clock += TOMBSTONE_RETENTION_MS + DAY const result = await engine.pull() expect(result.errors).toEqual([]) expect(localHistoryIds()).toEqual([alive]) expect(result.deleted).toBe(1) // 편집해도 지운 행이 서버에 되살아나지 않는다(로컬에 없다) expect(remote.find('history', stale)).toBeUndefined() }) it('아직 올리지 않은 로컬 변경이 있는 행은 전체 대조가 지우지 않는다', async () => { const seed = crypto.randomUUID() remote.mobileInsert('history', historyRow(seed, 'seed')) await engine.runFullSync() remote.mobileDelete('history', seed) await engine.pull() const local = crypto.randomUUID() const at = clock testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run() enqueueChange('history', local, 'upsert') pruneAllTombstones() clock += TOMBSTONE_RETENTION_MS + DAY await engine.pull() expect(localHistoryIds()).toEqual([local]) }) it('보존 기간 안이면 전체 대조를 하지 않는다(서버에 없는 행을 함부로 지우지 않는다)', async () => { const seed = crypto.randomUUID() remote.mobileInsert('history', historyRow(seed, 'seed')) await engine.runFullSync() remote.mobileDelete('history', seed) await engine.pull() const local = crypto.randomUUID() testDb.db.insert(history).values({ id: local, originalText: 'x', duration: 1, createdAt: clock, updatedAt: clock }).run() clock += 30 * DAY await engine.pull() expect(localHistoryIds()).toEqual([local]) }) it('한 번 대조하면 다음 pull부터는 다시 대조하지 않는다', async () => { const seed = crypto.randomUUID() remote.mobileInsert('history', historyRow(seed, 'seed')) await engine.runFullSync() remote.mobileDelete('history', seed) await engine.pull() clock += TOMBSTONE_RETENTION_MS + DAY await engine.pull() const fresh = crypto.randomUUID() testDb.db.insert(history).values({ id: fresh, originalText: 'y', duration: 1, createdAt: clock, updatedAt: clock }).run() clock += DAY await engine.pull() expect(localHistoryIds()).toEqual([fresh]) }) }) describe('tombstone 정책 (순수 함수)', () => { it('서버 시각 비교는 마이크로초까지 본다', () => { expect(remoteTimeAfter('2026-09-27T00:00:00.000200+00:00', '2026-09-27T00:00:00.000100+00:00')).toBe(true) expect(remoteTimeAfter('2026-09-27T00:00:01Z', '2026-09-27T00:00:02Z')).toBe(false) expect(remoteTimeAfter('bad', '2026-09-27T00:00:02Z')).toBe(false) }) it('복원 판정과 가장 이른 삭제 시각', () => { const ref = { rowId: 'a', deletedAt: '2026-09-27T00:00:05Z' } expect(isRestoredAfter(ref, '2026-09-27T00:00:06Z')).toBe(true) expect(isRestoredAfter(ref, '2026-09-27T00:00:04Z')).toBe(false) expect(isRestoredAfter({ rowId: 'a', deletedAt: null }, '2026-09-27T00:00:06Z')).toBe(false) expect(earliestDeletedAt([ref, { rowId: 'b', deletedAt: '2026-09-27T00:00:01Z' }, { rowId: 'c', deletedAt: null }])).toBe( '2026-09-27T00:00:01Z' ) }) it('보존 기간 창', () => { const now = Date.parse('2026-09-27T00:00:00Z') expect(tombstoneWindowExpired(null, now)).toBe(false) expect(tombstoneWindowExpired(now - 30 * DAY, now)).toBe(false) expect(tombstoneWindowExpired(now - 179 * DAY, now)).toBe(true) }) }) describe('지식 문서 청크 완전성', () => { it('문서 행의 chunk_count보다 적은 청크만 올라와 있으면 미루고, 다 올라오면 전부 저장한다', async () => { const id = crypto.randomUUID() remote.mobileInsert('knowledge_documents', { id, title: 'Big', file_name: 'big.txt', file_type: 'txt', chunk_count: 3 }) remote.rows('knowledge_chunks').push( { id: crypto.randomUUID(), document_id: id, chunk_index: 0, content: 'c0' }, { id: crypto.randomUUID(), document_id: id, chunk_index: 1, content: 'c1' } ) await engine.runFullSync() expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()).toBeUndefined() remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: 2, content: 'c2' }) await engine.pull() const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get() expect(doc?.chunkCount).toBe(3) const chunks = testDb.db.select().from(ragChunks).where(eq(ragChunks.documentId, id)).all() expect(chunks.sort((a, b) => a.chunkIndex - b.chunkIndex).map((c) => c.content)).toEqual(['c0', 'c1', 'c2']) }) it('completeKnowledgeChunks: 개수·연속 index를 확인한다', () => { const rows = [ { chunk_index: 1, content: 'b' }, { chunk_index: 0, content: 'a' }, ] expect(completeKnowledgeChunks(rows, 2)).toEqual(['a', 'b']) expect(completeKnowledgeChunks(rows, 3)).toBeNull() expect(completeKnowledgeChunks(rows, null)).toEqual(['a', 'b']) expect(completeKnowledgeChunks([{ chunk_index: 0, content: 'a' }, { chunk_index: 2, content: 'c' }], null)).toBeNull() expect(completeKnowledgeChunks([{ chunk_index: 0, content: 'a' }, { chunk_index: 0, content: 'a' }], 2)).toBeNull() expect(completeKnowledgeChunks([], 0)).toBeNull() expect(completeKnowledgeChunks([{ chunk_index: 0, content: null }], 1)).toBeNull() }) })