import { describe, it, expect, beforeEach } from "vitest"; import * as db from "./offlineStore"; import type { NoteMeta, Notebook } from "./types"; 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, sortOrder = 0): Notebook => ({ id, name: `NB ${id}`, cover: 0, sortOrder }); beforeEach(async () => { await db.clearAll(); }); describe("notes", () => { it("stores and reads back a note", async () => { await db.putNote(note("a")); expect(await db.getNote("a")).toMatchObject({ id: "a", title: "Note a" }); }); it("returns undefined for a note that does not exist", async () => { expect(await db.getNote("nope")).toBeUndefined(); }); it("writes a batch in one transaction", async () => { await db.putNotes([note("a"), note("b"), note("c")]); expect((await db.getAllNotes()).length).toBe(3); }); it("deleting a note also drops its body, leaving nothing orphaned", async () => { await db.putNote(note("a")); await db.putNoteContent("a", "content", null); await db.deleteNote("a"); expect(await db.getNote("a")).toBeUndefined(); expect(await db.getNoteContent("a")).toBeUndefined(); }); }); describe("note content", () => { it("keeps content, drawing and revision together", async () => { const strokes = { v: 1, w: 1200, h: 1600, strokes: [{ t: "pen" as const, c: "#fff", s: 5, p: [0, 0, 1, 1] }], }; await db.putNoteContent("a", "body", null, strokes, "2026-02-02T00:00:00.000Z"); const got = await db.getNoteContent("a"); expect(got?.content).toBe("body"); expect(got?.drawingStrokes?.strokes.length).toBe(1); expect(got?.updatedAt).toBe("2026-02-02T00:00:00.000Z"); }); it("defaults the drawing fields rather than leaving them undefined", async () => { await db.putNoteContent("a", "body", null); const got = await db.getNoteContent("a"); expect(got?.drawingStrokes).toBeNull(); expect(got?.updatedAt).toBeNull(); }); it("overwrites an existing body", async () => { await db.putNoteContent("a", "first", null); await db.putNoteContent("a", "second", null); expect((await db.getNoteContent("a"))?.content).toBe("second"); }); }); describe("notebooks", () => { it("returns notebooks in sort order, not insertion order", async () => { await db.putNotebooks([notebook("c", 2), notebook("a", 0), notebook("b", 1)]); expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["a", "b", "c"]); }); }); describe("sync queue", () => { it("preserves insertion order", async () => { await db.enqueue({ action: "first", data: {} }); await db.enqueue({ action: "second", data: {} }); expect((await db.peekQueue()).map((e) => e.action)).toEqual(["first", "second"]); }); it("counts what is waiting", async () => { await db.enqueue({ action: "a", data: {} }); await db.enqueue({ action: "b", data: {} }); expect(await db.getQueueSize()).toBe(2); }); it("removes only the dequeued entry", async () => { await db.enqueue({ action: "keep", data: {} }); await db.enqueue({ action: "drop", data: {} }); const [, second] = await db.peekQueue(); await db.dequeue(second!.queueId); expect((await db.peekQueue()).map((e) => e.action)).toEqual(["keep"]); }); it("records a failed attempt without losing the entry — the bug that silently ate offline edits", async () => { await db.enqueue({ action: "updateNote", data: { id: "a" } }); const [entry] = await db.peekQueue(); await db.updateQueueEntry(entry!.queueId, { attempts: 1, lastError: "boom" }); const [updated] = await db.peekQueue(); expect(updated!.attempts).toBe(1); expect(updated!.lastError).toBe("boom"); expect(updated!.action).toBe("updateNote"); expect(updated!.data).toEqual({ id: "a" }); }); it("ignores an update for an entry that is already gone", async () => { await expect(db.updateQueueEntry(9999, { attempts: 1 })).resolves.toBeUndefined(); }); }); describe("attachments", () => { /* jsdom's Blob is a stub without `arrayBuffer`, and fake-indexeddb's * structured clone flattens it to `{}` — so real Blob fidelity cannot be * asserted here. A byte array clones faithfully and still exercises the * store's own logic; that images survive a round trip through a real browser * is covered by the end-to-end suite instead. */ it("round-trips binary payloads", async () => { const bytes = new Uint8Array([1, 2, 3, 250]); await db.putAttachmentBlob("att1", bytes as unknown as Blob); const got = (await db.getAttachmentBlob("att1")) as unknown as Uint8Array; expect(got).not.toBeNull(); expect(Array.from(got)).toEqual([1, 2, 3, 250]); }); it("returns null for an attachment that was never cached", async () => { expect(await db.getAttachmentBlob("missing")).toBeNull(); }); }); describe("meta", () => { it("stores arbitrary values", async () => { await db.setMeta("user", { id: "u1", username: "marc" }); expect(await db.getMeta("user")).toEqual({ id: "u1", username: "marc" }); }); it("returns null for an unset key", async () => { expect(await db.getMeta("nothing")).toBeNull(); }); it("deletes a key", async () => { await db.setMeta("token", "abc"); await db.deleteMeta("token"); expect(await db.getMeta("token")).toBeNull(); }); }); describe("clearAll", () => { it("wipes every store, as logout must", async () => { await db.putNote(note("a")); await db.putNoteContent("a", "body", null); await db.putNotebook(notebook("nb1")); await db.enqueue({ action: "x", data: {} }); await db.setMeta("token", "abc"); await db.putAttachmentBlob("att", new Blob(["x"])); await db.clearAll(); expect(await db.getAllNotes()).toEqual([]); expect(await db.getNoteContent("a")).toBeUndefined(); expect(await db.getAllNotebooks()).toEqual([]); expect(await db.getQueueSize()).toBe(0); expect(await db.getMeta("token")).toBeNull(); expect(await db.getAttachmentBlob("att")).toBeNull(); }); });