| 1 |
import { expect, test } from "bun:test"; |
| 2 |
import { mkdtempSync } from "node:fs"; |
| 3 |
import { tmpdir } from "node:os"; |
| 4 |
import { join } from "node:path"; |
| 5 |
import { |
| 6 |
addNote, |
| 7 |
BODY_MAX, |
| 8 |
cleanTitle, |
| 9 |
deleteNote, |
| 10 |
findNote, |
| 11 |
getNote, |
| 12 |
listNotes, |
| 13 |
openOrCreate, |
| 14 |
searchNotes, |
| 15 |
today, |
| 16 |
updateNote, |
| 17 |
} from "./actions.ts"; |
| 18 |
import { openDb } from "./db.ts"; |
| 19 |
import type { Who } from "./who.ts"; |
| 20 |
|
| 21 |
const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); |
| 22 |
const anon: Who = { role: "anon" }; |
| 23 |
const alice: Who = { role: "user", id: "u1", name: "alice" }; |
| 24 |
const bob: Who = { role: "user", id: "u2", name: "bob" }; |
| 25 |
const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 26 |
|
| 27 |
const must = <T>(r: { ok: true; value: T } | { ok: false; message: string }): T => { |
| 28 |
if (!r.ok) throw new Error(r.message); |
| 29 |
return r.value; |
| 30 |
}; |
| 31 |
|
| 32 |
test("a vault is its author's: nobody else lists or reads it, admins included", () => { |
| 33 |
const refused = addNote(db, anon, { title: "x" }); |
| 34 |
expect(refused.ok).toBe(false); |
| 35 |
if (!refused.ok) expect(refused.error).toBe("signin_required"); |
| 36 |
const mine = must(addNote(db, alice, { title: " Secret plans ", body: "the plan" })); |
| 37 |
expect(mine.title).toBe("Secret plans"); |
| 38 |
expect(mine.author_name).toBe("alice"); |
| 39 |
expect(must(listNotes(db, alice)).map((n) => n.id)).toContain(mine.id); |
| 40 |
expect(must(listNotes(db, bob))).toEqual([]); |
| 41 |
expect(must(listNotes(db, root))).toEqual([]); |
| 42 |
for (const who of [bob, root]) { |
| 43 |
const read = getNote(db, who, { id: mine.id }); |
| 44 |
expect(read.ok).toBe(false); |
| 45 |
if (!read.ok) expect(read.error).toBe("forbidden"); |
| 46 |
expect(updateNote(db, who, { id: mine.id, body: "mine now" }).ok).toBe(false); |
| 47 |
expect(deleteNote(db, who, { id: mine.id }).ok).toBe(false); |
| 48 |
} |
| 49 |
const listed = listNotes(db, anon); |
| 50 |
expect(listed.ok).toBe(false); |
| 51 |
|
| 52 |
expect(addNote(db, bob, { title: "secret plans" }).ok).toBe(true); |
| 53 |
}); |
| 54 |
|
| 55 |
test("titles are unique in a vault, any case; blank means the next Untitled", () => { |
| 56 |
expect(must(addNote(db, alice, {})).title).toBe("Untitled"); |
| 57 |
expect(must(addNote(db, alice, { title: "" })).title).toBe("Untitled 2"); |
| 58 |
const dup = addNote(db, alice, { title: "SECRET PLANS" }); |
| 59 |
expect(dup.ok).toBe(false); |
| 60 |
if (!dup.ok) expect(dup.error).toBe("invalid"); |
| 61 |
for (const bad of ["a [b]", "x|y", "a#b", "^", "x".repeat(201), " "]) |
| 62 |
expect([bad, cleanTitle(bad).ok]).toEqual([bad, false]); |
| 63 |
expect(cleanTitle("2026-10-02").ok).toBe(true); |
| 64 |
const big = addNote(db, alice, { title: "big", body: "x".repeat(BODY_MAX + 1) }); |
| 65 |
expect(big.ok).toBe(false); |
| 66 |
}); |
| 67 |
|
| 68 |
test("renaming a note carries every link to it, keeping aliases and headings", () => { |
| 69 |
const target = must(addNote(db, alice, { title: "Garden" })); |
| 70 |
const a = must(addNote(db, alice, { title: "Links A", body: "see [[Garden]] and [[garden#Beds|the beds]]" })); |
| 71 |
const b = must(addNote(db, alice, { title: "Links B", body: "no link, just garden" })); |
| 72 |
|
| 73 |
const bobs = must(addNote(db, bob, { title: "Bob's", body: "[[Garden]]" })); |
| 74 |
const renamed = must(updateNote(db, alice, { id: target.id, title: "Allotment" })); |
| 75 |
expect(renamed.title).toBe("Allotment"); |
| 76 |
expect(renamed.relinked).toBe(1); |
| 77 |
expect(must(getNote(db, alice, { id: a.id })).body).toBe( |
| 78 |
"see [[Allotment]] and [[Allotment#Beds|the beds]]", |
| 79 |
); |
| 80 |
expect(must(getNote(db, alice, { id: b.id })).body).toBe("no link, just garden"); |
| 81 |
expect(must(getNote(db, bob, { id: bobs.id })).body).toBe("[[Garden]]"); |
| 82 |
|
| 83 |
const clash = updateNote(db, alice, { id: target.id, title: "links a" }); |
| 84 |
expect(clash.ok).toBe(false); |
| 85 |
expect(must(getNote(db, alice, { id: target.id })).title).toBe("Allotment"); |
| 86 |
}); |
| 87 |
|
| 88 |
test("pinning floats a note to the top", () => { |
| 89 |
const pinned = must(addNote(db, alice, { title: "Pin me" })); |
| 90 |
must(addNote(db, alice, { title: "Newer" })); |
| 91 |
must(updateNote(db, alice, { id: pinned.id, pinned: true })); |
| 92 |
expect(must(listNotes(db, alice))[0]?.id).toBe(pinned.id); |
| 93 |
must(updateNote(db, alice, { id: pinned.id, pinned: false })); |
| 94 |
expect(must(listNotes(db, alice))[0]?.id).not.toBe(pinned.id); |
| 95 |
}); |
| 96 |
|
| 97 |
test("following a link opens the note, or makes it", () => { |
| 98 |
const made = must(openOrCreate(db, alice, { title: "Reading list" })); |
| 99 |
expect(made.created).toBe(true); |
| 100 |
const again = must(openOrCreate(db, alice, { title: "reading LIST" })); |
| 101 |
expect(again.created).toBe(false); |
| 102 |
expect(again.id).toBe(made.id); |
| 103 |
expect(must(findNote(db, alice, { title: "Reading list" })).id).toBe(made.id); |
| 104 |
expect(findNote(db, bob, { title: "Reading list" }).ok).toBe(false); |
| 105 |
}); |
| 106 |
|
| 107 |
test("search finds words, phrases and tags, in your vault only", () => { |
| 108 |
must(addNote(db, alice, { title: "Recipes", body: "Bread with #cooking/baking\nsour dough starter" })); |
| 109 |
must(addNote(db, alice, { title: "Groceries", body: "flour, #cooking" })); |
| 110 |
must(addNote(db, bob, { title: "Bob recipes", body: "#cooking" })); |
| 111 |
const titles = (q: string) => must(searchNotes(db, alice, { q })).map((h) => h.title); |
| 112 |
expect(titles("#cooking").sort()).toEqual(["Groceries", "Recipes"]); |
| 113 |
expect(titles("tag:cooking/baking")).toEqual(["Recipes"]); |
| 114 |
expect(titles('"sour dough"')).toEqual(["Recipes"]); |
| 115 |
expect(titles("dough flour")).toEqual([]); |
| 116 |
expect(titles("recipes")).toEqual(["Recipes"]); |
| 117 |
expect(must(searchNotes(db, alice, { q: "sour" }))[0]?.snippet).toContain("sour dough"); |
| 118 |
expect(searchNotes(db, anon, { q: "cooking" }).ok).toBe(false); |
| 119 |
}); |
| 120 |
|
| 121 |
test("only the author removes a note", () => { |
| 122 |
const mine = must(addNote(db, alice, { title: "Ephemeral" })); |
| 123 |
const byAnon = deleteNote(db, anon, { id: mine.id }); |
| 124 |
expect(byAnon.ok).toBe(false); |
| 125 |
if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); |
| 126 |
expect(deleteNote(db, alice, { id: mine.id }).ok).toBe(true); |
| 127 |
const gone = deleteNote(db, alice, { id: mine.id }); |
| 128 |
expect(gone.ok).toBe(false); |
| 129 |
if (!gone.ok) expect(gone.error).toBe("not_found"); |
| 130 |
const odd = deleteNote(db, alice, { id: 1.5 }); |
| 131 |
expect(odd.ok).toBe(false); |
| 132 |
if (!odd.ok) expect(odd.error).toBe("invalid"); |
| 133 |
}); |
| 134 |
|
| 135 |
test("today is the date where you are", () => { |
| 136 |
const at = new Date("2026-10-02T02:00:00Z"); |
| 137 |
expect(today(0, at)).toBe("2026-10-02"); |
| 138 |
|
| 139 |
expect(today(300, at)).toBe("2026-10-01"); |
| 140 |
expect(today(-600, at)).toBe("2026-10-02"); |
| 141 |
}); |