import { expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { addNote, BODY_MAX, cleanTitle, deleteNote, findNote, getNote, listNotes, openOrCreate, searchNotes, today, updateNote, } from "./actions.ts"; import { openDb } from "./db.ts"; import type { Who } from "./who.ts"; const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); const anon: Who = { role: "anon" }; const alice: Who = { role: "user", id: "u1", name: "alice" }; const bob: Who = { role: "user", id: "u2", name: "bob" }; const root: Who = { role: "admin", id: "u0", name: "root" }; const must = (r: { ok: true; value: T } | { ok: false; message: string }): T => { if (!r.ok) throw new Error(r.message); return r.value; }; test("a vault is its author's: nobody else lists or reads it, admins included", () => { const refused = addNote(db, anon, { title: "x" }); expect(refused.ok).toBe(false); if (!refused.ok) expect(refused.error).toBe("signin_required"); const mine = must(addNote(db, alice, { title: " Secret plans ", body: "the plan" })); expect(mine.title).toBe("Secret plans"); expect(mine.author_name).toBe("alice"); expect(must(listNotes(db, alice)).map((n) => n.id)).toContain(mine.id); expect(must(listNotes(db, bob))).toEqual([]); expect(must(listNotes(db, root))).toEqual([]); for (const who of [bob, root]) { const read = getNote(db, who, { id: mine.id }); expect(read.ok).toBe(false); if (!read.ok) expect(read.error).toBe("forbidden"); expect(updateNote(db, who, { id: mine.id, body: "mine now" }).ok).toBe(false); expect(deleteNote(db, who, { id: mine.id }).ok).toBe(false); } const listed = listNotes(db, anon); expect(listed.ok).toBe(false); // Two people may each have a note of the same name. expect(addNote(db, bob, { title: "secret plans" }).ok).toBe(true); }); test("titles are unique in a vault, any case; blank means the next Untitled", () => { expect(must(addNote(db, alice, {})).title).toBe("Untitled"); expect(must(addNote(db, alice, { title: "" })).title).toBe("Untitled 2"); const dup = addNote(db, alice, { title: "SECRET PLANS" }); expect(dup.ok).toBe(false); if (!dup.ok) expect(dup.error).toBe("invalid"); for (const bad of ["a [b]", "x|y", "a#b", "^", "x".repeat(201), " "]) expect([bad, cleanTitle(bad).ok]).toEqual([bad, false]); expect(cleanTitle("2026-10-02").ok).toBe(true); const big = addNote(db, alice, { title: "big", body: "x".repeat(BODY_MAX + 1) }); expect(big.ok).toBe(false); }); test("renaming a note carries every link to it, keeping aliases and headings", () => { const target = must(addNote(db, alice, { title: "Garden" })); const a = must(addNote(db, alice, { title: "Links A", body: "see [[Garden]] and [[garden#Beds|the beds]]" })); const b = must(addNote(db, alice, { title: "Links B", body: "no link, just garden" })); // Bob's note links a Garden of his own: a rename in alice's vault leaves it be. const bobs = must(addNote(db, bob, { title: "Bob's", body: "[[Garden]]" })); const renamed = must(updateNote(db, alice, { id: target.id, title: "Allotment" })); expect(renamed.title).toBe("Allotment"); expect(renamed.relinked).toBe(1); expect(must(getNote(db, alice, { id: a.id })).body).toBe( "see [[Allotment]] and [[Allotment#Beds|the beds]]", ); expect(must(getNote(db, alice, { id: b.id })).body).toBe("no link, just garden"); expect(must(getNote(db, bob, { id: bobs.id })).body).toBe("[[Garden]]"); // A rename onto a title in use is refused, and changes nothing. const clash = updateNote(db, alice, { id: target.id, title: "links a" }); expect(clash.ok).toBe(false); expect(must(getNote(db, alice, { id: target.id })).title).toBe("Allotment"); }); test("pinning floats a note to the top", () => { const pinned = must(addNote(db, alice, { title: "Pin me" })); must(addNote(db, alice, { title: "Newer" })); must(updateNote(db, alice, { id: pinned.id, pinned: true })); expect(must(listNotes(db, alice))[0]?.id).toBe(pinned.id); must(updateNote(db, alice, { id: pinned.id, pinned: false })); expect(must(listNotes(db, alice))[0]?.id).not.toBe(pinned.id); }); test("following a link opens the note, or makes it", () => { const made = must(openOrCreate(db, alice, { title: "Reading list" })); expect(made.created).toBe(true); const again = must(openOrCreate(db, alice, { title: "reading LIST" })); expect(again.created).toBe(false); expect(again.id).toBe(made.id); expect(must(findNote(db, alice, { title: "Reading list" })).id).toBe(made.id); expect(findNote(db, bob, { title: "Reading list" }).ok).toBe(false); }); test("search finds words, phrases and tags, in your vault only", () => { must(addNote(db, alice, { title: "Recipes", body: "Bread with #cooking/baking\nsour dough starter" })); must(addNote(db, alice, { title: "Groceries", body: "flour, #cooking" })); must(addNote(db, bob, { title: "Bob recipes", body: "#cooking" })); const titles = (q: string) => must(searchNotes(db, alice, { q })).map((h) => h.title); expect(titles("#cooking").sort()).toEqual(["Groceries", "Recipes"]); expect(titles("tag:cooking/baking")).toEqual(["Recipes"]); expect(titles('"sour dough"')).toEqual(["Recipes"]); expect(titles("dough flour")).toEqual([]); expect(titles("recipes")).toEqual(["Recipes"]); expect(must(searchNotes(db, alice, { q: "sour" }))[0]?.snippet).toContain("sour dough"); expect(searchNotes(db, anon, { q: "cooking" }).ok).toBe(false); }); test("only the author removes a note", () => { const mine = must(addNote(db, alice, { title: "Ephemeral" })); const byAnon = deleteNote(db, anon, { id: mine.id }); expect(byAnon.ok).toBe(false); if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); expect(deleteNote(db, alice, { id: mine.id }).ok).toBe(true); const gone = deleteNote(db, alice, { id: mine.id }); expect(gone.ok).toBe(false); if (!gone.ok) expect(gone.error).toBe("not_found"); const odd = deleteNote(db, alice, { id: 1.5 }); expect(odd.ok).toBe(false); if (!odd.ok) expect(odd.error).toBe("invalid"); }); test("today is the date where you are", () => { const at = new Date("2026-10-02T02:00:00Z"); expect(today(0, at)).toBe("2026-10-02"); // Chicago in October is UTC-5: getTimezoneOffset() says 300. expect(today(300, at)).toBe("2026-10-01"); expect(today(-600, at)).toBe("2026-10-02"); });