Files
InkNote/frontend/src/syncEngine.test.ts
T
marcandClaude Opus 5 df279a5d36 Replace unused vector clocks with a conflict check that works, drop the rest
The vector clocks in the server were never reached: no client sent one,
so the branch was dead and every write was a blind overwrite. Not "the
newer edit wins" — "the request that arrives last wins", which is worse:
a phone that was offline all day buries the laptop's newer work simply by
reconnecting late.

Each write now carries baseUpdatedAt, the revision it was written on top
of. If the note has moved on since, the incoming edit still wins — a
rejection would throw away what was just typed — but the state it
replaces is kept as a version marked "conflict", so both exist and the
history is where you choose. Clocks could have told a real conflict from
a device catching up, but that precision only pays off with a merge
interface, and this reuses the version history already built.

adoptRevision() stores the revision the server reports back, or the
second save in a row would be flagged as conflicting with the first.

Also removed, as agreed:
- POST /api/notes/pdf — accepted PDFs that nothing could ever read back,
  with no retrieval route and no viewer. The readme advertised "PDF
  Import & Annotation"; that claim is gone too.
- POST /api/sync — a batch endpoint the client never called.
- the tags column — notebooks, starring and search already cover it.

Columns on existing databases are left in place. Dropping them buys
nothing and is not worth touching a live database for.

Tables now have a right-click menu (editor/tableMenu.js) for rows,
columns, header and merging. It needs stopPropagation: without it the
same event reaches window, where the menu that is mounting reads it as a
click elsewhere and closes instantly — the reason the other context menus
in App.jsx already do this.

92 browser checks across seven suites, 125 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 18:59:08 +02:00

