import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { MemoDetail, MemoEditSession } from "@edgeever/shared"; import type { MemoUpdateSyncPayload } from "../apps/web/src/lib/local-db"; const { localDb } = await import("../apps/web/src/lib/local-db"); const { discardWebMemoConflict, getMemoCreateQueueId, getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoCreate, queueMemoUpdate, syncQueuedChanges, } = await import("../apps/web/src/lib/sync-queue"); const { formatLocalDraftClipboardText } = await import("../apps/web/src/lib/memo-save-conflict"); const originalFetch = globalThis.fetch; const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); const payload = (title: string, revision = 0): MemoUpdateSyncPayload => ({ memoId: "memo_sync_test", expectedRevision: revision, expectedContentHash: `hash-${revision}`, editSessionId: "original-session", title, contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: title }] }] }, tags: [], }); const memo = (title: string, revision = 1): MemoDetail => ({ id: "memo_sync_test", notebookId: "nb_inbox", title, excerpt: title, tags: [], isPinned: false, isArchived: false, isDeleted: false, revision, createdAt: "2026-07-15T00:00:00.000Z", updatedAt: "2026-07-15T00:00:01.000Z", deletedAt: null, contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: title }] }] }, contentMarkdown: title, contentText: title, contentHash: `hash-${revision}`, sourceMemoIds: [], mergeSourceCount: 0, mergedIntoMemoId: null, }); const session = (revision = 0): MemoEditSession => ({ id: `edit-${revision}`, memoId: "memo_sync_test", baseRevision: revision, baseContentHash: `hash-${revision}`, expiresAt: "2026-08-15T00:00:00.000Z", }); const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); beforeEach(async () => { Object.defineProperty(globalThis, "navigator", { configurable: true, value: { onLine: true }, }); await localDb.transaction("rw", localDb.drafts, localDb.syncQueue, localDb.memos, async () => { await localDb.drafts.clear(); await localDb.syncQueue.clear(); await localDb.memos.clear(); }); }); afterEach(() => { globalThis.fetch = originalFetch; if (originalNavigator) { Object.defineProperty(globalThis, "navigator", originalNavigator); } else { delete globalThis.navigator; } }); describe("web sync queue concurrency", () => { test("recognizes a legacy queued snapshot that is already fully stored on the server", async () => { await queueMemoUpdate(payload("already saved")); const queued = await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test")); expect(queued).toBeDefined(); expect(isMemoUpdateAlreadyApplied(memo("already saved"), queued!)).toBe(true); expect(isMemoUpdateAlreadyApplied(memo("already saved", 0), queued!)).toBe(false); expect(isMemoUpdateAlreadyApplied(memo("different remote text"), queued!)).toBe(false); }); test("coalesces simultaneous sync runs so one queued save is sent only once", async () => { await queueMemoUpdate(payload("latest text")); let requestCount = 0; globalThis.fetch = (async (input: RequestInfo | URL) => { requestCount += 1; const url = String(input); if (url.endsWith("/edit-sessions")) { return jsonResponse({ editSession: session() }); } return jsonResponse({ memo: memo("latest text") }); }) as typeof fetch; const [first, second] = await Promise.all([syncQueuedChanges(), syncQueuedChanges()]); expect(requestCount).toBe(2); expect(first).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); expect(second).toEqual(first); expect(await localDb.syncQueue.count()).toBe(0); }); test("does not delete a newer draft queued while an older request is in flight", async () => { await queueMemoUpdate(payload("older text")); let releaseUpdate!: () => void; let updateStarted!: () => void; const updateStartedPromise = new Promise((resolve) => { updateStarted = resolve; }); const releaseUpdatePromise = new Promise((resolve) => { releaseUpdate = resolve; }); globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/edit-sessions")) { return jsonResponse({ editSession: session() }); } updateStarted(); await releaseUpdatePromise; return jsonResponse({ memo: memo("older text") }); }) as typeof fetch; const runningSync = syncQueuedChanges(); await updateStartedPromise; await queueMemoUpdate(payload("newer text")); releaseUpdate(); const result = await runningSync; const queued = await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test")); expect(result).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); expect(queued?.status).toBe("pending"); expect(queued?.payload.title).toBe("newer text"); expect(queued?.payload.expectedRevision).toBe(1); expect(queued?.payload.expectedContentHash).toBe("hash-1"); }); test("queues a draft saved while memo creation is in flight after remapping its id", async () => { const scope = "test-scope"; const temporaryId = "local_create_race"; const remoteMemo = { ...memo("", 1), id: "memo_created", title: null }; const draftTitle = "written during create"; const draftContent = payload(draftTitle).contentJson; await queueMemoCreate(scope, { temporaryId, notebookId: "nb_inbox", title: "", contentMarkdown: "", tags: [], createdAt: "2026-07-15T00:00:00.000Z", updatedAt: "2026-07-15T00:00:00.000Z", }); await localDb.drafts.put({ memoId: temporaryId, title: draftTitle, contentJson: draftContent, tagsText: "", updatedAt: "2026-07-15T00:00:02.000Z", }); const requests: Array<{ method: string; url: string }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = init?.method ?? "GET"; requests.push({ method, url }); if (method === "POST" && url.endsWith("/api/v1/memos")) { return jsonResponse({ memo: remoteMemo }, 201); } if (url.endsWith("/edit-sessions")) { return jsonResponse({ editSession: { ...session(1), memoId: remoteMemo.id } }); } return jsonResponse({ memo: { ...remoteMemo, title: draftTitle, contentJson: draftContent, revision: 2, contentHash: "hash-2" } }); }) as typeof fetch; const createResult = await syncQueuedChanges({ scope }); const queuedUpdate = await localDb.syncQueue.get(getMemoUpdateQueueId(remoteMemo.id)); expect(createResult).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); expect(await localDb.syncQueue.get(getMemoCreateQueueId(temporaryId))).toBeUndefined(); expect(await localDb.drafts.get(temporaryId)).toBeUndefined(); expect(await localDb.drafts.get(remoteMemo.id)).toMatchObject({ title: draftTitle, contentJson: draftContent }); expect(queuedUpdate).toMatchObject({ kind: "memo.update", scope, memoId: remoteMemo.id, status: "pending", payload: { expectedRevision: 1, expectedContentHash: "hash-1", title: draftTitle, contentJson: draftContent, }, }); const updateResult = await syncQueuedChanges({ scope }); expect(updateResult).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 }); expect(requests.some(({ method, url }) => method === "PATCH" && url.endsWith(`/api/v1/memos/${remoteMemo.id}`))).toBe(true); expect(await localDb.syncQueue.get(getMemoUpdateQueueId(remoteMemo.id))).toBeUndefined(); }); test("keeps a genuine server revision mismatch as a conflict", async () => { await queueMemoUpdate(payload("offline edit")); globalThis.fetch = (async () => jsonResponse({ editSession: session(1) })) as typeof fetch; const result = await syncQueuedChanges(); const queued = await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test")); expect(result).toEqual({ attempted: 1, synced: 0, failed: 0, conflicted: 1 }); expect(queued?.status).toBe("conflict"); expect(queued?.payload.title).toBe("offline edit"); }); test("discardWebMemoConflict replaces local draft with the cloud memo", async () => { const scope = "test-scope"; await queueMemoUpdate(payload("local conflicted edit"), scope); await localDb.syncQueue.update(getMemoUpdateQueueId("memo_sync_test"), { status: "conflict" }); await localDb.drafts.put({ memoId: "memo_sync_test", title: "local conflicted edit", tagsText: "", contentJson: payload("local conflicted edit").contentJson, updatedAt: "2026-07-15T00:00:02.000Z", }); const cloud = memo("cloud wins", 3); globalThis.fetch = (async () => jsonResponse({ memo: cloud })) as typeof fetch; const adopted = await discardWebMemoConflict(scope, "memo_sync_test"); expect(adopted).toEqual(cloud); expect(await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test"))).toBeUndefined(); expect(await localDb.drafts.get("memo_sync_test")).toBeUndefined(); expect(await localDb.memos.get([scope, "memo_sync_test"])).toMatchObject({ title: "cloud wins", revision: 3, scope, }); }); }); describe("local draft clipboard formatting", () => { test("includes title, tags, and body", () => { expect( formatLocalDraftClipboardText({ title: "Welcome", tags: ["overview", "demo"], contentMarkdown: "Hello world\n", }), ).toBe("# Welcome\n#overview #demo\n\nHello world"); }); });