// 레드팀 r3-2: 다른 기기에서 재색인한 지식 문서가 이미 문서를 가진 데스크톱에도 반영된다. // - 원문이 바뀌었으면 청크를 교체하고(옛 임베딩 제거) 다시 색인한다 // - 원문이 같으면(자기 push의 에코 등) 로컬 청크·임베딩을 건드리지 않는다 // - 재업로드가 중간이면 잘린 청크로 덮지 않고 미뤘다가 완성본을 반영한다 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 { 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 { planRemoteDocument, sameChunks, usableChunks } from '../../../src/main/services/rag/remote-document' const USER = '11111111-1111-4111-8111-111111111111' let testDb: ReturnType let remote: FakeSyncRemote let engine: SyncEngine beforeEach(() => { testDb = createTestDb() bindTestDatabase(testDb.db, USER) initInMemoryConfig() resetCustomInstructionServiceForTests() resetDictationTemplateServiceForTests() resetMeetingDocTemplateServiceForTests() resetRAGServiceForTests() getCustomInstructionService().initialize() // 임베딩 서버(Ollama)는 없다 — 원문만 저장되는 경로를 탄다. vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed')) remote = new FakeSyncRemote(USER) engine = new SyncEngine({ remote, userId: USER }) }) afterEach(() => { vi.restoreAllMocks() engine.dispose() unbindTestDatabase() resetInMemoryConfig() resetRAGServiceForTests() testDb.close() }) function localChunks(id: string): Array<{ content: string; embedding: string }> { return testDb.db .select() .from(ragChunks) .where(eq(ragChunks.documentId, id)) .all() .sort((a, b) => a.chunkIndex - b.chunkIndex) .map((c) => ({ content: c.content, embedding: c.embedding })) } function markEmbedded(id: string): void { testDb.db.update(ragChunks).set({ embedding: '[1,2]' }).where(eq(ragChunks.documentId, id)).run() testDb.db.update(ragDocuments).set({ indexed: true, indexedAt: 1 }).where(eq(ragDocuments.id, id)).run() } /** 다른 데스크톱(A)의 재색인 push를 흉내: 행 upsert(updated_at 이동) → 청크 전부 교체 */ function remoteReindex(id: string, chunks: string[], uploaded = chunks.length): void { remote.mobileUpdate('knowledge_documents', id, { chunk_count: chunks.length }) const rows = remote.rows('knowledge_chunks') for (let i = rows.length - 1; i >= 0; i--) if (rows[i].document_id === id) rows.splice(i, 1) chunks.slice(0, uploaded).forEach((content, chunk_index) => rows.push({ id: crypto.randomUUID(), document_id: id, chunk_index, content }) ) } function uploadRest(id: string, chunks: string[], from: number): void { chunks.slice(from).forEach((content, offset) => remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: from + offset, content }) ) } async function seedSyncedDocument(chunks: string[]): Promise { const id = crypto.randomUUID() remote.mobileInsert('knowledge_documents', { id, title: 'Doc', file_name: 'doc.md', file_type: 'md', chunk_count: chunks.length }) chunks.forEach((content, chunk_index) => remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index, content }) ) await engine.runFullSync() expect(localChunks(id).map((c) => c.content)).toEqual(chunks) // 받은 직후 시작된 색인 시도(임베딩 서버 없음)가 끝난 뒤에 "이미 색인됨" 상태를 만든다 for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)) markEmbedded(id) return id } describe('재색인된 지식 문서 동기화', () => { it('다른 기기에서 원문이 바뀌면 이미 있는 문서의 청크를 교체하고 옛 임베딩을 버린다', async () => { const id = await seedSyncedDocument(['old intro', 'old body']) remoteReindex(id, ['new intro', 'new body', 'new appendix']) await engine.pull() const chunks = localChunks(id) expect(chunks.map((c) => c.content)).toEqual(['new intro', 'new body', 'new appendix']) // 옛 벡터가 새 원문에 붙어 검색되지 않는다(임베딩 서버가 없어 아직 비어 있다) expect(chunks.every((c) => c.embedding === '')).toBe(true) const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get() expect(doc?.chunkCount).toBe(3) expect(doc?.indexed).toBe(false) }) it('원문이 같으면(에코·메타데이터만 변경) 로컬 청크와 임베딩을 그대로 둔다', async () => { const id = await seedSyncedDocument(['same a', 'same b']) remote.mobileUpdate('knowledge_documents', id, { indexed: true }) await engine.pull() expect(localChunks(id)).toEqual([ { content: 'same a', embedding: '[1,2]' }, { content: 'same b', embedding: '[1,2]' }, ]) expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()?.indexed).toBe(true) }) it('재업로드가 중간이면 잘린 청크로 덮지 않고, 완성되면 반영한다', async () => { const id = await seedSyncedDocument(['v1 a', 'v1 b']) const next = ['v2 a', 'v2 b', 'v2 c'] remoteReindex(id, next, 1) await engine.pull() expect(localChunks(id).map((c) => c.content)).toEqual(['v1 a', 'v1 b']) uploadRest(id, next, 1) await engine.pull() expect(localChunks(id).map((c) => c.content)).toEqual(next) }) }) describe('planRemoteDocument', () => { it('로컬에 없으면 insert, 같으면 skip, 다르면 replace', () => { expect(planRemoteDocument(null, ['a', ' ', 'b'])).toEqual({ kind: 'insert', chunks: ['a', 'b'] }) expect(planRemoteDocument(['a', 'b'], ['a', 'b'])).toEqual({ kind: 'skip' }) expect(planRemoteDocument(['a', 'b'], ['a', 'c'])).toEqual({ kind: 'replace', chunks: ['a', 'c'] }) expect(planRemoteDocument(['a'], ['a', 'b'])).toEqual({ kind: 'replace', chunks: ['a', 'b'] }) }) it('받은 청크가 전부 비어 있으면 로컬을 비우지 않는다', () => { expect(planRemoteDocument(['a'], ['', ' '])).toEqual({ kind: 'skip' }) expect(planRemoteDocument(null, [])).toEqual({ kind: 'skip' }) }) it('빈 청크는 비교에서 빠진다', () => { expect(usableChunks(['a', '', ' b '])).toEqual(['a', ' b ']) expect(planRemoteDocument(['a', ''], ['a'])).toEqual({ kind: 'skip' }) expect(sameChunks(['a'], ['a', 'b'])).toBe(false) }) })