420 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as db from "./offlineStore";
import * as sync from "./syncEngine";
import type { NoteMeta, Notebook } from "./types";
/* A stub transport. `req()` only ever touches status, ok and json(), so a plain
* object is enough and keeps the tests free of undici/jsdom differences. */
type Reply = { status: number; body?: unknown };
const reply = ({ status, body = {} }: Reply) => ({
status,
ok: status >= 200 && status < 300,
json: async () => body,
});
let calls: Array<{ method: string; url: string; body: unknown }> = [];
let handler: (method: string, url: string, body: unknown) => Reply | Promise<Reply>;
function installFetch() {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, opts: RequestInit = {}) => {
const method = opts.method || "GET";
const body = opts.body ? JSON.parse(opts.body as string) : undefined;
calls.push({ method, url, body });
return reply(await handler(method, url, body));
})
);
}
const succeed = () => {
handler = () => ({ status: 200, body: { id: "server-id" } });
};
const failWith = (status: number, body: unknown = { error: "nope" }) => {
handler = () => ({ status, body });
};
const respondWith = (body: unknown) => {
handler = () => ({ status: 200, body });
};
const failTransport = () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new TypeError("Failed to fetch");
})
);
};
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
id,
title: `Note ${id}`,
folderId: "nb1",
starred: false,
trashed: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...over,
});
const notebook = (id: string, over: Partial<Notebook> = {}): Notebook => ({
id,
name: `NB ${id}`,
cover: 0,
sortOrder: 0,
...over,
});
beforeEach(async () => {
await db.clearAll();
calls = [];
succeed();
installFetch();
sync.__testing.setToken("test-token");
sync.__testing.setOnline(true);
sync.__testing.resetPendingWrites();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
});
afterEach(() => {
// Restored here as well as in each test: a timed-out test never runs its own
// cleanup, and leaked fake timers would hang every hook after it.
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Fakes only setTimeout, so IndexedDB keeps working while the debounce does not. */
const withFakeDebounce = async (fn: () => Promise<void>) => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
await fn();
} finally {
vi.useRealTimers();
}
};
describe("flushQueue", () => {
it("removes an entry once the server accepted it", async () => {
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(0);
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual(["DELETE /api/notes/n1"]);
});
it("keeps a failed entry queued and counts the attempt — losing it silently ate offline edits", async () => {
failWith(500);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
const [entry] = await db.peekQueue();
expect(entry).toBeDefined();
expect(entry!.attempts).toBe(1);
expect(entry!.action).toBe("trashNote");
});
it("retries across flushes and gives up only after repeated failures", async () => {
failWith(500);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
for (let i = 1; i <= 4; i++) {
await sync.__testing.flushQueue();
const [entry] = await db.peekQueue();
expect(entry!.attempts).toBe(i);
}
// Fifth failure hits the cap; the entry is dropped so it cannot wedge the queue.
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(0);
});
it("stops at the first transport failure and keeps everything for later", async () => {
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await db.enqueue({ action: "trashNote", data: { id: "n2" } });
failTransport();
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(2);
const entries = await db.peekQueue();
// Nothing is counted as a failed attempt: being offline is not the entry's fault.
expect(entries.every((e) => e.attempts === undefined)).toBe(true);
});
it("propagates an auth failure and leaves the queue untouched", async () => {
failWith(401);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await expect(sync.__testing.flushQueue()).rejects.toThrow("AUTH");
expect(await db.getQueueSize()).toBe(1);
});
it("processes entries in the order they were queued", async () => {
await db.enqueue({ action: "trashNote", data: { id: "first" } });
await db.enqueue({ action: "trashNote", data: { id: "second" } });
await sync.__testing.flushQueue();
expect(calls.map((c) => c.url)).toEqual(["/api/notes/first", "/api/notes/second"]);
});
it("does nothing while signed out", async () => {
sync.__testing.setToken(null);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
expect(calls.length).toBe(0);
expect(await db.getQueueSize()).toBe(1);
});
it("repoints notes at the server id when a queued notebook is finally created", async () => {
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
await db.putNote(note("n1", { folderId: "nb_local" }));
await db.enqueue({
action: "createNotebook",
data: { localId: "nb_local", name: "Later", cover: 0, sortOrder: 0 },
});
await sync.__testing.flushQueue();
expect((await db.getNote("n1"))?.folderId).toBe("server-id");
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["server-id"]);
});
});
describe("fullSync", () => {
const serverState = (notes: unknown[], notebooks: unknown[]) => {
handler = (_method, url) => {
if (url.endsWith("/notebooks")) return { status: 200, body: notebooks };
if (url.endsWith("/notes/trash")) return { status: 200, body: [] };
if (url.endsWith("/notes")) return { status: 200, body: notes };
return { status: 200, body: {} };
};
};
it("mirrors the server's notes and notebooks", async () => {
serverState(
[{ id: "s1", title: "Server note", notebook_id: "snb", created_at: "t", updated_at: "t" }],
[{ id: "snb", name: "Server book", cover_index: 2, sort_order: 1 }]
);
await sync.fullSync();
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["s1"]);
const [nb] = await db.getAllNotebooks();
expect(nb).toMatchObject({ id: "snb", name: "Server book", cover: 2, sortOrder: 1 });
});
it("keeps notes created offline that the server has never seen", async () => {
await db.putNote(note("local_1", { _pendingCreate: true }));
await db.putNote(note("stale_1")); // synced earlier, since deleted elsewhere
serverState([], []);
await sync.fullSync();
const ids = (await db.getAllNotes()).map((n) => n.id);
expect(ids).toContain("local_1");
expect(ids).not.toContain("stale_1");
});
it("keeps notebooks created offline", async () => {
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
serverState([], []);
await sync.fullSync();
expect((await db.getAllNotebooks()).map((n) => n.id)).toContain("nb_local");
});
it("drops the local-only flag once the server reports the same id", async () => {
await db.putNote(note("s1", { _pendingCreate: true }));
serverState(
[{ id: "s1", title: "Now on server", notebook_id: null, created_at: "t", updated_at: "t" }],
[]
);
await sync.fullSync();
const [n] = await db.getAllNotes();
expect(n!._pendingCreate).toBeUndefined();
expect(n!.title).toBe("Now on server");
});
it("leaves the local mirror intact when a server read fails midway", async () => {
await db.putNote(note("keep"));
await db.putNotebook(notebook("keep_nb"));
handler = (_method, url) => {
if (url.endsWith("/notebooks")) return { status: 200, body: [] };
return { status: 500, body: { error: "boom" } }; // the notes read fails
};
await sync.fullSync();
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["keep"]);
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["keep_nb"]);
});
it("does nothing while signed out", async () => {
sync.__testing.setToken(null);
await sync.fullSync();
expect(calls.length).toBe(0);
});
});
describe("hasPendingWrites", () => {
it("is false without an id", () => {
expect(sync.hasPendingWrites(null)).toBe(false);
expect(sync.hasPendingWrites(undefined)).toBe(false);
expect(sync.hasPendingWrites("")).toBe(false);
});
it("is false for a note nobody is writing", () => {
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("reports a write that is in flight — the guard that stops stale reads winning", () => {
sync.__testing.beginWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(true);
expect(sync.hasPendingWrites("n2")).toBe(false);
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("stays true until the last of several overlapping writes finishes", () => {
sync.__testing.beginWrite("n1");
sync.__testing.beginWrite("n1");
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(true);
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("covers the debounce window before a save even starts", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "typing…");
// The request has not been sent yet, but the note must already count as busy.
expect(sync.hasPendingWrites("n1")).toBe(true);
await vi.runAllTimersAsync();
});
expect(sync.hasPendingWrites("n1")).toBe(false);
});
});
describe("updateNoteContent", () => {
it("writes locally before anything reaches the network", async () => {
await db.putNote(note("n1"));
await sync.updateNoteContent("n1", "body text");
expect((await db.getNoteContent("n1"))?.content).toBe("body text");
expect(calls.length).toBe(0); // still inside the debounce window
});
it("sends links along with the body, since the server cannot read it", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body", ["target-1"]);
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect(put?.body).toMatchObject({ content: "body", links: ["target-1"] });
});
it("omits the links field when the caller passes none", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect(put?.body).toMatchObject({ content: "body" });
expect(put?.body).not.toHaveProperty("links");
});
it("sends the revision the edit was based on, so the server can spot a conflict", async () => {
await db.putNote(note("n1"));
await db.putNoteContent("n1", "old", null, null, "2026-01-01T10:00:00.000Z");
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect((put?.body as Record<string, unknown>).baseUpdatedAt).toBe("2026-01-01T10:00:00.000Z");
});
it("adopts the revision the server reports, so the next save is not taken for a conflict", async () => {
await db.putNote(note("n1"));
await db.putNoteContent("n1", "old", null, null, "2026-01-01T10:00:00.000Z");
respondWith({ success: true, updatedAt: "2026-01-01T11:00:00.000Z" });
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
expect((await db.getNoteContent("n1"))?.updatedAt).toBe("2026-01-01T11:00:00.000Z");
});
it("queues the change when the request fails", async () => {
failWith(500);
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("updateNote");
});
});
describe("loadNoteContent", () => {
it("returns the cached body without waiting for the server", async () => {
await db.putNoteContent("n1", "cached", null);
const c = await sync.loadNoteContent("n1");
expect(c.content).toBe("cached");
});
it("does not refresh from the server while a write is in flight", async () => {
await db.putNoteContent("n1", "cached", null);
sync.__testing.beginWrite("n1");
await sync.loadNoteContent("n1");
expect(calls.length).toBe(0);
sync.__testing.endWrite("n1");
});
it("falls back to the cache when the server read fails", async () => {
await db.putNoteContent("n1", "cached", null);
failWith(500);
const c = await sync.loadNoteContent("n1", { forceRemote: true });
expect(c.content).toBe("cached");
});
it("returns an empty shape for a note with nothing cached and no connection", async () => {
sync.__testing.setOnline(false);
const c = await sync.loadNoteContent("missing");
expect(c).toEqual({ content: "", drawingData: null, drawingStrokes: null, updatedAt: null });
sync.__testing.setOnline(true);
});
});
describe("offline behaviour", () => {
beforeEach(() => sync.__testing.setOnline(false));
afterEach(() => sync.__testing.setOnline(true));
it("creates a note locally and queues it", async () => {
const n = await sync.createNote("nb1");
expect(n._pendingCreate).toBe(true);
expect(calls.length).toBe(0);
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("createNote");
});
it("creates a notebook locally, flagged so fullSync will not drop it", async () => {
const nb = await sync.createNotebook({ name: "Later", cover: 1 });
expect(nb._pendingCreate).toBe(true);
expect((await db.getAllNotebooks())[0]?._pendingCreate).toBe(true);
});
it("queues a star toggle instead of losing it", async () => {
await db.putNote(note("n1"));
expect(await sync.toggleNoteStar("n1")).toBe(true);
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("updateNote");
expect((await db.getNote("n1"))?.starred).toBe(true);
});
});