import { expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { addNote, deleteNote, getNote, listNotes, searchNotes, updateNote } from "./actions.ts"; import { openDb } from "./db.ts"; import { cleanFolder, createSpace, deleteSpace, fileOf, folderOf, joinSpace, mySpaces, removeMember, setLink, } from "./spaces.ts"; import { blobOid, syncSpace, type Entry, type Forge } from "./sync.ts"; // @ts-ignore — plain JS, shared with the browser. import * as md from "../static/md.js"; import type { Who } from "./who.ts"; const db = openDb(mkdtempSync(join(tmpdir(), "app-spaces-"))); const ben: Who = { role: "admin", id: "1", name: "ben" }; const sam: Who = { role: "user", id: "acct:7", name: "sam" }; const eve: Who = { role: "user", id: "9", name: "eve" }; const must = (r: { ok: true; value: T } | { ok: false; message: string }): T => { if (!r.ok) throw new Error(r.message); return r.value; }; /** A repository in memory, behind the same four calls the forge answers. */ function fakeForge(files: Record = {}) { const tree = new Map(Object.entries(files)); let head = tree.size === 0 ? null : "c0"; let n = 0; const commits: { message: string; files: { path: string; content: string | null }[] }[] = []; const forge: Forge = { async head() { return head; }, async list(_repo, _ref, path) { const prefix = path === "" ? "" : `${path}/`; const seen = new Map(); for (const [p, content] of tree) { if (!p.startsWith(prefix)) continue; const rest = p.slice(prefix.length); const name = rest.split("/")[0]!; if (rest.includes("/")) seen.set(name, { name, type: "tree", oid: `t-${name}`, size: -1 }); else seen.set(name, { name, type: "blob", oid: blobOid(content), size: content.length }); } return seen.size === 0 && path !== "" ? null : [...seen.values()]; }, async read(_repo, _ref, path) { const content = tree.get(path); if (content === undefined) throw new Error(`no ${path}`); return content; }, async commit(_repo, _branch, message, changes) { for (const f of changes) f.content === null ? tree.delete(f.path) : tree.set(f.path, f.content); commits.push({ message, files: changes }); head = `c${++n}`; return head; }, }; return { forge, tree, commits, /** Someone pushes to the repository, outside the app. */ push(changes: Record) { for (const [p, c] of Object.entries(changes)) c === null ? tree.delete(p) : tree.set(p, c); head = `x${++n}`; }, }; } test("only an app admin connects a repository; a join link lets someone in, and only into that folder", () => { const refused = createSpace(db, sam, { name: "Nope", repo: "sam/x", folder: "docs" }); expect(refused.ok).toBe(false); if (!refused.ok) expect(refused.error).toBe("forbidden"); expect(createSpace(db, ben, { name: "Bad", repo: "not a repo" }).ok).toBe(false); expect(createSpace(db, ben, { name: "Bad", repo: "ben/x", folder: "../etc" }).ok).toBe(false); const space = must(createSpace(db, ben, { name: "Brainstorm", repo: "ben/brainstorm", folder: "/docs/" })); expect(space.folder).toBe("docs"); expect(space.branch).toBe("main"); expect(mySpaces(db, ben).map((s) => s.id)).toEqual([space.id]); expect(mySpaces(db, sam)).toEqual([]); const mine = must(addNote(db, ben, { title: "My own thoughts" })); const shared = must(addNote(db, ben, { title: "Pricing", space: space.id })); expect(shared.path).toBe("docs/Pricing.md"); // A stranger can't see the folder or its notes, by id or by list. expect(getNote(db, sam, { id: shared.id }).ok).toBe(false); expect(listNotes(db, sam, { space: space.id }).ok).toBe(false); expect(addNote(db, sam, { title: "sneaky", space: space.id }).ok).toBe(false); must(joinSpace(db, sam, { code: space.join_code! })); expect(must(listNotes(db, sam, { space: space.id })).map((n) => n.title)).toEqual(["Pricing"]); expect(getNote(db, sam, { id: shared.id }).ok).toBe(true); // …and still nothing of Ben's own. expect(getNote(db, sam, { id: mine.id }).ok).toBe(false); expect(must(listNotes(db, sam)).map((n) => n.id)).not.toContain(mine.id); // Shared notes never show in anyone's own vault, Ben's included. expect(must(listNotes(db, ben)).map((n) => n.id)).not.toContain(shared.id); expect(must(searchNotes(db, ben, { q: "Pricing" }))).toEqual([]); expect(must(searchNotes(db, sam, { q: "Pricing", space: space.id })).length).toBe(1); // Sam writes; the note says so. const edited = must(updateNote(db, sam, { id: shared.id, body: "per seat?" })); expect(edited.author_name).toBe("sam"); // The link off, nobody new gets in; the owner removes a member; the owner can't be removed. const off = must(setLink(db, ben, { id: space.id, on: false })); expect(joinSpace(db, eve, { code: space.join_code! }).ok).toBe(false); expect(off.join_code).toBeNull(); expect(setLink(db, sam, { id: space.id, on: true }).ok).toBe(false); expect(removeMember(db, sam, { id: space.id, member: ben.id }).ok).toBe(false); must(removeMember(db, ben, { id: space.id, member: sam.id })); expect(getNote(db, sam, { id: shared.id }).ok).toBe(false); must(deleteSpace(db, ben, { id: space.id })); expect(getNote(db, ben, { id: shared.id }).ok).toBe(false); expect(getNote(db, ben, { id: mine.id }).ok).toBe(true); }); test("a shared note's title is a file name, its folder a path; renames and moves carry the file", () => { const space = must(createSpace(db, ben, { name: "Paths", repo: "ben/paths", folder: "docs" })); expect(addNote(db, ben, { title: "a:b", space: space.id }).ok).toBe(false); const deep = must(addNote(db, ben, { title: "ideas/q4/Launch", space: space.id })); expect(deep.title).toBe("Launch"); expect(deep.path).toBe("docs/ideas/q4/Launch.md"); expect(folderOf(space, deep.path!)).toBe("ideas/q4"); const renamed = must(updateNote(db, ben, { id: deep.id, title: "Launch plan" })); expect(renamed.path).toBe("docs/ideas/q4/Launch plan.md"); const moved = must(updateNote(db, ben, { id: deep.id, folder: "archive" })); expect(moved.path).toBe("docs/archive/Launch plan.md"); expect(must(updateNote(db, ben, { id: deep.id, folder: "" })).path).toBe("docs/Launch plan.md"); expect(cleanFolder(" a / b ").ok && must(cleanFolder(" a / b "))).toBe("a/b"); expect(fileOf({ folder: "" }, "", "Top")).toBe("Top.md"); }); test("someone else saved first: new words are a conflict, not an overwrite", async () => { const space = must(createSpace(db, ben, { name: "Race", repo: "ben/race" })); must(joinSpace(db, sam, { code: space.join_code! })); const note = must(addNote(db, ben, { title: "Plan", space: space.id, body: "v1" })); await Bun.sleep(3); // updated_at is to the millisecond const theirs = must(updateNote(db, sam, { id: note.id, body: "sam's v2", base: note.updated_at })); const mine = updateNote(db, ben, { id: note.id, body: "ben's v2", base: note.updated_at }); expect(mine.ok).toBe(false); if (!mine.ok) expect(mine.error).toBe("conflict"); expect(must(getNote(db, ben, { id: note.id })).body).toBe("sam's v2"); // Written against what is there now, it goes through. expect(must(updateNote(db, ben, { id: note.id, body: "ben's v3", base: theirs.updated_at })).body).toBe("ben's v3"); }); test("sync reads the folder, commits what is written here, and takes what is pushed there", async () => { const repo = fakeForge({ "README.md": "# not in the folder", "docs/Ideas.md": "---\ntags: [brainstorm, q4]\n---\nSee [[Pricing]].", "docs/research/Pricing.md": "per seat? #money", "docs/research/chart.png": "binary", "docs/.obsidian/workspace.md": "editor settings", }); const space = must(createSpace(db, ben, { name: "Synced", repo: "ben/synced", folder: "docs" })); const first = await syncSpace(db, repo.forge, space.id); expect(first.pulled).toBe(2); const notes = () => must(listNotes(db, ben, { space: space.id })); const byTitle = (t: string) => notes().find((n) => n.title === t)!; expect(notes().map((n) => n.path).sort()).toEqual(["docs/Ideas.md", "docs/research/Pricing.md"]); expect(md.tags(byTitle("Ideas").body).sort()).toEqual(["brainstorm", "q4"]); // Nothing changed here, so nothing is committed. expect((await syncSpace(db, repo.forge, space.id)).pushed).toBe(0); expect(repo.commits).toEqual([]); // Written, made, renamed and removed here → one commit. must(updateNote(db, ben, { id: byTitle("Ideas").id, body: "See [[Pricing]] and [[Naming]]." })); must(addNote(db, ben, { title: "Naming", space: space.id, folder: "research", body: "names" })); must(updateNote(db, ben, { id: byTitle("Pricing").id, title: "Pricing model" })); const wrote = await syncSpace(db, repo.forge, space.id); expect(repo.commits.length).toBe(1); expect(repo.commits[0]!.message).toContain("Written in notes by ben"); expect(wrote.pushed).toBe(4); expect([...repo.tree.keys()].sort()).toEqual([ "README.md", "docs/.obsidian/workspace.md", "docs/Ideas.md", "docs/research/Naming.md", "docs/research/Pricing model.md", "docs/research/chart.png", ]); expect(repo.tree.get("docs/Ideas.md")).toBe("See [[Pricing model]] and [[Naming]]."); // The next read finds our own commit and changes nothing. expect((await syncSpace(db, repo.forge, space.id)).pulled).toBe(0); must(deleteNote(db, ben, { id: byTitle("Naming").id })); await syncSpace(db, repo.forge, space.id); expect(repo.tree.has("docs/research/Naming.md")).toBe(false); // Someone pushes: a change, a new file, a removal. repo.push({ "docs/Ideas.md": "Rewritten on a laptop.", "docs/meetings/2026-10-13.md": "# Kickoff", "docs/research/Pricing model.md": null, }); const read = await syncSpace(db, repo.forge, space.id); expect(read.pulled).toBe(3); expect(byTitle("Ideas").body).toBe("Rewritten on a laptop."); expect(byTitle("Ideas").author_name).toBe("the repository"); expect(byTitle("2026-10-13").path).toBe("docs/meetings/2026-10-13.md"); expect(notes().some((n) => n.title === "Pricing model")).toBe(false); expect(repo.commits.length).toBe(2); }); test("edited in both places: ours is committed, theirs is kept beside it — nothing is lost", async () => { const repo = fakeForge({ "Plan.md": "v1" }); const space = must(createSpace(db, ben, { name: "Both", repo: "ben/both" })); await syncSpace(db, repo.forge, space.id); const plan = must(listNotes(db, ben, { space: space.id }))[0]!; must(updateNote(db, ben, { id: plan.id, body: "ours" })); repo.push({ "Plan.md": "theirs" }); const r = await syncSpace(db, repo.forge, space.id); expect(r.copies).toBe(1); expect(repo.tree.get("Plan.md")).toBe("ours"); expect(repo.tree.get("Plan (repo copy).md")).toBe("theirs"); const titles = must(listNotes(db, ben, { space: space.id })).map((n) => n.title).sort(); expect(titles).toEqual(["Plan", "Plan (repo copy)"]); }); test("a repository that can't be read is said, and the space remembers why", async () => { const space = must(createSpace(db, ben, { name: "Broken", repo: "ben/gone" })); const broken: Forge = { ...fakeForge().forge, head: async () => { throw new Error("No repository ben/gone — or the token can't see it."); }, }; await expect(syncSpace(db, broken, space.id)).rejects.toThrow("No repository"); const row = mySpaces(db, ben).find((s) => s.id === space.id)!; expect(row.sync_error).toContain("No repository"); }); test("properties at the top of a note read as properties, not as a rule and a paragraph", () => { const html = md.render("---\ntitle: Plan\ntags:\n - a\n - b\n---\n# Body", {}); expect(html).toContain('
'); expect(html).toContain("
a, b
"); expect(html).not.toContain("
"); expect(md.tags("---\ntags: x, y\n---\n#z")).toEqual(["x", "y", "z"]); expect(md.render("[[Missing]] #t", { base: "/s/3" })).toContain('href="/s/3/open?title=Missing"'); });