// 동기화 확장 — 지식베이스(원문 청크), 모바일 user_settings, 녹음 파일 업로드. import fs from 'fs' import os from 'os' import path from 'path' 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 { configGet, configSet, 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 { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox' import { remoteToLocalPatch, toRemoteSettings } from '../../../src/main/services/sync/settings-sync' const USER = '11111111-1111-4111-8111-111111111111' let testDb: ReturnType let remote: FakeSyncRemote let engine: SyncEngine let tmpDir: string 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 }) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-sync-audio-')) }) afterEach(() => { vi.restoreAllMocks() engine.dispose() unbindTestDatabase() resetInMemoryConfig() testDb.close() fs.rmSync(tmpDir, { recursive: true, force: true }) }) function insertLocalDocument(chunks: string[]): string { const id = crypto.randomUUID() testDb.db .insert(ragDocuments) .values({ id, fileName: 'notes.md', filePath: '', fileType: 'md', chunkCount: chunks.length, indexed: false, addedAt: Date.now() }) .run() chunks.forEach((content, chunkIndex) => { testDb.db.insert(ragChunks).values({ id: crypto.randomUUID(), documentId: id, content, embedding: '', chunkIndex }).run() }) return id } describe('지식베이스', () => { it('데스크톱 문서를 원문 청크로 올리고 서버 임베딩을 요청한다', async () => { const id = insertLocalDocument(['first chunk text', 'second chunk text']) const result = await engine.runFullSync() expect(result.errors).toEqual([]) expect(remote.find('knowledge_documents', id)?.chunk_count).toBe(2) expect(remote.rows('knowledge_chunks').filter((c) => c.document_id === id).map((c) => c.content)).toEqual([ 'first chunk text', 'second chunk text', ]) expect(remote.invoked).toContain(`embed-chunks:${id}`) }) it('모바일 문서를 받아 원문을 저장하고, 모바일 삭제를 반영한다', async () => { const id = crypto.randomUUID() remote.mobileInsert('knowledge_documents', { id, title: 'Phone doc', file_name: 'phone.txt', file_type: 'txt', chunk_count: 2 }) remote.rows('knowledge_chunks').push( { id: crypto.randomUUID(), document_id: id, chunk_index: 1, content: 'second' }, { id: crypto.randomUUID(), document_id: id, chunk_index: 0, content: 'first' } ) await engine.runFullSync() const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get() expect(doc?.fileName).toBe('phone.txt') expect(doc?.filePath).toBe('') 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(['first', 'second']) remote.mobileDelete('knowledge_documents', id) await engine.pull() expect(testDb.db.select().from(ragDocuments).all()).toEqual([]) expect(testDb.db.select().from(ragChunks).all()).toEqual([]) }) }) describe('설정 (user_settings)', () => { it('서버에 행이 없으면 데스크톱 값을 올린다', async () => { configSet('language', 'en') configSet('theme', 'dark') configSet('defaultLLMAction', 'none') await engine.runFullSync() const row = remote.rows('user_settings')[0] expect(row).toMatchObject({ locale: 'en', theme_mode: 'dark', auto_polish_enabled: false }) }) it('서버에 행이 있으면 모바일 값이 이기고, 반영이 다시 올라가지 않는다', async () => { configSet('language', 'en') configSet('theme', 'auto') configSet('defaultLLMAction', 'none') remote.mobileInsert('user_settings', { locale: 'ko', theme_mode: 'light', auto_polish_enabled: true, revision: 4, active_instruction_id: null }) const result = await engine.runFullSync() expect(result.changed).toContain('user_settings') expect(configGet('language')).toBe('ko') expect(configGet('theme')).toBe('light') expect(configGet('defaultLLMAction')).toBe('refine') expect(pendingOps('user_settings').size).toBe(0) expect(remote.rows('user_settings')[0].revision).toBe(4) }) it('데스크톱 변경은 revision 조건부로 올리고, 같은 값이면 쓰지 않는다', async () => { remote.mobileInsert('user_settings', { locale: 'ko', theme_mode: 'system', auto_polish_enabled: true, revision: 2, active_instruction_id: null }) await engine.runFullSync() configSet('theme', 'dark') enqueueChange('user_settings', 'self', 'upsert') await engine.flush() expect(remote.rows('user_settings')[0]).toMatchObject({ theme_mode: 'dark', revision: 3 }) enqueueChange('user_settings', 'self', 'upsert') await engine.flush() expect(remote.rows('user_settings')[0].revision).toBe(3) expect(outboxCounts().pending).toBe(0) }) it('양쪽에 있는 사용자 명령만 활성 명령으로 맞춘다', async () => { const command = getCustomInstructionService().create({ name: 'Calm', description: '', prompt: 'Make it calm' }) await engine.runFullSync() const row = remote.rows('user_settings')[0] Object.assign(row, { active_instruction_id: command.id, revision: Number(row.revision) + 1 }) await engine.pull() expect(configGet('activeInstructionId')).toBe(command.id) // 데스크톱 프리셋이 켜져 있으면 모바일의 '없음'이 덮지 않는다 const local = { language: 'ko', theme: 'auto' as const, defaultLLMAction: 'refine' as const, activeInstructionId: 'builtin-translate' } expect(remoteToLocalPatch({ active_instruction_id: null }, local, new Set([command.id]))).toEqual({}) }) it('데스크톱 전용 테마·언어는 서버로 보내지 않는다', () => { expect(toRemoteSettings({ language: 'ja', theme: 'nord', defaultLLMAction: 'translate', activeInstructionId: '' })).toEqual({ auto_polish_enabled: true, }) }) }) describe('녹음 파일', () => { function insertHistoryWithAudio(): { id: string; file: string } { const id = crypto.randomUUID() const file = path.join(tmpDir, `${id}.wav`) fs.writeFileSync(file, Buffer.from('RIFF....WAVEfmt fake audio')) const now = Date.now() testDb.db.insert(history).values({ id, originalText: 'spoken', duration: 2, audioLocalPath: file, createdAt: now, updatedAt: now }).run() return { id, file } } it('기록 녹음을 모바일과 같은 경로·행으로 올리고, 기록을 지우면 파일도 지운다', async () => { const { id } = insertHistoryWithAudio() const result = await engine.runFullSync() expect(result.errors).toEqual([]) const key = `${USER}/desktop/history/${id}.wav` expect(remote.objects.has(`audio/${key}`)).toBe(true) expect(remote.rows('audio_files')[0]).toMatchObject({ history_id: id, storage_key: key, mime_type: 'audio/wav', upload_status: 'uploaded', duration_ms: 2000, }) // 같은 파일은 다시 올리지 않는다 enqueueChange('history_audio', id, 'upsert') await engine.flush() expect(remote.rows('audio_files')).toHaveLength(1) testDb.db.delete(history).where(eq(history.id, id)).run() enqueueChange('history', id, 'delete') await engine.flush() expect(remote.objects.size).toBe(0) expect(remote.rows('audio_files')).toHaveLength(0) }) it('녹음 동기화를 끄면 올리지 않는다', async () => { configSet('cloudSyncAudio', false) insertHistoryWithAudio() await engine.runFullSync() expect(remote.objects.size).toBe(0) expect(outboxCounts().pending).toBe(0) }) })