// notes.json: the store, the corpus write predicate and the targets. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { corpusNotesFile, isCorpusNotesFile } from "../paths.mjs"; import { NoteError, applyOp, emptyDoc, parseNotesDoc, validateAnchor } from "./shape.mjs"; import { NotesUnreadable, StaleNotes, readNotes, withNotesLock, writeOp } from "./store.mjs"; import { articleTarget, listNotesFiles, writeNote } from "./targets.mjs"; const SUBJECT = { kind: "article", site: "s1", report: "r1" }; async function tmp() { return mkdtemp(path.join(tmpdir(), "umtool-notes-")); } test("add, reply, resolve, reopen, delete: round-trip, and the last delete removes the file", async () => { const dir = await tmp(); const file = path.join(dir, "notes.json"); const a = await writeOp(file, { subject: SUBJECT, source: { draft: "~/d.json" } }, { op: "add", text: "fix this", anchor: { kind: "whole" } }, { by: "operator" }); assert.equal(a.doc.notes.length, 1); assert.equal(a.doc.source.draft, "~/d.json"); const id = a.note.id; assert.match(id, /^n_[a-z0-9]+$/); const back = await readNotes(file); assert.deepEqual(back.doc, a.doc); assert.equal(back.token, a.token); const r = await writeOp(file, { subject: SUBJECT }, { op: "reply", id, text: "done in drafts/x.json", resolve: true }, { by: "agent", token: a.token }); assert.equal(r.note.status, "resolved"); assert.equal(r.note.resolvedBy, "agent"); assert.equal(r.note.replies[0].author, "agent"); const o = await writeOp(file, { subject: SUBJECT }, { op: "status", id, status: "open" }, { by: "operator" }); assert.equal(o.note.status, "open"); assert.equal(o.note.resolvedAt, undefined); const d = await writeOp(file, { subject: SUBJECT }, { op: "delete", id }, { by: "operator" }); assert.equal(d.doc, null); await assert.rejects(stat(file), /ENOENT/); await rm(dir, { recursive: true }); }); test("a stale token is a 409 and the file is untouched", async () => { const dir = await tmp(); const file = path.join(dir, "notes.json"); const a = await writeOp(file, { subject: SUBJECT }, { op: "add", text: "one", anchor: { kind: "whole" } }, { by: "operator", token: "absent" }); await writeOp(file, { subject: SUBJECT }, { op: "add", text: "two (agent)", anchor: { kind: "whole" } }, { by: "agent" }); const before = await readFile(file, "utf8"); await assert.rejects( writeOp(file, { subject: SUBJECT }, { op: "add", text: "three", anchor: { kind: "whole" } }, { by: "operator", token: a.token }), (err) => err instanceof StaleNotes && err.status === 409, ); assert.equal(await readFile(file, "utf8"), before); // "absent" against a file that exists is stale too await assert.rejects( writeOp(file, { subject: SUBJECT }, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator", token: "absent" }), StaleNotes, ); await rm(dir, { recursive: true }); }); test("an unparseable notes.json is never overwritten", async () => { const dir = await tmp(); const file = path.join(dir, "notes.json"); await writeFile(file, "{ not json"); assert.match((await readNotes(file)).error, /not JSON/); await assert.rejects(writeOp(file, { subject: SUBJECT }, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator" }), NotesUnreadable); assert.equal(await readFile(file, "utf8"), "{ not json"); // a parseable file with a bad note is refused the same way await writeFile(file, JSON.stringify({ format: "umtool-notes", version: 1, subject: SUBJECT, notes: [{ id: "bad" }] })); await assert.rejects(writeOp(file, { subject: SUBJECT }, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator" }), NotesUnreadable); await rm(dir, { recursive: true }); }); test("lock contention: parallel writers all land; a held lock waits; a stale lock is taken over", async () => { const dir = await tmp(); const file = path.join(dir, "notes.json"); await Promise.all( Array.from({ length: 12 }, (_, i) => writeOp(file, { subject: SUBJECT }, { op: "add", text: `n${i}`, anchor: { kind: "whole" } }, { by: i % 2 ? "agent" : "operator" }), ), ); assert.equal((await readNotes(file)).doc.notes.length, 12); // Another process holds it (a fresh lock file): we wait, then give up. await writeFile(`${file}.lock`, "999999 now\n"); await assert.rejects(withNotesLock(file, async () => 1, { waitMs: 120 }), /held/); // The same lock, 31 s old: a dead writer's, taken over. const old = new Date(Date.now() - 31_000); await utimes(`${file}.lock`, old, old); assert.equal(await withNotesLock(file, async () => 2, { waitMs: 120 }), 2); await assert.rejects(stat(`${file}.lock`), /ENOENT/); await rm(dir, { recursive: true }); }); test("ops refuse what the contract does not allow", () => { const doc = emptyDoc(SUBJECT); assert.throws(() => applyOp(doc, { op: "add", text: " ", anchor: { kind: "whole" } }, "operator"), NoteError); assert.throws(() => applyOp(doc, { op: "add", text: "x", anchor: { kind: "nope" } }, "operator"), NoteError); assert.throws(() => applyOp(doc, { op: "add", text: "x".repeat(8001), anchor: { kind: "whole" } }, "operator"), NoteError); const n = applyOp(doc, { op: "add", text: "x", anchor: { kind: "whole" } }, "operator"); assert.throws(() => applyOp(doc, { op: "edit", id: n.id, text: "agent rewrites it" }, "agent"), /reply instead/); assert.throws(() => applyOp(doc, { op: "status", id: n.id, status: "done" }, "agent"), NoteError); assert.throws(() => applyOp(doc, { op: "reply", id: "n_missing0", text: "x" }, "agent"), /no note/); assert.throws(() => applyOp(doc, { op: "frobnicate" }, "agent"), NoteError); assert.throws(() => applyOp(doc, { op: "add", text: "x", anchor: { kind: "whole" } }, "someone"), NoteError); // source: set, then cleared applyOp(doc, { op: "source", source: { draft: "~/x.json", junk: 1 } }, "agent"); assert.deepEqual(doc.source, { draft: "~/x.json" }); assert.ok(parseNotesDoc(JSON.parse(JSON.stringify(doc))).doc); }); test("anchors: every kind validates; bad ones refuse", () => { const ok = [ { kind: "whole" }, { kind: "section", section: "s-2" }, { kind: "text", section: "summary", quote: "the claim", prefix: "before ", suffix: " after" }, { kind: "cite", cite: "c12" }, { kind: "moment", file: "takes/deck/preview.mp4", t: 12.345, take: "deck", entry: "e3", resolved: { title: "T", sourceT: 81.234, approx: true, bogus: 1 } }, { kind: "entry", entry: "clip-4" }, { kind: "take", take: "cold-open" }, { kind: "edit", entry: "e3", field: "quote", from: "a", to: "b" }, ]; for (const a of ok) assert.ok("anchor" in validateAnchor(a), JSON.stringify(a)); assert.equal(validateAnchor(ok[4]).anchor.t, 12.35); assert.deepEqual(validateAnchor(ok[4]).anchor.resolved, { title: "T", sourceT: 81.23, approx: true }); assert.equal(validateAnchor({ kind: "text", section: "s", quote: "q" }).anchor.prefix, ""); const bad = [ null, { kind: "text", section: "s" }, { kind: "moment", file: "../x.mp4", t: 1 }, { kind: "moment", file: "/abs.mp4", t: 1 }, { kind: "moment", file: "a.mp4", t: -1 }, { kind: "take", take: "Bad Id" }, { kind: "section", section: "has space" }, { kind: "edit", field: "" }, ]; for (const a of bad) assert.ok("error" in validateAnchor(a), JSON.stringify(a)); }); async function sitesFixture() { const root = await tmp(); const sites = path.join(root, "sites"); await mkdir(path.join(sites, "s1", "reports", "r1"), { recursive: true }); await writeFile(path.join(sites, "s1", "site.json"), "{}"); await writeFile(path.join(sites, "s1", "reports", "r1", "report.json"), "{}"); const outside = path.join(root, "outside", "r2"); await mkdir(outside, { recursive: true }); await symlink(outside, path.join(sites, "s1", "reports", "r2")); return { root, sites }; } test("isCorpusNotesFile: exactly sites//reports//notes.json, and nothing else", async () => { const { root, sites } = await sitesFixture(); const opt = { sitesDir: sites }; const good = path.join(sites, "s1", "reports", "r1", "notes.json"); assert.equal(corpusNotesFile("s1", "r1", opt), good); assert.equal(await isCorpusNotesFile(good, opt), true); // traversal, spelled several ways assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r1", "..", "r1", "notes.json").replace(/\/r1\/notes/, "/../r1/r1/notes"), opt), false); assert.equal(await isCorpusNotesFile(`${sites}/s1/reports/../reports/r1/notes.json`, opt), false); assert.equal(await isCorpusNotesFile(`${sites}/s1/reports//r1/notes.json`, opt), false); assert.equal(await isCorpusNotesFile("s1/reports/r1/notes.json", opt), false); // wrong name, wrong depth, wrong middle segment, bad ids assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r1", "report.json"), opt), false); assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r1", "x", "notes.json"), opt), false); assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "stills", "r1", "notes.json"), opt), false); assert.equal(await isCorpusNotesFile(path.join(sites, "S1", "reports", "r1", "notes.json"), opt), false); assert.equal(corpusNotesFile("s1", "../r1", opt), null); // a report dir that is a symlink out of the site assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r2", "notes.json"), opt), false); // a report that does not exist: a note never creates its directory assert.equal(await isCorpusNotesFile(path.join(sites, "s1", "reports", "r9", "notes.json"), opt), false); // a notes.json that is itself a symlink await writeFile(path.join(root, "elsewhere.json"), "{}"); await symlink(path.join(root, "elsewhere.json"), good); assert.equal(await isCorpusNotesFile(good, opt), false); await rm(root, { recursive: true }); }); test("articleTarget: writes land beside report.json with the discovered source; refusals are typed", async () => { const { root, sites } = await sitesFixture(); const reports = path.join(root, "reports"); await mkdir(path.join(reports, "ws", "polemics", "drafts"), { recursive: true }); await writeFile(path.join(reports, "ws", "polemics", "drafts", "r1.json"), JSON.stringify({ id: "r1" })); await writeFile(path.join(reports, "ws", "polemics", "make-site.py"), 'OUT = "sites/s1/reports"\nfor d in drafts: pass\n'); const opt = { sitesDir: sites, reportsRoot: reports }; const t = await articleTarget("s1/r1", opt); const w = await writeNote(t, { op: "add", text: "x", anchor: { kind: "whole" } }, { by: "operator" }); assert.equal(w.doc.source.draft.endsWith(path.join("ws", "polemics", "drafts", "r1.json")), true); assert.equal(w.doc.source.generator.endsWith("make-site.py"), true); const list = await listNotesFiles(opt); assert.deepEqual(list.map((l) => [l.kind, l.id, l.doc.notes.length]), [["article", "s1/r1", 1]]); await assert.rejects(articleTarget("s1/r9", opt), (e) => e.status === 404); await assert.rejects(articleTarget("s1/r2", opt), (e) => e.status === 403); await assert.rejects(articleTarget("s1/../r1", opt), (e) => e.status === 400); await assert.rejects(articleTarget("s1", opt), (e) => e.status === 400); await rm(root, { recursive: true }); });