146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
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 { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, syncQueuedChanges } = await import("../apps/web/src/lib/sync-queue");
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
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, async () => {
|
|
await localDb.drafts.clear();
|
|
await localDb.syncQueue.clear();
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
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("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<void>((resolve) => {
|
|
updateStarted = resolve;
|
|
});
|
|
const releaseUpdatePromise = new Promise<void>((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: 0, failed: 0, conflicted: 0 });
|
|
expect(queued?.status).toBe("pending");
|
|
expect(queued?.payload.title).toBe("newer text");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|