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.
351 lines
18 KiB
TypeScript
351 lines
18 KiB
TypeScript
// 데스크톱 동기화 엔진 ↔ 실제 Supabase(로컬 스택) 통합 검증.
|
|
// 모바일 앱이 쓰는 것과 같은 테이블·RPC·RLS를 상대로, 모바일 역할 클라이언트와 데스크톱 엔진이
|
|
// 서로의 생성·수정·삭제를 주고받는지 확인한다.
|
|
//
|
|
// 실행: 로컬 스택(`supabase start`, server/) 기동 후
|
|
// D3RO_SYNC_IT_SUPABASE_URL=http://127.0.0.1:55321 \
|
|
// D3RO_SYNC_IT_ANON_KEY=<publishable> D3RO_SYNC_IT_SERVICE_KEY=<secret> \
|
|
// vitest run tests/integration/cross-device-sync.supabase.test.ts
|
|
// 환경변수가 없으면 건너뛴다(CI 기본).
|
|
|
|
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, 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,
|
|
} from '../../src/main/services/CustomInstructionService'
|
|
import {
|
|
getDictationTemplateService,
|
|
resetDictationTemplateServiceForTests,
|
|
} from '../../src/main/services/DictationTemplateService'
|
|
import { resetMeetingDocTemplateServiceForTests } from '../../src/main/services/MeetingDocTemplateService'
|
|
import { SyncEngine } from '../../src/main/services/sync/SyncEngine'
|
|
import { SupabaseSyncRemote } from '../../src/main/services/sync/supabase-sync-remote'
|
|
import { enqueueChange, outboxCounts } from '../../src/main/services/sync/sync-outbox'
|
|
import { memoTagKey } from '../../src/main/services/sync/memo-tag-sync'
|
|
import { checkInDesktopDevice } from '../../src/main/services/sync/device-registration'
|
|
|
|
const URL = process.env.D3RO_SYNC_IT_SUPABASE_URL
|
|
const ANON = process.env.D3RO_SYNC_IT_ANON_KEY
|
|
const SERVICE = process.env.D3RO_SYNC_IT_SERVICE_KEY
|
|
const enabled = Boolean(URL && ANON && SERVICE)
|
|
|
|
// 데스크톱 CloudSyncService와 같은 클라이언트 설정 (Node 20에는 전역 WebSocket이 없다)
|
|
const noPersist = {
|
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
realtime: { transport: nodeRealtimeTransport },
|
|
}
|
|
|
|
async function signedInClient(email: string, password: string): Promise<SupabaseClient> {
|
|
const client = createClient(URL!, ANON!, noPersist)
|
|
const { error } = await client.auth.signInWithPassword({ email, password })
|
|
if (error) throw error
|
|
return client
|
|
}
|
|
|
|
function must<T>(result: { data: T; error: { message: string } | null }): T {
|
|
if (result.error) throw new Error(result.error.message)
|
|
return result.data
|
|
}
|
|
|
|
describe.skipIf(!enabled)('cross-device sync against local Supabase', () => {
|
|
let admin: SupabaseClient
|
|
let desktop: SupabaseClient
|
|
let mobile: SupabaseClient
|
|
let userId: string
|
|
let testDb: ReturnType<typeof createTestDb>
|
|
let engine: SyncEngine
|
|
|
|
beforeAll(async () => {
|
|
admin = createClient(URL!, SERVICE!, noPersist)
|
|
const email = `sync-it-${Date.now()}@example.com`
|
|
const password = `Sync-it-${crypto.randomUUID()}`
|
|
const created = await admin.auth.admin.createUser({ email, password, email_confirm: true })
|
|
if (created.error || !created.data.user) throw created.error ?? new Error('no user')
|
|
userId = created.data.user.id
|
|
desktop = await signedInClient(email, password)
|
|
mobile = await signedInClient(email, password)
|
|
|
|
testDb = createTestDb()
|
|
bindTestDatabase(testDb.db, userId)
|
|
initInMemoryConfig()
|
|
resetCustomInstructionServiceForTests()
|
|
resetDictationTemplateServiceForTests()
|
|
resetMeetingDocTemplateServiceForTests()
|
|
getCustomInstructionService().initialize()
|
|
engine = new SyncEngine({ remote: new SupabaseSyncRemote(desktop), userId })
|
|
})
|
|
|
|
afterAll(async () => {
|
|
engine?.dispose()
|
|
unbindTestDatabase()
|
|
resetInMemoryConfig()
|
|
testDb?.close()
|
|
if (userId) await admin.auth.admin.deleteUser(userId)
|
|
})
|
|
|
|
it('양방향 최초 동기화 — 모바일 기존 데이터 수신 + 데스크톱 데이터 업로드', async () => {
|
|
const db = testDb.db
|
|
const now = Date.now()
|
|
|
|
// 모바일이 먼저 만들어 둔 것
|
|
const phoneHistory = crypto.randomUUID()
|
|
must(await mobile.from('history').insert({ id: phoneHistory, user_id: userId, original_text: 'from phone', duration: 2, is_favorite: true }))
|
|
await mobile.rpc('mobile_add_memo_tag_v1', { p_history_id: phoneHistory, p_tag: 'Phone Tag' }).throwOnError()
|
|
must(await mobile.from('dictionary').insert({ id: crypto.randomUUID(), user_id: userId, word: 'kubernetes', category: 'user' }))
|
|
const phoneMeeting = crypto.randomUUID()
|
|
must(await mobile.from('meetings').insert({ id: phoneMeeting, user_id: userId, title: 'phone meeting', status: 'completed' }))
|
|
must(await mobile.from('meeting_memos').insert({ id: crypto.randomUUID(), meeting_id: phoneMeeting, user_id: userId, content: 'phone memo', timestamp_ms: 3 }))
|
|
must(await mobile.from('custom_instructions').insert({ user_id: userId, name: 'Phone command', prompt: 'Be brief', icon: 'sparkles' }))
|
|
await mobile
|
|
.rpc('create_user_template_v1', {
|
|
p_template_kind: 'meeting_document',
|
|
p_name: 'Phone template',
|
|
p_description: null,
|
|
p_fields: [],
|
|
p_output_format: null,
|
|
p_system_prompt: 'Summarise',
|
|
})
|
|
.throwOnError()
|
|
|
|
// 데스크톱에만 있는 것
|
|
const deskHistory = crypto.randomUUID()
|
|
db.insert(history).values({ id: deskHistory, originalText: 'from desktop', duration: 1.5, createdAt: now, updatedAt: now }).run()
|
|
db.insert(memoTags).values({ id: crypto.randomUUID(), historyId: deskHistory, tag: 'desk', createdAt: now }).run()
|
|
db.insert(dictionary).values({ id: crypto.randomUUID(), word: 'Kubernetes', category: 'user', usageCount: 4, createdAt: now, updatedAt: now }).run()
|
|
const m1 = crypto.randomUUID()
|
|
const m2 = crypto.randomUUID()
|
|
for (const m of [m1, m2]) {
|
|
db.insert(meetingSessions).values({ id: m, title: `desk ${m.slice(0, 4)}`, status: 'completed', startedAt: now, createdAt: now, updatedAt: now }).run()
|
|
db.insert(meetingMemos).values({ id: crypto.randomUUID(), sessionId: m, content: 'desk memo', timestampMs: 1, createdAt: now }).run()
|
|
}
|
|
const deskCommand = getCustomInstructionService().create({ name: 'Desk command', description: '', prompt: 'Rewrite formally' })
|
|
const deskTemplate = getDictationTemplateService().create({
|
|
name: 'Desk dictation',
|
|
description: 'd',
|
|
fields: [{ id: 'field0', name: 'body', label: 'Body', promptText: 'Say it', required: true, maxDurationSec: 60 }],
|
|
outputFormat: '{{body}}',
|
|
})
|
|
|
|
const result = await engine.runFullSync()
|
|
expect(result.errors).toEqual([])
|
|
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
|
|
|
|
// 서버(모바일이 보는 것)
|
|
const remoteHistory = must(await mobile.from('history').select('id').eq('user_id', userId))
|
|
expect(remoteHistory.map((r) => r.id).sort()).toEqual([phoneHistory, deskHistory].sort())
|
|
const remoteMemos = must(await mobile.from('meeting_memos').select('meeting_id').eq('user_id', userId))
|
|
expect(new Set(remoteMemos.map((r) => r.meeting_id))).toEqual(new Set([phoneMeeting, m1, m2]))
|
|
const remoteTags = must(await mobile.rpc('mobile_list_memo_tags_v1')) as Array<{ normalized_tag: string }>
|
|
expect(remoteTags.map((r) => r.normalized_tag).sort()).toEqual(['desk', 'phone tag'])
|
|
const remoteDict = must(await mobile.from('dictionary').select('word').eq('user_id', userId))
|
|
expect(remoteDict).toHaveLength(1)
|
|
const remoteCommands = must(await mobile.from('custom_instructions').select('id,name').eq('user_id', userId).is('builtin_key', null))
|
|
expect(remoteCommands.map((r) => r.name).sort()).toEqual(['Desk command', 'Phone command'])
|
|
expect(remoteCommands.some((r) => r.id === deskCommand.id)).toBe(true)
|
|
const remoteTemplate = must(await mobile.from('user_templates').select('id,name').eq('id', deskTemplate.id).maybeSingle())
|
|
expect(remoteTemplate?.name).toBe('Desk dictation')
|
|
|
|
// 데스크톱(로컬)
|
|
const local = db.select().from(history).where(eq(history.id, phoneHistory)).get()
|
|
expect(local?.isFavorite).toBe(true)
|
|
expect(db.select().from(memoTags).all().map((t) => t.tag).sort()).toEqual(['desk', 'phone tag'])
|
|
expect(db.select().from(dictionary).all()).toHaveLength(1)
|
|
expect(db.select().from(meetingMemos).where(eq(meetingMemos.sessionId, phoneMeeting)).all()).toHaveLength(1)
|
|
expect(getCustomInstructionService().getAll().some((i) => i.name === 'Phone command')).toBe(true)
|
|
})
|
|
|
|
it('모바일 수정·삭제 → 데스크톱, 데스크톱 삭제 → 모바일', async () => {
|
|
const db = testDb.db
|
|
const rows = must(await mobile.from('history').select('id,original_text,revision').eq('user_id', userId))
|
|
const phone = rows.find((r) => r.original_text === 'from phone')!
|
|
const desk = rows.find((r) => r.original_text === 'from desktop')!
|
|
|
|
// 모바일식 낙관적 동시성 수정
|
|
must(
|
|
await mobile
|
|
.from('history')
|
|
.update({ original_text: 'phone edited', revision: Number(phone.revision) + 1 })
|
|
.eq('id', phone.id)
|
|
.eq('revision', phone.revision)
|
|
)
|
|
must(await mobile.from('meetings').delete().eq('user_id', userId).eq('title', 'phone meeting'))
|
|
await engine.pull()
|
|
expect(db.select().from(history).where(eq(history.id, phone.id)).get()?.originalText).toBe('phone edited')
|
|
expect(db.select().from(meetingSessions).all().map((m) => m.title)).not.toContain('phone meeting')
|
|
|
|
// 데스크톱 즐겨찾기 → 서버 revision 증가(모바일 충돌 감지)
|
|
db.update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, desk.id)).run()
|
|
enqueueChange('history', desk.id, 'upsert')
|
|
await engine.flush()
|
|
const after = must(await mobile.from('history').select('is_favorite,revision').eq('id', desk.id).single())
|
|
expect(after.is_favorite).toBe(true)
|
|
expect(Number(after.revision)).toBeGreaterThan(Number(desk.revision))
|
|
|
|
// 데스크톱 삭제
|
|
db.delete(memoTags).where(eq(memoTags.historyId, desk.id)).run()
|
|
db.delete(history).where(eq(history.id, desk.id)).run()
|
|
enqueueChange('history', desk.id, 'delete')
|
|
enqueueChange('memo_tags', memoTagKey(desk.id, 'desk'), 'delete')
|
|
const flushed = await engine.flush()
|
|
expect(flushed.errors).toEqual([])
|
|
const gone = must(await mobile.from('history').select('id').eq('id', desk.id))
|
|
expect(gone).toEqual([])
|
|
})
|
|
|
|
it('모바일 데이터 내보내기가 동기화된 데이터로도 성공한다 (v1 키 계약)', async () => {
|
|
const exported = must(await mobile.rpc('export_account_portability')) as Array<{ canonical_payload: string }>
|
|
const payload = JSON.parse(exported[0].canonical_payload) as {
|
|
datasets: { meeting_memos: Array<Record<string, unknown>>; meetings: Array<Record<string, unknown>> }
|
|
}
|
|
for (const memo of payload.datasets.meeting_memos) {
|
|
expect(Object.keys(memo).sort()).toEqual(['content', 'created_at', 'id', 'meeting_id', 'timestamp_ms', 'user_id'])
|
|
}
|
|
for (const meeting of payload.datasets.meetings) {
|
|
expect(meeting).not.toHaveProperty('attendees')
|
|
}
|
|
})
|
|
|
|
it('모바일 생성·삭제가 Realtime(행 + 삭제 기록)으로 데스크톱에 즉시 알려진다', async () => {
|
|
const session = must(await desktop.auth.getSession()).session
|
|
desktop.realtime.setAuth(session!.access_token)
|
|
const seen: string[] = []
|
|
const channel = desktop
|
|
.channel(`it-sync:${userId}`)
|
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'history', filter: `user_id=eq.${userId}` }, (p) =>
|
|
seen.push(`history:${p.eventType}`)
|
|
)
|
|
.on(
|
|
'postgres_changes',
|
|
{ event: 'INSERT', schema: 'public', table: 'sync_tombstones', filter: `user_id=eq.${userId}` },
|
|
() => seen.push('tombstone')
|
|
)
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('realtime subscribe timeout')), 15_000)
|
|
channel.subscribe((status, err) => {
|
|
if (status !== 'SUBSCRIBED') seen.push(`status:${status}:${err?.message ?? ''}`)
|
|
if (status === 'SUBSCRIBED') {
|
|
clearTimeout(timer)
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
// 구독 직후에는 서버의 변경 감시 등록이 끝나지 않았을 수 있다.
|
|
await new Promise((r) => setTimeout(r, 1500))
|
|
const id = crypto.randomUUID()
|
|
must(await mobile.from('history').insert({ id, user_id: userId, original_text: 'rt', duration: 1 }))
|
|
await new Promise((r) => setTimeout(r, 1000))
|
|
// 필터 채널에는 DELETE 이벤트가 오지 않는다(Supabase 제약) — 삭제는 sync_tombstones INSERT로 전달된다.
|
|
must(await mobile.from('history').delete().eq('id', id))
|
|
const deadline = Date.now() + 15_000
|
|
while (Date.now() < deadline && !(seen.includes('history:INSERT') && seen.includes('tombstone'))) {
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
}
|
|
await desktop.removeChannel(channel)
|
|
expect(seen).toContain('history:INSERT')
|
|
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)
|
|
expect(first.status).toBe('active')
|
|
const listed = must(await mobile.from('devices').select('id,platform,device_name').eq('user_id', userId))
|
|
expect(listed.some((d) => d.device_name === 'IT-DESKTOP')).toBe(true)
|
|
|
|
const deviceId = first.status === 'active' ? first.deviceId : ''
|
|
await mobile.rpc('revoke_device', { target_device_id: deviceId }).throwOnError()
|
|
const after = await checkInDesktopDevice(desktop, userId, 'heartbeat', info, deviceId)
|
|
expect(after.status).toBe('revoked')
|
|
|
|
// 같은 계정으로 다시 로그인하면 새 설치 id로 재등록된다
|
|
const again = await checkInDesktopDevice(desktop, userId, 'signin', info, deviceId)
|
|
expect(again.status).toBe('active')
|
|
})
|
|
})
|