import { readFileSync, renameSync, rmSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { evaluate, defaultRules } from "../src/rules.ts"; import { book, long, post, rig, type Rig } from "./helpers.ts"; let r: Rig; beforeEach(() => { r = rig(); }); afterEach(() => r.done()); const ok = (o: { status: string }) => expect(o.status).toBe("accepted"); describe("from the vault", () => { it("takes in valid notes and indexes them for search and links", () => { r.put(book("Mistborn", { creator: "Brandon Sanderson" }), "Allomancy, heists and [[Elantris]]."); r.put(book("Elantris")); expect(r.service.scan()).toEqual({ changed: 2, held: 0 }); expect(r.store.search("allomancy").map((n) => n.title)).toEqual(["Mistborn"]); expect(r.store.backlinks("Elantris").map((n) => n.title)).toEqual(["Mistborn"]); expect(r.service.scan()).toEqual({ changed: 0, held: 0 }); }); it("holds a broken edit and keeps showing the last good version", () => { const path = r.put(book("Mistborn")); ok(r.service.ingest(path)); r.putText(path, "---\ntype: library-item\ntitle: Mistborn\nmedium: scroll\n---\n"); const o = r.service.ingest(path); expect(o.status).toBe("held"); expect(o.status === "held" && o.held.reasons[0]!.code).toBe("invalid"); expect(r.store.get(path)!.data.medium).toBe("book"); }); it("holds a large loss of text until it is accepted", () => { const path = r.put(post("Building a bench"), long()); ok(r.service.ingest(path)); r.put(post("Building a bench"), "Short now."); const o = r.service.ingest(path); expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["text-loss"]); expect(r.store.get(path)!.body.trim()).toBe(long()); ok(r.service.acceptHeld(o.status === "held" ? o.held.id : 0)); expect(r.store.get(path)!.body.trim()).toBe("Short now."); }); it("holds a secret pasted into a note", () => { const path = r.put(post("Homelab notes"), "Setup."); ok(r.service.ingest(path)); r.put(post("Homelab notes"), "Setup. api_key = abcd1234efgh5678"); expect(r.service.ingest(path).status).toBe("held"); }); it("unpublishes a deleted note but keeps its history for a rollback", () => { const path = r.put(book("Mistborn")); ok(r.service.ingest(path)); rmSync(join(r.site, path)); r.service.scan(); expect(r.store.get(path)!.removed).toBe(true); expect(r.store.list({ publishedOnly: true })).toHaveLength(0); ok(r.service.rollback(path, 1)); expect(r.store.get(path)!.removed).toBe(false); expect(r.vault.exists(path)).toBe(true); }); it("keeps old addresses working after a slug change and a move", () => { const path = r.put(post("Bench build", { slug: "bench" })); ok(r.service.ingest(path)); r.put(post("Bench build", { slug: "workbench" })); ok(r.service.ingest(path)); expect(r.store.redirect("post", "bench")).toBe(path); // Renaming the file: the old path goes, the same note appears under a new name. renameSync(join(r.site, path), join(r.site, "Posts/Workbench build.md")); r.service.scan(); expect(r.store.redirect("post", "bench")).toBe("Posts/Workbench build.md"); }); it("holds a second note that takes a used address", () => { ok(r.service.ingest(r.put(post("Bench build", { slug: "bench" })))); const o = r.service.ingest(r.put(post("Another bench", { slug: "bench" }))); expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["duplicate-slug"]); }); it("ignores conflict copies and template folders", () => { r.putText("Posts/Bench (conflicted copy 2026-10-11).md", "junk"); r.putText("_templates/Post.md", "junk"); expect(r.service.scan()).toEqual({ changed: 0, held: 0 }); }); }); describe("the write API", () => { it("writes an accepted change into the vault and skips it when the watcher sees it", () => { const o = r.service.apply({ op: "create", data: book("Warbreaker") }, "agent"); ok(o); const text = readFileSync(join(r.site, o.path!), "utf8"); expect(text).toContain("updatedBy: agent"); expect(r.service.ingest(o.path!)).toEqual({ status: "unchanged", path: o.path }); }); it("rejects invalid input at once instead of holding it", () => { const o = r.service.apply({ op: "create", data: { type: "library-item", title: "X", medium: "scroll", state: "now" } }, "agent"); expect(o.status).toBe("rejected"); expect(r.store.heldList()).toHaveLength(0); }); it("refuses paths outside the Site folder and type changes", () => { expect(r.service.apply({ op: "update", path: "../secrets.md", fields: {} }, "admin").status).toBe("rejected"); const path = r.put(book("Mistborn")); r.service.ingest(path); expect(r.service.apply({ op: "update", path, fields: { type: "post" } }, "admin").status).toBe("rejected"); }); it("takes in an Obsidian edit before applying an API change on top of it", () => { const path = r.put(book("Mistborn")); r.service.ingest(path); r.put(book("Mistborn", { verdict: "Great heist." })); ok(r.service.apply({ op: "update", path, fields: { state: "now" } }, "greg")); expect(r.store.get(path)!.data).toMatchObject({ verdict: "Great heist.", state: "now" }); }); it("holds an agent editing Greg's note, and writes it to the vault only once accepted", () => { const path = r.put(book("Mistborn")); r.service.ingest(path); const before = readFileSync(join(r.site, path), "utf8"); const o = r.service.apply({ op: "update", path, fields: { verdict: "Meh." } }, "agent"); expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["agent-edit"]); expect(readFileSync(join(r.site, path), "utf8")).toBe(before); ok(r.service.acceptHeld(o.status === "held" ? o.held.id : 0)); expect(readFileSync(join(r.site, path), "utf8")).toContain("verdict: Meh."); }); it("lets an agent edit a note marked agentEdits, and its own notes", () => { const path = r.put(book("Mistborn", { agentEdits: true })); r.service.ingest(path); ok(r.service.apply({ op: "update", path, fields: { progress: "2/3" } }, "agent")); const own = r.service.apply({ op: "create", data: book("Warbreaker") }, "agent"); ok(r.service.apply({ op: "update", path: own.path!, fields: { state: "now" } }, "agent")); }); it("holds an agent publishing a post", () => { const o = r.service.apply({ op: "create", data: post("Draft", { publish: false }), body: "Hi." }, "agent"); ok(o); const pub = r.service.apply({ op: "update", path: o.path!, fields: { publish: true } }, "agent"); expect(pub.status === "held" && pub.held.reasons.map((x) => x.code)).toEqual(["agent-publish"]); }); it("holds an agent adding contact details", () => { const o = r.service.apply({ op: "create", data: post("Draft", { publish: false }), body: "Mail me at someone@example.com" }, "agent"); expect(o.status === "held" && o.held.reasons.map((x) => x.code)).toEqual(["contact"]); }); it("holds an agent making too many changes at once", () => { for (let i = 0; i < 10; i++) ok(r.service.apply({ op: "create", data: book(`Book ${i}`) }, "agent")); expect(r.service.apply({ op: "create", data: book("Book 10") }, "agent").status).toBe("held"); r.clock.advance(11); ok(r.service.apply({ op: "create", data: book("Book 11") }, "agent")); }); it("refuses a change while another waits for review", () => { const path = r.put(book("Mistborn")); r.service.ingest(path); r.service.apply({ op: "update", path, fields: { verdict: "Meh." } }, "agent"); expect(r.service.apply({ op: "update", path, fields: { verdict: "Good." } }, "greg").status).toBe("rejected"); }); it("rejecting a held vault edit can restore the live version into the vault", () => { const path = r.put(book("Mistborn")); r.service.ingest(path); const good = readFileSync(join(r.site, path), "utf8"); r.putText(path, "---\ntype: library-item\n---\n"); const o = r.service.ingest(path); r.service.rejectHeld(o.status === "held" ? o.held.id : 0, { restore: true }); expect(readFileSync(join(r.site, path), "utf8")).toBe(good); }); }); describe("rules", () => { it("only flags secrets that are new in this edit", () => { const text = "---\ntype: post\n---\npassword = hunter2hunter2"; const checked = { ok: true as const, type: "post", data: { type: "post", title: "x", publish: false }, body: "" }; const current = { text, removed: false, author: "greg", data: {}, publish: false } as never; const v = evaluate( { path: "Posts/x.md", name: "x", slug: "x", text, author: "greg", checked: checked as never }, { current, nameTakenBy: null, slugTakenBy: null, recentAgentChanges: 0, settings: defaultRules }, ); expect(v.decision).toBe("accept"); }); });