benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 48459319b3fcac902928ea7f5ec007a0d1a287bc
252 lines · 11.9 KB · ~3.5k tok Raw History
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
// @ts-ignore — plain JS, shared with the browser.
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
/** A repository in memory, behind the same four calls the forge answers. */
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
    /** Someone pushes to the repository, outside the app. */
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
  // A stranger can't see the folder or its notes, by id or by list.
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
  // …and still nothing of Ben's own.
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
  // Shared notes never show in anyone's own vault, Ben's included.
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
  // Sam writes; the note says so.
112
  const edited = must(updateNote(db, sam, { id: shared.id, body: "per seat?" }));
113
  expect(edited.author_name).toBe("sam");
114
115
  // The link off, nobody new gets in; the owner removes a member; the owner can't be removed.
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); // updated_at is to the millisecond
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
  // Written against what is there now, it goes through.
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
  // Nothing changed here, so nothing is committed.
175
  expect((await syncSpace(db, repo.forge, space.id)).pushed).toBe(0);
176
  expect(repo.commits).toEqual([]);
177
178
  // Written, made, renamed and removed here → one commit.
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
  // The next read finds our own commit and changes nothing.
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
  // Someone pushes: a change, a new file, a removal.
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
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts