import assert from "node:assert/strict"; import { BME_SYNC_DEVICE_ID_KEY, BME_SYNC_UPLOAD_DEBOUNCE_MS, __testOnlyDecodeBase64Utf8, autoSyncOnChatChange, autoSyncOnVisibility, deleteRemoteSyncFile, getOrCreateDeviceId, getRemoteStatus, download, mergeSnapshots, scheduleUpload, syncNow, upload, } from "../bme-sync.js"; const PREFIX = "[ST-BME][indexeddb-sync]"; class MemoryStorage { constructor() { this.map = new Map(); } getItem(key) { return this.map.has(key) ? this.map.get(key) : null; } setItem(key, value) { this.map.set(String(key), String(value)); } removeItem(key) { this.map.delete(String(key)); } } class FakeDb { constructor(chatId, snapshot = null) { this.chatId = chatId; this.snapshot = snapshot || { meta: { schemaVersion: 1, chatId, deviceId: "", revision: 0, lastModified: Date.now(), nodeCount: 0, edgeCount: 0, tombstoneCount: 0, }, nodes: [], edges: [], tombstones: [], state: { lastProcessedFloor: -1, extractionCount: 0, }, }; this.meta = new Map([ ["syncDirty", false], ["syncDirtyReason", ""], ["lastSyncedRevision", 0], ["deviceId", ""], ]); this.lastImportPayload = null; this.lastImportOptions = null; } async exportSnapshot() { return JSON.parse(JSON.stringify(this.snapshot)); } async importSnapshot(snapshot, options = {}) { this.lastImportPayload = JSON.parse(JSON.stringify(snapshot)); this.lastImportOptions = { ...options }; this.snapshot = JSON.parse(JSON.stringify(snapshot)); return { mode: options.mode || "replace", revision: snapshot?.meta?.revision || 0, imported: { nodes: Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0, edges: Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0, tombstones: Array.isArray(snapshot?.tombstones) ? snapshot.tombstones.length : 0, }, }; } async getMeta(key, fallback = null) { return this.meta.has(key) ? this.meta.get(key) : fallback; } async patchMeta(record = {}) { for (const [key, value] of Object.entries(record)) { this.meta.set(key, value); } } async setMeta(key, value) { this.meta.set(key, value); } } function createJsonResponse(status, body) { return { ok: status >= 200 && status < 300, status, statusText: String(status), async json() { return JSON.parse(JSON.stringify(body)); }, async text() { return typeof body === "string" ? body : JSON.stringify(body); }, }; } function createMockFetchEnvironment() { const remoteFiles = new Map(); const logs = { sanitizeCalls: 0, getCalls: 0, uploadCalls: 0, deleteCalls: 0, uploadedPayloads: [], }; const fetch = async (url, options = {}) => { const method = String(options?.method || "GET").toUpperCase(); if (url === "/api/files/sanitize-filename" && method === "POST") { logs.sanitizeCalls += 1; const body = JSON.parse(String(options.body || "{}")); const sanitized = String(body.fileName || "") .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") .replace(/\s+/g, "_"); return createJsonResponse(200, { fileName: sanitized }); } if (url === "/api/files/upload" && method === "POST") { logs.uploadCalls += 1; const body = JSON.parse(String(options.body || "{}")); const decoded = __testOnlyDecodeBase64Utf8(body.data); const payload = JSON.parse(decoded); remoteFiles.set(body.name, payload); logs.uploadedPayloads.push({ name: body.name, payload, }); return createJsonResponse(200, { path: `/user/files/${body.name}` }); } if (url === "/api/files/delete" && method === "POST") { logs.deleteCalls += 1; const body = JSON.parse(String(options.body || "{}")); const name = String(body.path || "").replace("/user/files/", ""); if (!remoteFiles.has(name)) return createJsonResponse(404, "not found"); remoteFiles.delete(name); return createJsonResponse(200, {}); } if (String(url).startsWith("/user/files/") && method === "GET") { logs.getCalls += 1; const withoutQuery = String(url).split("?")[0]; const fileName = decodeURIComponent(withoutQuery.slice("/user/files/".length)); if (!remoteFiles.has(fileName)) { return createJsonResponse(404, "not found"); } return createJsonResponse(200, remoteFiles.get(fileName)); } return createJsonResponse(404, "unsupported route"); }; return { fetch, remoteFiles, logs, }; } function buildRuntimeOptions({ dbByChatId, fetch }) { return { fetch, getDb: async (chatId) => { const db = dbByChatId.get(chatId); if (!db) throw new Error(`missing db: ${chatId}`); return db; }, getRequestHeaders: () => ({ "X-Test": "1", }), disableRemoteSanitize: false, }; } async function sleep(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } function createVisibilityMockDocument(initialVisibilityState = "visible") { const listeners = new Map(); const document = { visibilityState: initialVisibilityState, addEventListener(eventName, handler) { listeners.set(String(eventName), handler); }, }; return { document, emitVisibilityChange(nextVisibilityState) { document.visibilityState = nextVisibilityState; const handler = listeners.get("visibilitychange"); if (typeof handler === "function") { handler(); } }, getListener(eventName) { return listeners.get(String(eventName)); }, }; } async function testDeviceId() { const storage = new MemoryStorage(); globalThis.localStorage = storage; const first = getOrCreateDeviceId(); const second = getOrCreateDeviceId(); assert.ok(first); assert.equal(first, second); assert.equal(storage.getItem(BME_SYNC_DEVICE_ID_KEY), first); } async function testRemoteStatusMissing() { const { fetch } = createMockFetchEnvironment(); const status = await getRemoteStatus("chat-a", { fetch, getRequestHeaders: () => ({}), }); assert.equal(status.exists, false); assert.equal(status.status, "not-found"); } async function testUploadPayloadMetaFirstAndDebounce() { const { fetch, logs } = createMockFetchEnvironment(); const dbByChatId = new Map(); dbByChatId.set( "chat-upload", new FakeDb("chat-upload", { meta: { schemaVersion: 1, chatId: "chat-upload", deviceId: "", revision: 9, lastModified: Date.now(), nodeCount: 1, edgeCount: 0, tombstoneCount: 0, }, nodes: [{ id: "n1", updatedAt: 100 }], edges: [], tombstones: [], state: { lastProcessedFloor: 7, extractionCount: 4 }, }), ); const runtime = buildRuntimeOptions({ dbByChatId, fetch }); const uploadResult = await upload("chat-upload", runtime); assert.equal(uploadResult.uploaded, true); assert.equal(logs.uploadCalls, 1); const uploadedPayload = logs.uploadedPayloads[0].payload; assert.equal(Object.keys(uploadedPayload)[0], "meta"); assert.equal(uploadedPayload.meta.revision, 9); scheduleUpload("chat-upload", { ...runtime, debounceMs: 20, }); await sleep(50); assert.equal(logs.uploadCalls, 2); } async function testDownloadImport() { const { fetch, remoteFiles } = createMockFetchEnvironment(); const dbByChatId = new Map(); const db = new FakeDb("chat-download"); dbByChatId.set("chat-download", db); remoteFiles.set("ST-BME_sync_chat-download.json", { meta: { schemaVersion: 1, chatId: "chat-download", revision: 12, deviceId: "remote-device", lastModified: 500, nodeCount: 1, edgeCount: 0, tombstoneCount: 0, }, nodes: [{ id: "remote-node", updatedAt: 400 }], edges: [], tombstones: [], state: { lastProcessedFloor: 10, extractionCount: 2, }, }); const runtime = buildRuntimeOptions({ dbByChatId, fetch }); const result = await download("chat-download", runtime); assert.equal(result.downloaded, true); assert.equal(db.lastImportPayload.meta.revision, 12); assert.equal(db.lastImportPayload.nodes[0].id, "remote-node"); } async function testMergeRules() { const local = { meta: { chatId: "chat-merge", revision: 7, lastModified: 100, deviceId: "local-device", schemaVersion: 1, }, nodes: [{ id: "node-a", updatedAt: 100, value: "old" }], edges: [{ id: "edge-a", updatedAt: 100, fromId: "a", toId: "b" }], tombstones: [], state: { lastProcessedFloor: 5, extractionCount: 3, }, }; const remote = { meta: { chatId: "chat-merge", revision: 10, lastModified: 200, deviceId: "remote-device", schemaVersion: 1, }, nodes: [{ id: "node-a", updatedAt: 200, value: "new" }], edges: [{ id: "edge-a", updatedAt: 200, fromId: "a", toId: "b" }], tombstones: [ { id: "node:node-a", kind: "node", targetId: "node-a", deletedAt: 250, sourceDeviceId: "remote-device", }, ], state: { lastProcessedFloor: 8, extractionCount: 2, }, }; const merged = mergeSnapshots(local, remote, { chatId: "chat-merge" }); assert.equal(merged.meta.revision, 11); assert.equal(merged.nodes.length, 0, "tombstone 必须覆盖复活"); assert.equal(merged.state.lastProcessedFloor, 8); assert.equal(merged.state.extractionCount, 3); } async function testSyncNowLockAndAutoSync() { const { fetch, remoteFiles, logs } = createMockFetchEnvironment(); const dbByChatId = new Map(); const db = new FakeDb("chat-lock", { meta: { schemaVersion: 1, chatId: "chat-lock", revision: 1, lastModified: 10, deviceId: "", nodeCount: 0, edgeCount: 0, tombstoneCount: 0, }, nodes: [], edges: [], tombstones: [], state: { lastProcessedFloor: -1, extractionCount: 0, }, }); dbByChatId.set("chat-lock", db); const runtime = buildRuntimeOptions({ dbByChatId, fetch }); const [r1, r2] = await Promise.all([ syncNow("chat-lock", runtime), syncNow("chat-lock", runtime), ]); assert.equal(r1.action, "upload"); assert.equal(r2.action, "upload"); assert.equal(logs.uploadCalls, 1, "同 chatId 并发 sync 应串行去重"); remoteFiles.set("ST-BME_sync_chat-lock.json", { meta: { schemaVersion: 1, chatId: "chat-lock", revision: 3, lastModified: 99, deviceId: "remote-device", nodeCount: 1, edgeCount: 0, tombstoneCount: 0, }, nodes: [{ id: "remote-new", updatedAt: 99 }], edges: [], tombstones: [], state: { lastProcessedFloor: 2, extractionCount: 1, }, }); db.meta.set("syncDirty", false); const autoResult = await autoSyncOnChatChange("chat-lock", runtime); assert.equal(autoResult.action, "download"); assert.equal(db.lastImportPayload.nodes[0].id, "remote-new"); } async function testDeleteRemoteSyncFile() { const { fetch, logs } = createMockFetchEnvironment(); const dbByChatId = new Map(); dbByChatId.set("chat-delete", new FakeDb("chat-delete")); const runtime = buildRuntimeOptions({ dbByChatId, fetch }); await upload("chat-delete", runtime); assert.equal(logs.uploadCalls, 1); const deleteResult = await deleteRemoteSyncFile("chat-delete", runtime); assert.equal(deleteResult.deleted, true); assert.equal(deleteResult.chatId, "chat-delete"); assert.equal(logs.deleteCalls, 1); const deleteMissingResult = await deleteRemoteSyncFile("chat-delete", runtime); assert.equal(deleteMissingResult.deleted, false); assert.equal(deleteMissingResult.reason, "not-found"); assert.equal(logs.deleteCalls, 2); } async function testAutoSyncOnVisibility() { const { fetch, logs } = createMockFetchEnvironment(); const dbByChatId = new Map(); dbByChatId.set( "chat-visibility", new FakeDb("chat-visibility", { meta: { schemaVersion: 1, chatId: "chat-visibility", revision: 2, lastModified: 12, deviceId: "", nodeCount: 0, edgeCount: 0, tombstoneCount: 0, }, nodes: [], edges: [], tombstones: [], state: { lastProcessedFloor: -1, extractionCount: 0 }, }), ); const runtime = buildRuntimeOptions({ dbByChatId, fetch }); runtime.getCurrentChatId = () => "chat-visibility"; const originalDocument = globalThis.document; const visibilityDocument = createVisibilityMockDocument("hidden"); globalThis.document = visibilityDocument.document; try { const installResult = autoSyncOnVisibility(runtime); assert.equal(installResult.installed, true); assert.ok( typeof visibilityDocument.getListener("visibilitychange") === "function", ); visibilityDocument.emitVisibilityChange("visible"); await sleep(30); assert.equal(logs.uploadCalls, 1, "visibility visible 应触发一次自动同步"); const secondInstallResult = autoSyncOnVisibility(runtime); assert.equal(secondInstallResult.installed, true); } finally { globalThis.document = originalDocument; } } async function testSyncNowRemoteReadErrorPath() { const base = createMockFetchEnvironment(); const fetch = async (url, options = {}) => { if (String(url).startsWith("/user/files/")) { return createJsonResponse(500, "server-error"); } return await base.fetch(url, options); }; const dbByChatId = new Map(); dbByChatId.set("chat-remote-error", new FakeDb("chat-remote-error")); const runtime = buildRuntimeOptions({ dbByChatId, fetch }); const result = await syncNow("chat-remote-error", runtime); assert.equal(result.synced, false); assert.equal(result.reason, "http-error"); } async function main() { console.log(`${PREFIX} debounce=${BME_SYNC_UPLOAD_DEBOUNCE_MS}`); await testDeviceId(); await testRemoteStatusMissing(); await testUploadPayloadMetaFirstAndDebounce(); await testDownloadImport(); await testMergeRules(); await testSyncNowLockAndAutoSync(); await testDeleteRemoteSyncFile(); await testAutoSyncOnVisibility(); await testSyncNowRemoteReadErrorPath(); console.log("indexeddb-sync tests passed"); } await main();