feat(desktop): sync knowledge, recordings and shared settings; play any recording
Knowledge documents travel as source-text chunks; each surface embeds them with its own model, the server index is requested through embed-chunks, and documents from the phone are stored without a file and indexed from their chunks. Chunk text is now kept when local embedding fails, so reindexing no longer needs the original file. Recordings upload to the mobile storage contract (audio bucket under the user's folder plus an audio_files row, 50 MiB cap, a Settings > Cloud toggle) and are removed with their record. The history card gains a play button that uses the local file or, for phone recordings, a signed URL. Language (ko/en), system/light/dark theme, auto-polish and the active user command follow the phone's user_settings with its revision rule; changes that arrive from the phone reach the open window.
This commit is contained in:
parent
cee4ab9317
commit
9a8f7e6aa6
34 changed files with 1405 additions and 64 deletions
|
|
@ -17,6 +17,9 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
private clock = Date.parse('2026-09-27T00:00:00.000Z')
|
||||
private tombstoneSeq = 0
|
||||
networkDown = false
|
||||
/** 업로드된 저장소 객체: `${bucket}/${key}` → 바이트 */
|
||||
readonly objects = new Map<string, Uint8Array>()
|
||||
readonly invoked: string[] = []
|
||||
/** 특정 행 upsert를 거부시키는 훅 (재시도 불가 오류 시뮬레이션) */
|
||||
rejectRow: ((table: string, row: RemoteRow) => SyncRemoteError | null) | null = null
|
||||
readonly calls: string[] = []
|
||||
|
|
@ -106,7 +109,71 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
.map((r) => ({ ...r }))
|
||||
}
|
||||
|
||||
async upsert(table: string, rows: RemoteRow[]): Promise<void> {
|
||||
async selectChildren(
|
||||
table: string,
|
||||
parentColumn: string,
|
||||
parentId: string,
|
||||
_columns: string,
|
||||
orderBy: string
|
||||
): Promise<RemoteRow[]> {
|
||||
this.guard()
|
||||
return this.rows(table)
|
||||
.filter((r) => r[parentColumn] === parentId)
|
||||
.sort((a, b) => Number(a[orderBy]) - Number(b[orderBy]))
|
||||
.map((r) => ({ ...r }))
|
||||
}
|
||||
|
||||
async insert(table: string, rows: RemoteRow[]): Promise<void> {
|
||||
this.guard()
|
||||
this.calls.push(`insert:${table}:${rows.length}`)
|
||||
for (const row of rows) {
|
||||
if (table === 'user_settings' && this.rows(table).some((r) => r.user_id === row.user_id)) {
|
||||
throw new SyncRemoteError('duplicate key value', '23505', false)
|
||||
}
|
||||
const at = this.now()
|
||||
this.rows(table).push({ id: crypto.randomUUID(), revision: 1, created_at: at, updated_at: at, ...row })
|
||||
}
|
||||
}
|
||||
|
||||
async updateMatching(
|
||||
table: string,
|
||||
userId: string,
|
||||
match: Record<string, string | number>,
|
||||
patch: RemoteRow
|
||||
): Promise<number> {
|
||||
this.guard()
|
||||
this.calls.push(`update:${table}`)
|
||||
const rows = this.rows(table).filter(
|
||||
(r) => r.user_id === userId && Object.entries(match).every(([k, v]) => r[k] === v)
|
||||
)
|
||||
for (const row of rows) Object.assign(row, patch, { updated_at: this.now() })
|
||||
return rows.length
|
||||
}
|
||||
|
||||
async deleteChildren(table: string, parentColumn: string, parentId: string): Promise<void> {
|
||||
this.guard()
|
||||
const rows = this.rows(table)
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][parentColumn] === parentId) rows.splice(i, 1)
|
||||
}
|
||||
|
||||
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
|
||||
this.guard()
|
||||
this.invoked.push(`${name}:${String(body.document_id ?? '')}`)
|
||||
return { indexed: true }
|
||||
}
|
||||
|
||||
async uploadObject(bucket: string, key: string, bytes: Uint8Array): Promise<void> {
|
||||
this.guard()
|
||||
if (!key.startsWith(`${this.userId}/`)) throw new SyncRemoteError('row-level security', '42501', false)
|
||||
this.objects.set(`${bucket}/${key}`, bytes)
|
||||
}
|
||||
|
||||
async removeObjects(bucket: string, keys: string[]): Promise<void> {
|
||||
this.guard()
|
||||
for (const key of keys) this.objects.delete(`${bucket}/${key}`)
|
||||
}
|
||||
|
||||
async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise<void> {
|
||||
this.guard()
|
||||
this.calls.push(`upsert:${table}:${rows.length}`)
|
||||
// 배치는 원자적이다: 하나라도 거부되면 전체 실패
|
||||
|
|
@ -126,15 +193,19 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
throw new SyncRemoteError('violates foreign key', '23503', true)
|
||||
}
|
||||
}
|
||||
const conflictColumns = onConflict.split(',')
|
||||
for (const row of rows) {
|
||||
const existing = this.find(table, String(row.id))
|
||||
const existing =
|
||||
onConflict === 'id'
|
||||
? this.find(table, String(row.id))
|
||||
: this.rows(table).find((r) => conflictColumns.every((c) => r[c] === row[c]))
|
||||
if (existing) {
|
||||
const changed = Object.keys(row).some((k) => k !== 'updated_at' && existing[k] !== row[k])
|
||||
Object.assign(existing, row, { updated_at: this.now() })
|
||||
if (table === 'history' && changed) existing.revision = Number(existing.revision ?? 1) + 1
|
||||
} else {
|
||||
const at = this.now()
|
||||
this.rows(table).push({ revision: 1, created_at: at, ...row, updated_at: row.updated_at ?? at })
|
||||
this.rows(table).push({ id: crypto.randomUUID(), revision: 1, created_at: at, ...row, updated_at: row.updated_at ?? at })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +239,10 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
if (table === 'history') {
|
||||
for (const t of this.rows('memo_tags').filter((r) => r.history_id === id)) this.removeRow('memo_tags', String(t.id))
|
||||
}
|
||||
if (table === 'knowledge_documents') {
|
||||
const chunks = this.rows('knowledge_chunks')
|
||||
for (let i = chunks.length - 1; i >= 0; i--) if (chunks[i].document_id === id) chunks.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async rpc(name: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
@ -213,6 +288,16 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
}
|
||||
return this.mobileInsert('user_templates', { id, revision: 1, ...fields })
|
||||
}
|
||||
if (name === 'set_active_custom_instruction') {
|
||||
const instructionId = params.instruction_id
|
||||
if (instructionId !== null && !this.find('custom_instructions', String(instructionId))) {
|
||||
throw new SyncRemoteError('instruction_not_found', 'P0002', true)
|
||||
}
|
||||
const row = this.rows('user_settings').find((r) => r.user_id === this.userId)
|
||||
if (row) Object.assign(row, { active_instruction_id: instructionId, revision: Number(row.revision) + 1, updated_at: this.now() })
|
||||
else this.mobileInsert('user_settings', { active_instruction_id: instructionId, revision: 1 })
|
||||
return row
|
||||
}
|
||||
if (name === 'sync_delete_user_template_v1') {
|
||||
const id = String(params.p_id)
|
||||
if (!this.find('user_templates', id)) return false
|
||||
|
|
|
|||
|
|
@ -8,14 +8,18 @@
|
|||
// vitest run tests/integration/cross-device-sync.supabase.test.ts
|
||||
// 환경변수가 없으면 건너뛴다(CI 기본).
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
import { nodeRealtimeTransport } from '../../src/main/services/sync/realtime-transport'
|
||||
import { createTestDb } from '../helpers/createTestDb'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../src/main/db'
|
||||
import { dictionary, history, meetingMemos, meetingSessions, memoTags } from '../../src/main/db/schema'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService'
|
||||
import { dictionary, history, meetingMemos, meetingSessions, memoTags, ragChunks, ragDocuments } from '../../src/main/db/schema'
|
||||
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService'
|
||||
import { resetRAGServiceForTests } from '../../src/main/services/RAGService'
|
||||
import {
|
||||
getCustomInstructionService,
|
||||
resetCustomInstructionServiceForTests,
|
||||
|
|
@ -252,6 +256,82 @@ describe.skipIf(!enabled)('cross-device sync against local Supabase', () => {
|
|||
expect(seen).toContain('tombstone')
|
||||
}, 45_000)
|
||||
|
||||
it('지식 문서(원문 청크)·설정·녹음이 실제 RLS/저장소 정책을 통과해 오간다', async () => {
|
||||
const db = testDb.db
|
||||
resetRAGServiceForTests()
|
||||
// 로컬 임베딩 서버(Ollama)는 없다고 보고 원문 저장 경로만 확인한다(Supabase 호출은 통과시킨다).
|
||||
const realFetch = globalThis.fetch
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) =>
|
||||
String(input instanceof Request ? input.url : input).includes('11434')
|
||||
? Promise.reject(new TypeError('fetch failed'))
|
||||
: realFetch(input, init)
|
||||
)
|
||||
|
||||
// 데스크톱 지식 문서
|
||||
const docId = crypto.randomUUID()
|
||||
db.insert(ragDocuments).values({ id: docId, fileName: 'desk.md', filePath: '', fileType: 'md', chunkCount: 2, indexed: false, addedAt: Date.now() }).run()
|
||||
;['alpha chunk', 'beta chunk'].forEach((content, chunkIndex) =>
|
||||
db.insert(ragChunks).values({ id: crypto.randomUUID(), documentId: docId, content, embedding: '', chunkIndex }).run()
|
||||
)
|
||||
enqueueChange('knowledge_documents', docId, 'upsert')
|
||||
|
||||
// 모바일 지식 문서
|
||||
const phoneDoc = crypto.randomUUID()
|
||||
must(await mobile.from('knowledge_documents').insert({ id: phoneDoc, user_id: userId, title: 'Phone doc', file_name: 'phone.txt', file_type: 'txt', chunk_count: 1 }))
|
||||
must(await mobile.from('knowledge_chunks').insert({ document_id: phoneDoc, chunk_index: 0, content: 'from the phone' }))
|
||||
|
||||
// 녹음이 있는 데스크톱 기록
|
||||
const audioDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-it-audio-'))
|
||||
const audioFile = path.join(audioDir, 'clip.wav')
|
||||
fs.writeFileSync(audioFile, Buffer.from('RIFF----WAVEfmt integration audio bytes'))
|
||||
const clipId = crypto.randomUUID()
|
||||
db.insert(history).values({ id: clipId, originalText: 'with audio', duration: 1, audioLocalPath: audioFile, createdAt: Date.now(), updatedAt: Date.now() }).run()
|
||||
enqueueChange('history', clipId, 'upsert')
|
||||
enqueueChange('history_audio', clipId, 'upsert')
|
||||
|
||||
// 설정: 데스크톱 변경 → 서버, 이후 모바일 변경 → 데스크톱
|
||||
configSet('theme', 'dark')
|
||||
enqueueChange('user_settings', 'self', 'upsert')
|
||||
|
||||
const pushed = await engine.flush()
|
||||
expect(pushed.errors).toEqual([])
|
||||
const remoteChunks = must(await mobile.from('knowledge_chunks').select('content').eq('document_id', docId).order('chunk_index'))
|
||||
expect(remoteChunks.map((c) => c.content)).toEqual(['alpha chunk', 'beta chunk'])
|
||||
const audio = must(await mobile.from('audio_files').select('storage_key,upload_status,mime_type').eq('history_id', clipId))
|
||||
expect(audio).toHaveLength(1)
|
||||
expect(audio[0].upload_status).toBe('uploaded')
|
||||
const signed = await mobile.storage.from('audio').createSignedUrl(String(audio[0].storage_key), 60)
|
||||
expect(signed.error).toBeNull()
|
||||
const settings = must(await mobile.from('user_settings').select('theme_mode,revision').eq('user_id', userId).single())
|
||||
expect(settings.theme_mode).toBe('dark')
|
||||
|
||||
must(
|
||||
await mobile
|
||||
.from('user_settings')
|
||||
.update({ locale: 'en', revision: Number(settings.revision) + 1 })
|
||||
.eq('user_id', userId)
|
||||
.eq('revision', settings.revision)
|
||||
)
|
||||
await engine.pull()
|
||||
expect(configGet('language')).toBe('en')
|
||||
const pulledChunks = db.select().from(ragChunks).where(eq(ragChunks.documentId, phoneDoc)).all()
|
||||
expect(pulledChunks.map((c) => c.content)).toEqual(['from the phone'])
|
||||
|
||||
// 모바일이 지식 문서를 지우면 데스크톱에서도 사라진다(삭제 기록)
|
||||
must(await mobile.from('knowledge_documents').delete().eq('id', phoneDoc))
|
||||
await engine.pull()
|
||||
expect(db.select().from(ragDocuments).where(eq(ragDocuments.id, phoneDoc)).all()).toEqual([])
|
||||
|
||||
// 데스크톱 기록 삭제 → 저장소 녹음도 정리
|
||||
db.delete(history).where(eq(history.id, clipId)).run()
|
||||
enqueueChange('history', clipId, 'delete')
|
||||
const deleted = await engine.flush()
|
||||
expect(deleted.errors).toEqual([])
|
||||
expect(must(await mobile.from('audio_files').select('id').eq('user_id', userId).eq('storage_key', String(audio[0].storage_key)))).toEqual([])
|
||||
fs.rmSync(audioDir, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
}, 60_000)
|
||||
|
||||
it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => {
|
||||
const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' }
|
||||
const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null)
|
||||
|
|
|
|||
203
apps/desktop/tests/main/sync/SyncExtensions.test.ts
Normal file
203
apps/desktop/tests/main/sync/SyncExtensions.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// 동기화 확장 — 지식베이스(원문 청크), 모바일 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<typeof createTestDb>
|
||||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue