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; 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 => ({ 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 => ({ 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) => { 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).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); }); });