| 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 { addNote, deleteNote, getNote, listNotes, searchNotes, updateNote } from "./actions.ts"; |
| 6 |
import { openDb } from "./db.ts"; |
| 7 |
import { |
| 8 |
cleanFolder, |
| 9 |
createSpace, |
| 10 |
deleteSpace, |
| 11 |
fileOf, |
| 12 |
folderOf, |
| 13 |
joinSpace, |
| 14 |
mySpaces, |
| 15 |
removeMember, |
| 16 |
setLink, |
| 17 |
} from "./spaces.ts"; |
| 18 |
import { blobOid, syncSpace, type Entry, type Forge } from "./sync.ts"; |
| 19 |
|
| 20 |
import * as md from "../static/md.js"; |
| 21 |
import type { Who } from "./who.ts"; |
| 22 |
|
| 23 |
const db = openDb(mkdtempSync(join(tmpdir(), "app-spaces-"))); |
| 24 |
const ben: Who = { role: "admin", id: "1", name: "ben" }; |
| 25 |
const sam: Who = { role: "user", id: "acct:7", name: "sam" }; |
| 26 |
const eve: Who = { role: "user", id: "9", name: "eve" }; |
| 27 |
|
| 28 |
const must = <T>(r: { ok: true; value: T } | { ok: false; message: string }): T => { |
| 29 |
if (!r.ok) throw new Error(r.message); |
| 30 |
return r.value; |
| 31 |
}; |
| 32 |
|
| 33 |
|
| 34 |
function fakeForge(files: Record<string, string> = {}) { |
| 35 |
const tree = new Map(Object.entries(files)); |
| 36 |
let head = tree.size === 0 ? null : "c0"; |
| 37 |
let n = 0; |
| 38 |
const commits: { message: string; files: { path: string; content: string | null }[] }[] = []; |
| 39 |
const forge: Forge = { |
| 40 |
async head() { |
| 41 |
return head; |
| 42 |
}, |
| 43 |
async list(_repo, _ref, path) { |
| 44 |
const prefix = path === "" ? "" : `${path}/`; |
| 45 |
const seen = new Map<string, Entry>(); |
| 46 |
for (const [p, content] of tree) { |
| 47 |
if (!p.startsWith(prefix)) continue; |
| 48 |
const rest = p.slice(prefix.length); |
| 49 |
const name = rest.split("/")[0]!; |
| 50 |
if (rest.includes("/")) seen.set(name, { name, type: "tree", oid: `t-${name}`, size: -1 }); |
| 51 |
else seen.set(name, { name, type: "blob", oid: blobOid(content), size: content.length }); |
| 52 |
} |
| 53 |
return seen.size === 0 && path !== "" ? null : [...seen.values()]; |
| 54 |
}, |
| 55 |
async read(_repo, _ref, path) { |
| 56 |
const content = tree.get(path); |
| 57 |
if (content === undefined) throw new Error(`no ${path}`); |
| 58 |
return content; |
| 59 |
}, |
| 60 |
async commit(_repo, _branch, message, changes) { |
| 61 |
for (const f of changes) f.content === null ? tree.delete(f.path) : tree.set(f.path, f.content); |
| 62 |
commits.push({ message, files: changes }); |
| 63 |
head = `c${++n}`; |
| 64 |
return head; |
| 65 |
}, |
| 66 |
}; |
| 67 |
return { |
| 68 |
forge, |
| 69 |
tree, |
| 70 |
commits, |
| 71 |
|
| 72 |
push(changes: Record<string, string | null>) { |
| 73 |
for (const [p, c] of Object.entries(changes)) c === null ? tree.delete(p) : tree.set(p, c); |
| 74 |
head = `x${++n}`; |
| 75 |
}, |
| 76 |
}; |
| 77 |
} |
| 78 |
|
| 79 |
test("only an app admin connects a repository; a join link lets someone in, and only into that folder", () => { |
| 80 |
const refused = createSpace(db, sam, { name: "Nope", repo: "sam/x", folder: "docs" }); |
| 81 |
expect(refused.ok).toBe(false); |
| 82 |
if (!refused.ok) expect(refused.error).toBe("forbidden"); |
| 83 |
expect(createSpace(db, ben, { name: "Bad", repo: "not a repo" }).ok).toBe(false); |
| 84 |
expect(createSpace(db, ben, { name: "Bad", repo: "ben/x", folder: "../etc" }).ok).toBe(false); |
| 85 |
|
| 86 |
const space = must(createSpace(db, ben, { name: "Brainstorm", repo: "ben/brainstorm", folder: "/docs/" })); |
| 87 |
expect(space.folder).toBe("docs"); |
| 88 |
expect(space.branch).toBe("main"); |
| 89 |
expect(mySpaces(db, ben).map((s) => s.id)).toEqual([space.id]); |
| 90 |
expect(mySpaces(db, sam)).toEqual([]); |
| 91 |
|
| 92 |
const mine = must(addNote(db, ben, { title: "My own thoughts" })); |
| 93 |
const shared = must(addNote(db, ben, { title: "Pricing", space: space.id })); |
| 94 |
expect(shared.path).toBe("docs/Pricing.md"); |
| 95 |
|
| 96 |
expect(getNote(db, sam, { id: shared.id }).ok).toBe(false); |
| 97 |
expect(listNotes(db, sam, { space: space.id }).ok).toBe(false); |
| 98 |
expect(addNote(db, sam, { title: "sneaky", space: space.id }).ok).toBe(false); |
| 99 |
|
| 100 |
must(joinSpace(db, sam, { code: space.join_code! })); |
| 101 |
expect(must(listNotes(db, sam, { space: space.id })).map((n) => n.title)).toEqual(["Pricing"]); |
| 102 |
expect(getNote(db, sam, { id: shared.id }).ok).toBe(true); |
| 103 |
|
| 104 |
expect(getNote(db, sam, { id: mine.id }).ok).toBe(false); |
| 105 |
expect(must(listNotes(db, sam)).map((n) => n.id)).not.toContain(mine.id); |
| 106 |
|
| 107 |
expect(must(listNotes(db, ben)).map((n) => n.id)).not.toContain(shared.id); |
| 108 |
expect(must(searchNotes(db, ben, { q: "Pricing" }))).toEqual([]); |
| 109 |
expect(must(searchNotes(db, sam, { q: "Pricing", space: space.id })).length).toBe(1); |
| 110 |
|
| 111 |
|
| 112 |
const edited = must(updateNote(db, sam, { id: shared.id, body: "per seat?" })); |
| 113 |
expect(edited.author_name).toBe("sam"); |
| 114 |
|
| 115 |
|
| 116 |
const off = must(setLink(db, ben, { id: space.id, on: false })); |
| 117 |
expect(joinSpace(db, eve, { code: space.join_code! }).ok).toBe(false); |
| 118 |
expect(off.join_code).toBeNull(); |
| 119 |
expect(setLink(db, sam, { id: space.id, on: true }).ok).toBe(false); |
| 120 |
expect(removeMember(db, sam, { id: space.id, member: ben.id }).ok).toBe(false); |
| 121 |
must(removeMember(db, ben, { id: space.id, member: sam.id })); |
| 122 |
expect(getNote(db, sam, { id: shared.id }).ok).toBe(false); |
| 123 |
|
| 124 |
must(deleteSpace(db, ben, { id: space.id })); |
| 125 |
expect(getNote(db, ben, { id: shared.id }).ok).toBe(false); |
| 126 |
expect(getNote(db, ben, { id: mine.id }).ok).toBe(true); |
| 127 |
}); |
| 128 |
|
| 129 |
test("a shared note's title is a file name, its folder a path; renames and moves carry the file", () => { |
| 130 |
const space = must(createSpace(db, ben, { name: "Paths", repo: "ben/paths", folder: "docs" })); |
| 131 |
expect(addNote(db, ben, { title: "a:b", space: space.id }).ok).toBe(false); |
| 132 |
const deep = must(addNote(db, ben, { title: "ideas/q4/Launch", space: space.id })); |
| 133 |
expect(deep.title).toBe("Launch"); |
| 134 |
expect(deep.path).toBe("docs/ideas/q4/Launch.md"); |
| 135 |
expect(folderOf(space, deep.path!)).toBe("ideas/q4"); |
| 136 |
const renamed = must(updateNote(db, ben, { id: deep.id, title: "Launch plan" })); |
| 137 |
expect(renamed.path).toBe("docs/ideas/q4/Launch plan.md"); |
| 138 |
const moved = must(updateNote(db, ben, { id: deep.id, folder: "archive" })); |
| 139 |
expect(moved.path).toBe("docs/archive/Launch plan.md"); |
| 140 |
expect(must(updateNote(db, ben, { id: deep.id, folder: "" })).path).toBe("docs/Launch plan.md"); |
| 141 |
expect(cleanFolder(" a / b ").ok && must(cleanFolder(" a / b "))).toBe("a/b"); |
| 142 |
expect(fileOf({ folder: "" }, "", "Top")).toBe("Top.md"); |
| 143 |
}); |
| 144 |
|
| 145 |
test("someone else saved first: new words are a conflict, not an overwrite", async () => { |
| 146 |
const space = must(createSpace(db, ben, { name: "Race", repo: "ben/race" })); |
| 147 |
must(joinSpace(db, sam, { code: space.join_code! })); |
| 148 |
const note = must(addNote(db, ben, { title: "Plan", space: space.id, body: "v1" })); |
| 149 |
await Bun.sleep(3); |
| 150 |
const theirs = must(updateNote(db, sam, { id: note.id, body: "sam's v2", base: note.updated_at })); |
| 151 |
const mine = updateNote(db, ben, { id: note.id, body: "ben's v2", base: note.updated_at }); |
| 152 |
expect(mine.ok).toBe(false); |
| 153 |
if (!mine.ok) expect(mine.error).toBe("conflict"); |
| 154 |
expect(must(getNote(db, ben, { id: note.id })).body).toBe("sam's v2"); |
| 155 |
|
| 156 |
expect(must(updateNote(db, ben, { id: note.id, body: "ben's v3", base: theirs.updated_at })).body).toBe("ben's v3"); |
| 157 |
}); |
| 158 |
|
| 159 |
test("sync reads the folder, commits what is written here, and takes what is pushed there", async () => { |
| 160 |
const repo = fakeForge({ |
| 161 |
"README.md": "# not in the folder", |
| 162 |
"docs/Ideas.md": "---\ntags: [brainstorm, q4]\n---\nSee [[Pricing]].", |
| 163 |
"docs/research/Pricing.md": "per seat? #money", |
| 164 |
"docs/research/chart.png": "binary", |
| 165 |
"docs/.obsidian/workspace.md": "editor settings", |
| 166 |
}); |
| 167 |
const space = must(createSpace(db, ben, { name: "Synced", repo: "ben/synced", folder: "docs" })); |
| 168 |
const first = await syncSpace(db, repo.forge, space.id); |
| 169 |
expect(first.pulled).toBe(2); |
| 170 |
const notes = () => must(listNotes(db, ben, { space: space.id })); |
| 171 |
const byTitle = (t: string) => notes().find((n) => n.title === t)!; |
| 172 |
expect(notes().map((n) => n.path).sort()).toEqual(["docs/Ideas.md", "docs/research/Pricing.md"]); |
| 173 |
expect(md.tags(byTitle("Ideas").body).sort()).toEqual(["brainstorm", "q4"]); |
| 174 |
|
| 175 |
expect((await syncSpace(db, repo.forge, space.id)).pushed).toBe(0); |
| 176 |
expect(repo.commits).toEqual([]); |
| 177 |
|
| 178 |
|
| 179 |
must(updateNote(db, ben, { id: byTitle("Ideas").id, body: "See [[Pricing]] and [[Naming]]." })); |
| 180 |
must(addNote(db, ben, { title: "Naming", space: space.id, folder: "research", body: "names" })); |
| 181 |
must(updateNote(db, ben, { id: byTitle("Pricing").id, title: "Pricing model" })); |
| 182 |
const wrote = await syncSpace(db, repo.forge, space.id); |
| 183 |
expect(repo.commits.length).toBe(1); |
| 184 |
expect(repo.commits[0]!.message).toContain("Written in notes by ben"); |
| 185 |
expect(wrote.pushed).toBe(4); |
| 186 |
expect([...repo.tree.keys()].sort()).toEqual([ |
| 187 |
"README.md", |
| 188 |
"docs/.obsidian/workspace.md", |
| 189 |
"docs/Ideas.md", |
| 190 |
"docs/research/Naming.md", |
| 191 |
"docs/research/Pricing model.md", |
| 192 |
"docs/research/chart.png", |
| 193 |
]); |
| 194 |
expect(repo.tree.get("docs/Ideas.md")).toBe("See [[Pricing model]] and [[Naming]]."); |
| 195 |
|
| 196 |
expect((await syncSpace(db, repo.forge, space.id)).pulled).toBe(0); |
| 197 |
|
| 198 |
must(deleteNote(db, ben, { id: byTitle("Naming").id })); |
| 199 |
await syncSpace(db, repo.forge, space.id); |
| 200 |
expect(repo.tree.has("docs/research/Naming.md")).toBe(false); |
| 201 |
|
| 202 |
|
| 203 |
repo.push({ |
| 204 |
"docs/Ideas.md": "Rewritten on a laptop.", |
| 205 |
"docs/meetings/2026-10-13.md": "# Kickoff", |
| 206 |
"docs/research/Pricing model.md": null, |
| 207 |
}); |
| 208 |
const read = await syncSpace(db, repo.forge, space.id); |
| 209 |
expect(read.pulled).toBe(3); |
| 210 |
expect(byTitle("Ideas").body).toBe("Rewritten on a laptop."); |
| 211 |
expect(byTitle("Ideas").author_name).toBe("the repository"); |
| 212 |
expect(byTitle("2026-10-13").path).toBe("docs/meetings/2026-10-13.md"); |
| 213 |
expect(notes().some((n) => n.title === "Pricing model")).toBe(false); |
| 214 |
expect(repo.commits.length).toBe(2); |
| 215 |
}); |
| 216 |
|
| 217 |
test("edited in both places: ours is committed, theirs is kept beside it — nothing is lost", async () => { |
| 218 |
const repo = fakeForge({ "Plan.md": "v1" }); |
| 219 |
const space = must(createSpace(db, ben, { name: "Both", repo: "ben/both" })); |
| 220 |
await syncSpace(db, repo.forge, space.id); |
| 221 |
const plan = must(listNotes(db, ben, { space: space.id }))[0]!; |
| 222 |
must(updateNote(db, ben, { id: plan.id, body: "ours" })); |
| 223 |
repo.push({ "Plan.md": "theirs" }); |
| 224 |
const r = await syncSpace(db, repo.forge, space.id); |
| 225 |
expect(r.copies).toBe(1); |
| 226 |
expect(repo.tree.get("Plan.md")).toBe("ours"); |
| 227 |
expect(repo.tree.get("Plan (repo copy).md")).toBe("theirs"); |
| 228 |
const titles = must(listNotes(db, ben, { space: space.id })).map((n) => n.title).sort(); |
| 229 |
expect(titles).toEqual(["Plan", "Plan (repo copy)"]); |
| 230 |
}); |
| 231 |
|
| 232 |
test("a repository that can't be read is said, and the space remembers why", async () => { |
| 233 |
const space = must(createSpace(db, ben, { name: "Broken", repo: "ben/gone" })); |
| 234 |
const broken: Forge = { |
| 235 |
...fakeForge().forge, |
| 236 |
head: async () => { |
| 237 |
throw new Error("No repository ben/gone — or the token can't see it."); |
| 238 |
}, |
| 239 |
}; |
| 240 |
await expect(syncSpace(db, broken, space.id)).rejects.toThrow("No repository"); |
| 241 |
const row = mySpaces(db, ben).find((s) => s.id === space.id)!; |
| 242 |
expect(row.sync_error).toContain("No repository"); |
| 243 |
}); |
| 244 |
|
| 245 |
test("properties at the top of a note read as properties, not as a rule and a paragraph", () => { |
| 246 |
const html = md.render("---\ntitle: Plan\ntags:\n - a\n - b\n---\n# Body", {}); |
| 247 |
expect(html).toContain('<dl class="properties">'); |
| 248 |
expect(html).toContain("<dd>a, b</dd>"); |
| 249 |
expect(html).not.toContain("<hr>"); |
| 250 |
expect(md.tags("---\ntags: x, y\n---\n#z")).toEqual(["x", "y", "z"]); |
| 251 |
expect(md.render("[[Missing]] #t", { base: "/s/3" })).toContain('href="/s/3/open?title=Missing"'); |
| 252 |
}); |