Files
InkNote/frontend/src/offlineStore.test.ts
T
marcandClaude Opus 5 92d525b3ab TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.

Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
  missing; it refuses to start. The old fallback silently made every stored
  note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
  not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
  already eaten drawing strokes and note links; sync.hasPendingWrites() now
  guards every path that takes a body from the server.

Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
  to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
  into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
  encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
  fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
  the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.

Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
  covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
  unreadable.

Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
  search, layout, history and drag each live behind a hook, and the four
  panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
  the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
  restoring a history entry pushed a duplicate one.

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

176 lines
6.1 KiB
TypeScript

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