feat(desktop): two-way cloud sync with mobile and web
Rewrites the desktop mirror as services/sync/SyncEngine: a persistent outbox, per-account server-clock keyset cursors with paging, pulls that never overwrite unsent local edits, deletions both ways through sync_tombstones and per-row failure isolation. It now covers history titles and favorites, dictionary, every meeting's memos and documents, memo tags, user commands and dictation/meeting templates, and registers the desktop as a device that the phone can disconnect. Fixes shipped defects: the first pull after sign-in fetched nothing, only the first meeting's children were pushed, team meetings leaked into the personal database and lost team_id on re-push, and Realtime never connected because Electron's Node 20 has no global WebSocket (ws is now the transport). Anonymous local-mode records are imported into the first account that signs in. The settings sync section is translated and shows pending/rejected changes; synced screens reload on app:dataChanged.
This commit is contained in:
parent
b5c9ff9f31
commit
0a4f5aee64
49 changed files with 3940 additions and 1033 deletions
|
|
@ -1,9 +1,10 @@
|
|||
// tests/helpers/createTestDb.ts
|
||||
// in-memory SQLite + drizzle-orm 스키마 적용 (src/main/db/index.ts applySchema 와 동일)
|
||||
// in-memory SQLite + drizzle-orm 스키마 적용 (src/main/db/index.ts applySchema 그대로)
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from '../../src/main/db/schema'
|
||||
import { applySchema } from '../../src/main/db'
|
||||
|
||||
/**
|
||||
* 테스트용 in-memory SQLite DB를 생성한다.
|
||||
|
|
@ -16,156 +17,8 @@ export function createTestDb(): {
|
|||
} {
|
||||
const sqlite = new Database(':memory:')
|
||||
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
sqlite.pragma('busy_timeout = 5000')
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
original_text TEXT NOT NULL,
|
||||
polished_text TEXT,
|
||||
focused_app TEXT,
|
||||
focused_app_name TEXT,
|
||||
focused_app_window_title TEXT,
|
||||
mode TEXT NOT NULL DEFAULT 'dictation',
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
error_code TEXT,
|
||||
audio_local_path TEXT,
|
||||
duration REAL NOT NULL,
|
||||
detected_language TEXT,
|
||||
mic_device TEXT,
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
stt_model TEXT,
|
||||
llm_model TEXT,
|
||||
stt_latency_ms INTEGER,
|
||||
llm_latency_ms INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
app_version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
title TEXT,
|
||||
summary_text TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_status ON history(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dictionary (
|
||||
id TEXT PRIMARY KEY,
|
||||
word TEXT NOT NULL,
|
||||
pronunciation TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'user',
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category);
|
||||
CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stats (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
total_duration REAL NOT NULL DEFAULT 0,
|
||||
total_words INTEGER NOT NULL DEFAULT 0,
|
||||
session_count INTEGER NOT NULL DEFAULT 0,
|
||||
streak_days INTEGER NOT NULL DEFAULT 0,
|
||||
last_session_at INTEGER,
|
||||
last_updated INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated)
|
||||
VALUES (1, 0, 0, 0, 0, ${Date.now()});
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memo_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
history_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_memo_tags_unique ON memo_tags(history_id, tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_tags_history_id ON memo_tags(history_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memo_tags_tag ON memo_tags(tag);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
feature TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_usage_date_feature ON daily_usage(date, feature);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rag_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_type TEXT NOT NULL,
|
||||
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at INTEGER,
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_documents_added_at ON rag_documents(added_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rag_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding TEXT NOT NULL,
|
||||
chunk_index INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meeting_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'recording',
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
duration_ms INTEGER,
|
||||
raw_transcript TEXT,
|
||||
minutes_markdown TEXT,
|
||||
minutes_json TEXT,
|
||||
stt_model TEXT,
|
||||
llm_model TEXT,
|
||||
stt_latency_ms INTEGER,
|
||||
llm_latency_ms INTEGER,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
edited_transcript TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_meeting_sessions_created_at ON meeting_sessions(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status ON meeting_sessions(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meeting_memos (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_meeting_memos_session_id ON meeting_memos(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meeting_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
template_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
prompt_used TEXT,
|
||||
llm_model TEXT,
|
||||
llm_latency_ms INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id);
|
||||
`)
|
||||
// 운영과 같은 스키마 정본을 쓴다 — 손으로 복제한 DDL은 컬럼 추가 때마다 어긋났다.
|
||||
applySchema(sqlite)
|
||||
|
||||
const db = drizzle(sqlite, { schema })
|
||||
|
||||
|
|
|
|||
224
apps/desktop/tests/helpers/fakeSyncRemote.ts
Normal file
224
apps/desktop/tests/helpers/fakeSyncRemote.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// tests/helpers/fakeSyncRemote.ts
|
||||
// Supabase 동작을 흉내 내는 메모리 SyncRemote.
|
||||
// - INSERT는 보낸 updated_at 유지, UPDATE는 서버 시각으로 갱신(moddatetime)
|
||||
// - 삭제 시 sync_tombstones 기록, meetings 삭제는 메모·문서로 cascade
|
||||
// - dictionary (lower(word), category) 유일, memo_tags/템플릿 RPC
|
||||
|
||||
import type {
|
||||
RemoteFilter,
|
||||
RemotePageRequest,
|
||||
RemoteRow,
|
||||
SyncRemote,
|
||||
} from '../../src/main/services/sync/sync-types'
|
||||
import { SyncRemoteError } from '../../src/main/services/sync/sync-types'
|
||||
|
||||
export class FakeSyncRemote implements SyncRemote {
|
||||
readonly tables = new Map<string, RemoteRow[]>()
|
||||
private clock = Date.parse('2026-09-27T00:00:00.000Z')
|
||||
private tombstoneSeq = 0
|
||||
networkDown = false
|
||||
/** 특정 행 upsert를 거부시키는 훅 (재시도 불가 오류 시뮬레이션) */
|
||||
rejectRow: ((table: string, row: RemoteRow) => SyncRemoteError | null) | null = null
|
||||
readonly calls: string[] = []
|
||||
|
||||
constructor(readonly userId: string) {}
|
||||
|
||||
now(): string {
|
||||
this.clock += 1000
|
||||
return new Date(this.clock).toISOString()
|
||||
}
|
||||
|
||||
rows(table: string): RemoteRow[] {
|
||||
let rows = this.tables.get(table)
|
||||
if (!rows) {
|
||||
rows = []
|
||||
this.tables.set(table, rows)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
find(table: string, id: string): RemoteRow | undefined {
|
||||
return this.rows(table).find((r) => r.id === id)
|
||||
}
|
||||
|
||||
// ── 모바일/웹이 서버에 직접 쓰는 것을 흉내 ──
|
||||
|
||||
mobileInsert(table: string, row: RemoteRow): RemoteRow {
|
||||
const at = this.now()
|
||||
const full = { user_id: this.userId, created_at: at, updated_at: at, ...row }
|
||||
this.rows(table).push(full)
|
||||
return full
|
||||
}
|
||||
|
||||
mobileUpdate(table: string, id: string, patch: RemoteRow): void {
|
||||
const row = this.find(table, id)
|
||||
if (!row) throw new Error(`no ${table}/${id}`)
|
||||
Object.assign(row, patch, { updated_at: this.now() })
|
||||
}
|
||||
|
||||
mobileDelete(table: string, id: string): void {
|
||||
this.removeRow(table, id)
|
||||
}
|
||||
|
||||
// ── SyncRemote ──
|
||||
|
||||
private guard(): void {
|
||||
if (this.networkDown) throw new SyncRemoteError('fetch failed', 'network', true)
|
||||
}
|
||||
|
||||
private matches(row: RemoteRow, filters: RemoteFilter[] | undefined): boolean {
|
||||
for (const f of filters ?? []) {
|
||||
const v = row[f.column]
|
||||
if (f.op === 'eq' && v !== f.value) return false
|
||||
if (f.op === 'is' && v !== null && v !== undefined) return false
|
||||
if (f.op === 'ilike' && (typeof v !== 'string' || v.toLowerCase() !== f.value.toLowerCase())) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async fetchPage(request: RemotePageRequest): Promise<RemoteRow[]> {
|
||||
this.guard()
|
||||
this.calls.push(`fetch:${request.table}`)
|
||||
const col = request.cursorColumn
|
||||
const key = (r: RemoteRow): [number, string] => [Date.parse(String(r[col])), String(r.id)]
|
||||
const after = request.after ? ([Date.parse(request.after.ts), request.after.id] as [number, string]) : null
|
||||
const cmpId = (a: string, b: string): number =>
|
||||
/^\d+$/.test(a) && /^\d+$/.test(b) ? Number(a) - Number(b) : a < b ? -1 : a > b ? 1 : 0
|
||||
const sorted = this.rows(request.table)
|
||||
.filter((r) => r.user_id === request.userId && this.matches(r, request.filters))
|
||||
.sort((a, b) => {
|
||||
const [ta, ia] = key(a)
|
||||
const [tb, ib] = key(b)
|
||||
return ta !== tb ? ta - tb : cmpId(ia, ib)
|
||||
})
|
||||
.filter((r) => {
|
||||
if (!after) return true
|
||||
const [t, i] = key(r)
|
||||
return t > after[0] || (t === after[0] && cmpId(i, after[1]) > 0)
|
||||
})
|
||||
return sorted.slice(0, request.limit).map((r) => ({ ...r }))
|
||||
}
|
||||
|
||||
async selectWhere(table: string, userId: string, filters: RemoteFilter[]): Promise<RemoteRow[]> {
|
||||
this.guard()
|
||||
return this.rows(table)
|
||||
.filter((r) => r.user_id === userId && this.matches(r, filters))
|
||||
.map((r) => ({ ...r }))
|
||||
}
|
||||
|
||||
async upsert(table: string, rows: RemoteRow[]): Promise<void> {
|
||||
this.guard()
|
||||
this.calls.push(`upsert:${table}:${rows.length}`)
|
||||
// 배치는 원자적이다: 하나라도 거부되면 전체 실패
|
||||
for (const row of rows) {
|
||||
const rejected = this.rejectRow?.(table, row)
|
||||
if (rejected) throw rejected
|
||||
if (table === 'dictionary') {
|
||||
const clash = this.rows(table).find(
|
||||
(r) =>
|
||||
r.id !== row.id &&
|
||||
String(r.word).trim().toLowerCase() === String(row.word).trim().toLowerCase() &&
|
||||
r.category === row.category
|
||||
)
|
||||
if (clash) throw new SyncRemoteError('duplicate key value', '23505', false)
|
||||
}
|
||||
if ((table === 'meeting_memos' || table === 'meeting_documents') && !this.find('meetings', String(row.meeting_id))) {
|
||||
throw new SyncRemoteError('violates foreign key', '23503', true)
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
const existing = this.find(table, String(row.id))
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByIds(table: string, userId: string, ids: string[]): Promise<void> {
|
||||
this.guard()
|
||||
this.calls.push(`delete:${table}:${ids.length}`)
|
||||
for (const id of ids) {
|
||||
const row = this.find(table, id)
|
||||
if (row && row.user_id === userId) this.removeRow(table, id)
|
||||
}
|
||||
}
|
||||
|
||||
private removeRow(table: string, id: string): void {
|
||||
const rows = this.rows(table)
|
||||
const index = rows.findIndex((r) => r.id === id)
|
||||
if (index === -1) return
|
||||
const [row] = rows.splice(index, 1)
|
||||
this.rows('sync_tombstones').push({
|
||||
id: ++this.tombstoneSeq,
|
||||
user_id: row.user_id,
|
||||
table_name: table,
|
||||
row_id: id,
|
||||
deleted_at: this.now(),
|
||||
})
|
||||
if (table === 'meetings') {
|
||||
for (const child of ['meeting_memos', 'meeting_documents']) {
|
||||
for (const c of this.rows(child).filter((r) => r.meeting_id === id)) this.removeRow(child, String(c.id))
|
||||
}
|
||||
}
|
||||
if (table === 'history') {
|
||||
for (const t of this.rows('memo_tags').filter((r) => r.history_id === id)) this.removeRow('memo_tags', String(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async rpc(name: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
this.guard()
|
||||
this.calls.push(`rpc:${name}`)
|
||||
if (name === 'mobile_add_memo_tag_v1') {
|
||||
const historyId = String(params.p_history_id)
|
||||
if (!this.find('history', historyId)) throw new SyncRemoteError('history_not_found', 'P0002', true)
|
||||
const normalized = String(params.p_tag).trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
const existing = this.rows('memo_tags').find((r) => r.history_id === historyId && r.normalized_tag === normalized)
|
||||
if (existing) return existing
|
||||
return this.mobileInsert('memo_tags', {
|
||||
id: crypto.randomUUID(),
|
||||
history_id: historyId,
|
||||
tag: String(params.p_tag),
|
||||
normalized_tag: normalized,
|
||||
})
|
||||
}
|
||||
if (name === 'mobile_remove_memo_tag_v1') {
|
||||
const historyId = String(params.p_history_id)
|
||||
const normalized = String(params.p_tag).trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
const row = this.rows('memo_tags').find((r) => r.history_id === historyId && r.normalized_tag === normalized)
|
||||
if (!row) return false
|
||||
this.removeRow('memo_tags', String(row.id))
|
||||
return true
|
||||
}
|
||||
if (name === 'sync_upsert_user_template_v1') {
|
||||
const id = String(params.p_id)
|
||||
const existing = this.find('user_templates', id)
|
||||
const fields = {
|
||||
template_kind: params.p_template_kind,
|
||||
name: params.p_name,
|
||||
description: params.p_description,
|
||||
fields: params.p_fields,
|
||||
output_format: params.p_output_format,
|
||||
system_prompt: params.p_system_prompt,
|
||||
template_type: params.p_template_kind === 'meeting_document' ? 'custom' : null,
|
||||
is_builtin: false,
|
||||
}
|
||||
if (existing) {
|
||||
Object.assign(existing, fields, { revision: Number(existing.revision) + 1, updated_at: this.now() })
|
||||
return existing
|
||||
}
|
||||
return this.mobileInsert('user_templates', { id, revision: 1, ...fields })
|
||||
}
|
||||
if (name === 'sync_delete_user_template_v1') {
|
||||
const id = String(params.p_id)
|
||||
if (!this.find('user_templates', id)) return false
|
||||
this.removeRow('user_templates', id)
|
||||
return true
|
||||
}
|
||||
throw new Error(`unknown rpc ${name}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
// 데스크톱 동기화 엔진 ↔ 실제 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 { afterAll, beforeAll, describe, expect, it } 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 {
|
||||
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('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 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')
|
||||
})
|
||||
})
|
||||
383
apps/desktop/tests/main/sync/SyncEngine.test.ts
Normal file
383
apps/desktop/tests/main/sync/SyncEngine.test.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
// 데스크톱 ↔ Supabase(모바일·웹) 양방향 동기화 엔진 — 서버 흉내 원격으로 실제 SQLite에 대해 검증한다.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } 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 {
|
||||
dictionary,
|
||||
history,
|
||||
meetingDocuments,
|
||||
meetingMemos,
|
||||
meetingSessions,
|
||||
memoTags,
|
||||
} from '../../../src/main/db/schema'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
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 { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox'
|
||||
import { memoTagKey } from '../../../src/main/services/sync/memo-tag-sync'
|
||||
import { SyncRemoteError } from '../../../src/main/services/sync/sync-types'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function uuid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: FakeSyncRemote
|
||||
let engine: SyncEngine
|
||||
|
||||
function db(): ReturnType<typeof createTestDb>['db'] {
|
||||
return testDb.db
|
||||
}
|
||||
|
||||
function insertLocalHistory(text: string, at = Date.now()): string {
|
||||
const id = uuid()
|
||||
db().insert(history).values({ id, originalText: text, duration: 1.5, createdAt: at, updatedAt: at }).run()
|
||||
return id
|
||||
}
|
||||
|
||||
function insertLocalMeeting(title: string, at = Date.now()): string {
|
||||
const id = uuid()
|
||||
db()
|
||||
.insert(meetingSessions)
|
||||
.values({ id, title, status: 'completed', startedAt: at, createdAt: at, updatedAt: at })
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
|
||||
function localHistoryIds(): string[] {
|
||||
return db().select({ id: history.id }).from(history).all().map((r) => r.id).sort()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
resetCustomInstructionServiceForTests()
|
||||
resetDictationTemplateServiceForTests()
|
||||
resetMeetingDocTemplateServiceForTests()
|
||||
getCustomInstructionService().initialize()
|
||||
remote = new FakeSyncRemote(USER)
|
||||
engine = new SyncEngine({ remote, userId: USER })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
engine.dispose()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
describe('최초 동기화', () => {
|
||||
it('모바일에서 만든 기존 기록을 내려받고, 데스크톱 기록을 올린다', async () => {
|
||||
const mobileId = uuid()
|
||||
remote.mobileInsert('history', { id: mobileId, original_text: 'from phone', duration: 2, mode: 'dictation', status: 'completed', is_favorite: true })
|
||||
const desktopId = insertLocalHistory('from desktop')
|
||||
|
||||
const result = await engine.runFullSync()
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(localHistoryIds()).toEqual([mobileId, desktopId].sort())
|
||||
expect(remote.find('history', desktopId)?.original_text).toBe('from desktop')
|
||||
const pulled = db().select().from(history).where(eq(history.id, mobileId)).get()
|
||||
expect(pulled?.isFavorite).toBe(true)
|
||||
expect(result.changed).toContain('history')
|
||||
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
|
||||
})
|
||||
|
||||
it('서버와 같은 판은 다시 올리지 않는다 (다른 기기의 편집을 덮지 않는다)', async () => {
|
||||
const id = insertLocalHistory('same', Date.parse('2026-09-26T00:00:00Z'))
|
||||
remote.mobileInsert('history', {
|
||||
id,
|
||||
original_text: 'edited on phone',
|
||||
duration: 1.5,
|
||||
mode: 'dictation',
|
||||
status: 'completed',
|
||||
})
|
||||
await engine.runFullSync()
|
||||
expect(remote.calls.filter((c) => c.startsWith('upsert:history'))).toEqual([])
|
||||
expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('edited on phone')
|
||||
})
|
||||
|
||||
it('500행이 넘어도 페이지를 넘겨 전부 가져온다', async () => {
|
||||
for (let i = 0; i < 1203; i++) {
|
||||
remote.mobileInsert('history', { id: uuid(), original_text: `r${i}`, duration: 1, mode: 'dictation', status: 'completed' })
|
||||
}
|
||||
await engine.runFullSync()
|
||||
expect(localHistoryIds()).toHaveLength(1203)
|
||||
})
|
||||
|
||||
it('다른 사용자(팀) 행은 가져오지 않는다', async () => {
|
||||
remote.rows('history').push({
|
||||
id: uuid(),
|
||||
user_id: OTHER,
|
||||
original_text: 'not mine',
|
||||
duration: 1,
|
||||
mode: 'dictation',
|
||||
status: 'completed',
|
||||
created_at: remote.now(),
|
||||
updated_at: remote.now(),
|
||||
})
|
||||
await engine.runFullSync()
|
||||
expect(localHistoryIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('변경·삭제 전파', () => {
|
||||
it('모바일에서 수정/삭제한 것이 데스크톱에 반영된다', async () => {
|
||||
const a = uuid()
|
||||
const b = uuid()
|
||||
remote.mobileInsert('history', { id: a, original_text: 'a', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
remote.mobileInsert('history', { id: b, original_text: 'b', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
|
||||
remote.mobileUpdate('history', a, { original_text: 'a edited', title: 'renamed' })
|
||||
remote.mobileDelete('history', b)
|
||||
const result = await engine.pull()
|
||||
|
||||
expect(result.deleted).toBe(1)
|
||||
expect(localHistoryIds()).toEqual([a])
|
||||
const row = db().select().from(history).where(eq(history.id, a)).get()
|
||||
expect(row?.originalText).toBe('a edited')
|
||||
expect(row?.title).toBe('renamed')
|
||||
})
|
||||
|
||||
it('데스크톱 삭제가 서버로 전파된다', async () => {
|
||||
const id = insertLocalHistory('bye')
|
||||
await engine.runFullSync()
|
||||
expect(remote.find('history', id)).toBeDefined()
|
||||
|
||||
db().delete(history).where(eq(history.id, id)).run()
|
||||
enqueueChange('history', id, 'delete')
|
||||
await engine.flush()
|
||||
|
||||
expect(remote.find('history', id)).toBeUndefined()
|
||||
// 자기 삭제 기록을 다시 받아도 문제없다
|
||||
const pulled = await engine.pull()
|
||||
expect(pulled.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('아직 올리지 않은 로컬 변경은 pull이 덮지 않는다', async () => {
|
||||
const id = uuid()
|
||||
remote.mobileInsert('history', { id, original_text: 'v1', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
|
||||
db().update(history).set({ originalText: 'local v2', updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
remote.mobileUpdate('history', id, { original_text: 'remote v2' })
|
||||
await engine.pull()
|
||||
expect(db().select().from(history).where(eq(history.id, id)).get()?.originalText).toBe('local v2')
|
||||
|
||||
await engine.flush()
|
||||
expect(remote.find('history', id)?.original_text).toBe('local v2')
|
||||
})
|
||||
|
||||
it('원격 삭제는 로컬 미전송 변경보다 우선한다', async () => {
|
||||
const id = uuid()
|
||||
remote.mobileInsert('history', { id, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
remote.mobileDelete('history', id)
|
||||
await engine.pull()
|
||||
expect(localHistoryIds()).toEqual([])
|
||||
expect(pendingOps('history').size).toBe(0)
|
||||
})
|
||||
|
||||
it('데스크톱이 내용을 바꿔 올리면 서버 revision이 오른다 (모바일 충돌 감지)', async () => {
|
||||
const id = uuid()
|
||||
remote.mobileInsert('history', { id, original_text: 'x', duration: 1.5, mode: 'dictation', status: 'completed', revision: 1 })
|
||||
await engine.runFullSync()
|
||||
db().update(history).set({ isFavorite: true, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
await engine.flush()
|
||||
expect(remote.find('history', id)?.is_favorite).toBe(true)
|
||||
expect(Number(remote.find('history', id)?.revision)).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('회의', () => {
|
||||
it('여러 회의의 메모·문서를 모두 올린다 (첫 회의만 올리던 회귀)', async () => {
|
||||
const m1 = insertLocalMeeting('one')
|
||||
const m2 = insertLocalMeeting('two')
|
||||
const now = Date.now()
|
||||
for (const m of [m1, m2]) {
|
||||
db().insert(meetingMemos).values({ id: uuid(), sessionId: m, content: `memo ${m}`, timestampMs: 5, createdAt: now }).run()
|
||||
db()
|
||||
.insert(meetingDocuments)
|
||||
.values({ id: uuid(), sessionId: m, templateType: 'minutes', title: 't', content: 'c', createdAt: now, updatedAt: now })
|
||||
.run()
|
||||
}
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(remote.rows('meeting_memos')).toHaveLength(2)
|
||||
expect(remote.rows('meeting_documents')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('팀에 공유된 회의를 데스크톱이 수정해도 team_id를 지우지 않는다', async () => {
|
||||
const id = insertLocalMeeting('shared')
|
||||
await engine.runFullSync()
|
||||
const teamId = uuid()
|
||||
remote.mobileUpdate('meetings', id, { team_id: teamId })
|
||||
await engine.pull()
|
||||
db().update(meetingSessions).set({ title: 'renamed', updatedAt: Date.now() }).where(eq(meetingSessions.id, id)).run()
|
||||
enqueueChange('meetings', id, 'upsert')
|
||||
await engine.flush()
|
||||
expect(remote.find('meetings', id)?.team_id).toBe(teamId)
|
||||
expect(remote.find('meetings', id)?.title).toBe('renamed')
|
||||
})
|
||||
|
||||
it('모바일에서 수정한 회의 메모를 가져오고, 회의 삭제는 메모·문서까지 지운다', async () => {
|
||||
const meeting = uuid()
|
||||
const memo = uuid()
|
||||
remote.mobileInsert('meetings', { id: meeting, title: 'phone meeting', status: 'completed', started_at: remote.now() })
|
||||
remote.mobileInsert('meeting_memos', { id: memo, meeting_id: meeting, content: 'first', timestamp_ms: 10 })
|
||||
await engine.runFullSync()
|
||||
remote.mobileUpdate('meeting_memos', memo, { content: 'edited' })
|
||||
await engine.pull()
|
||||
expect(db().select().from(meetingMemos).where(eq(meetingMemos.id, memo)).get()?.content).toBe('edited')
|
||||
|
||||
remote.mobileDelete('meetings', meeting)
|
||||
await engine.pull()
|
||||
expect(db().select().from(meetingSessions).all()).toEqual([])
|
||||
expect(db().select().from(meetingMemos).all()).toEqual([])
|
||||
})
|
||||
|
||||
it('남의 팀 회의에 단 내 메모는 부모 없는 고아 행으로 만들지 않는다', async () => {
|
||||
const foreignMeeting = uuid()
|
||||
remote.rows('meetings').push({ id: foreignMeeting, user_id: OTHER, status: 'completed', created_at: remote.now(), updated_at: remote.now() })
|
||||
remote.mobileInsert('meeting_memos', { id: uuid(), meeting_id: foreignMeeting, content: 'mine', timestamp_ms: 1 })
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(db().select().from(meetingMemos).all()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('사전', () => {
|
||||
it('대소문자만 다른 단어가 서버에 있으면 서버 행을 채택한다', async () => {
|
||||
const remoteId = uuid()
|
||||
remote.mobileInsert('dictionary', { id: remoteId, word: 'api', category: 'user', usage_count: 1 })
|
||||
const localId = uuid()
|
||||
const now = Date.now()
|
||||
db().insert(dictionary).values({ id: localId, word: 'API', category: 'user', usageCount: 7, createdAt: now, updatedAt: now }).run()
|
||||
|
||||
const result = await engine.runFullSync()
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
const rows = db().select().from(dictionary).all()
|
||||
expect(rows.map((r) => r.id)).toEqual([remoteId])
|
||||
expect(rows[0].usageCount).toBe(7)
|
||||
expect(outboxCounts()).toEqual({ pending: 0, parked: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('실패 처리', () => {
|
||||
it('네트워크가 끊기면 변경을 보관했다가 다음에 올린다', async () => {
|
||||
await engine.runFullSync()
|
||||
const id = insertLocalHistory('offline')
|
||||
enqueueChange('history', id, 'upsert')
|
||||
remote.networkDown = true
|
||||
const failed = await engine.flush()
|
||||
expect(failed.pushed).toBe(0)
|
||||
expect(outboxCounts().pending).toBe(1)
|
||||
|
||||
remote.networkDown = false
|
||||
// 백오프 시각을 지난 것으로 본다
|
||||
const later = new SyncEngine({ remote, userId: USER, now: () => Date.now() + 60_000 })
|
||||
await later.flush()
|
||||
later.dispose()
|
||||
expect(remote.find('history', id)).toBeDefined()
|
||||
expect(outboxCounts().pending).toBe(0)
|
||||
})
|
||||
|
||||
it('서버가 거부한 행만 격리하고 나머지는 올린다', async () => {
|
||||
const good = insertLocalHistory('good')
|
||||
const bad = insertLocalHistory('bad')
|
||||
remote.rejectRow = (table, row) =>
|
||||
table === 'history' && row.id === bad ? new SyncRemoteError('check violation', '23514', false) : null
|
||||
const result = await engine.runFullSync()
|
||||
expect(remote.find('history', good)).toBeDefined()
|
||||
expect(remote.find('history', bad)).toBeUndefined()
|
||||
expect(result.errors.some((e) => e.includes(bad))).toBe(true)
|
||||
expect(outboxCounts().pending).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('메모 태그', () => {
|
||||
it('데스크톱 태그를 올리고 모바일 태그·삭제를 반영한다', async () => {
|
||||
const h = uuid()
|
||||
remote.mobileInsert('history', { id: h, original_text: 'x', duration: 1, mode: 'dictation', status: 'completed' })
|
||||
await engine.runFullSync()
|
||||
|
||||
db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'work', createdAt: Date.now() }).run()
|
||||
enqueueChange('memo_tags', memoTagKey(h, 'work'), 'upsert')
|
||||
await engine.flush()
|
||||
expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['work'])
|
||||
|
||||
await remote.rpc('mobile_add_memo_tag_v1', { p_history_id: h, p_tag: 'Idea Board' })
|
||||
await remote.rpc('mobile_remove_memo_tag_v1', { p_history_id: h, p_tag: 'work' })
|
||||
await engine.pull()
|
||||
expect(db().select().from(memoTags).all().map((r) => r.tag)).toEqual(['idea board'])
|
||||
})
|
||||
|
||||
it('이력이 아직 서버에 없으면 태그는 대기했다가 이력 뒤에 올라간다', async () => {
|
||||
const h = insertLocalHistory('tagged')
|
||||
db().insert(memoTags).values({ id: uuid(), historyId: h, tag: 'later', createdAt: Date.now() }).run()
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(remote.rows('memo_tags').map((r) => r.normalized_tag)).toEqual(['later'])
|
||||
expect(db().select().from(memoTags).all()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('명령·템플릿', () => {
|
||||
it('사용자 명령을 양방향으로 맞추고 프리셋은 건드리지 않는다', async () => {
|
||||
const service = getCustomInstructionService()
|
||||
const created = service.create({ name: 'Tone down', description: 'd', prompt: 'Make it calm' })
|
||||
enqueueChange('custom_instructions', created.id, 'upsert')
|
||||
const phone = uuid()
|
||||
remote.mobileInsert('custom_instructions', { id: phone, builtin_key: null, name: 'From phone', description: '', prompt: 'p', icon: 'sparkles', sort_order: 3 })
|
||||
remote.mobileInsert('custom_instructions', { id: uuid(), builtin_key: 'summarize', name: 'Summarize', description: '', prompt: 'p', icon: 'x', sort_order: 0 })
|
||||
|
||||
await engine.runFullSync()
|
||||
|
||||
expect(remote.find('custom_instructions', created.id)?.prompt).toBe('Make it calm')
|
||||
expect(service.getById(phone)?.name).toBe('From phone')
|
||||
expect(service.getAll().filter((i) => i.name === 'Summarize')).toEqual([])
|
||||
expect(remote.rows('custom_instructions').filter((r) => String(r.id).startsWith('builtin-'))).toEqual([])
|
||||
|
||||
remote.mobileDelete('custom_instructions', phone)
|
||||
await engine.pull()
|
||||
expect(service.getById(phone)).toBeNull()
|
||||
})
|
||||
|
||||
it('받아쓰기 템플릿을 같은 id로 올리고 모바일 수정을 반영한다', async () => {
|
||||
const service = getDictationTemplateService()
|
||||
const template = service.create({
|
||||
name: 'Standup',
|
||||
description: '',
|
||||
fields: [{ id: 'field0', name: 'done', label: 'Done', promptText: 'What did you do?', required: true, maxDurationSec: 60 }],
|
||||
outputFormat: '{{done}}',
|
||||
})
|
||||
enqueueChange('user_templates', template.id, 'upsert')
|
||||
await engine.runFullSync()
|
||||
expect(remote.find('user_templates', template.id)?.name).toBe('Standup')
|
||||
|
||||
remote.mobileUpdate('user_templates', template.id, { name: 'Daily standup' })
|
||||
await engine.pull()
|
||||
expect(service.getById(template.id)?.name).toBe('Daily standup')
|
||||
expect(service.getAll().filter((t) => t.isBuiltin).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue