195 lines
6.8 KiB
TypeScript
195 lines
6.8 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 {
|
|
discardWebMemoConflict,
|
|
getMemoUpdateQueueId,
|
|
isMemoUpdateAlreadyApplied,
|
|
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 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;
|
|
});
|
|
|
|
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: 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("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");
|
|
});
|
|
});
|