// 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() 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 { 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 { 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 { 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 { 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): Promise { 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}`) } }