import { existsSync, readFileSync } from "node:fs"; import type { AddressInfo } from "node:net"; import type { Server } from "node:http"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { parseTokens } from "../../content/src/config.ts"; import { createHttpServer } from "../../content/src/http.ts"; import { book, rig, type Rig } from "../../content/test/helpers.ts"; import { ContentApi } from "../src/client.ts"; import { createAdminServer } from "../src/server.ts"; const ADMIN = "d".repeat(24); let r: Rig; let content: Server; let admin: Server; let base: string; const listen = async (s: Server) => { await new Promise((ok) => s.listen(0, "127.0.0.1", ok)); return `http://127.0.0.1:${(s.address() as AddressInfo).port}`; }; beforeAll(async () => { r = rig(); r.put({ type: "status-kinds", title: "Status kinds", publish: true, kinds: [{ label: "Reading", icon: "book" }, { label: "Playing", icon: "dice" }] }); r.put( { type: "store-settings", title: "Store settings", publish: true, categories: [ { name: "Tabletop", ordersOpen: true, turnaround: "2 weeks" }, { name: "Paper", ordersOpen: false }, ], }, ); r.put({ type: "product-line", title: "Proxy cards", publish: true, category: "Tabletop", mode: "made to order", channel: "here", summary: "Cards.", slots: { open: 3, total: 5 }, priceTiers: [{ band: "1-10", price: "$1" }], }); r.put(book("Mistborn"), "Allomancy."); r.service.scan(); content = createHttpServer(r.service, { tokens: parseTokens(`admin:${ADMIN}`) }); const contentUrl = await listen(content); admin = createAdminServer(new ContentApi(contentUrl, ADMIN), { now: () => new Date("2026-10-11T12:00:00Z") }); base = await listen(admin); }); afterAll(() => { admin.close(); content.close(); r.done(); }); const get = async (path: string) => { const res = await fetch(base + path, { redirect: "manual" }); return { status: res.status, text: await res.text(), location: res.headers.get("location") }; }; const post = async (path: string, form: Record, headers: Record = {}) => { const res = await fetch(base + path, { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...headers }, body: new URLSearchParams(form).toString(), }); return { status: res.status, location: res.headers.get("location") ?? "", text: await res.text() }; }; describe("admin page", () => { it("shows the overview", async () => { const page = await get("/"); expect(page.status).toBe(200); expect(page.text).toContain("live notes of 4"); expect(page.text).toContain("No sync job has reported yet"); }); it("refuses a form posted from another site", async () => { expect((await post("/scan", {}, { "sec-fetch-site": "cross-site" })).status).toBe(403); }); it("captures a Library item with a status, written into the vault", async () => { const res = await post("/capture/library", { title: "Warbreaker", medium: "book", state: "now", publish: "1", alsoStatus: "1", kind: "Reading" }); expect(res.location).toContain("msg="); expect(existsSync(join(r.site, "Library/Books/Warbreaker.md"))).toBe(true); const status = r.store.list({ type: "status" })[0]!; expect(status.data).toMatchObject({ kind: "Reading", subject: "[[Warbreaker]]", date: "2026-10-11" }); expect(status.author).toBe("admin"); }); it("sends a rejected capture back with the reasons", async () => { const res = await post("/capture/library", { title: "Warbreaker", medium: "book", state: "now" }); expect(res.location).toMatch(/err=.*r=/); }); it("reviews a held change with a diff and accepts it", async () => { const o = r.service.apply({ op: "update", path: "Library/Books/Mistborn.md", fields: { verdict: "A heist." } }, "agent"); expect(o.status).toBe("held"); const id = o.status === "held" ? o.held.id : 0; const list = await get("/review"); expect(list.text).toContain(`/review/${id}`); const one = await get(`/review/${id}`); expect(one.text).toContain('
verdict: A heist.
'); expect(one.text).toContain("agent-edit"); const res = await post(`/review/${id}/accept`, {}); expect(res.location).toContain("Accepted"); expect(readFileSync(join(r.site, "Library/Books/Mistborn.md"), "utf8")).toContain("verdict: A heist."); }); it("previews a note, shows its history and rolls it back", async () => { const page = await get("/note?path=Library%2FBooks%2FMistborn.md"); expect(page.text).toContain("

Allomancy.

"); expect(page.text).toContain("Compare and roll back"); const rev = await get("/note/revision?path=Library%2FBooks%2FMistborn.md&rev=1"); expect(rev.text).toContain('
verdict: A heist.
'); await post("/note/rollback", { path: "Library/Books/Mistborn.md", rev: "1" }); expect(r.store.get("Library/Books/Mistborn.md")!.data.verdict).toBeUndefined(); }); it("publishes a draft", async () => { r.put(book("Elantris", { publish: false })); r.service.scan(); expect((await get("/drafts")).text).toContain("Elantris"); await post("/note/publish", { path: "Library/Books/Elantris.md", publish: "1" }); expect(r.store.get("Library/Books/Elantris.md")!.publish).toBe(true); }); it("opens and closes orders and sets slots", async () => { expect((await get("/store")).text).toContain("Proxy cards"); await post("/store/category", { name: "Paper", ordersOpen: "1", turnaround: "3 weeks" }); await post("/store/category", { name: "Tabletop" }); const cats = r.store.get("Store/_settings.md")!.data.categories; expect(cats).toEqual([ { name: "Tabletop", ordersOpen: false }, { name: "Paper", ordersOpen: true, turnaround: "3 weeks" }, ]); await post("/store/stock", { path: "Store/Lines/Proxy cards.md", open: "1" }); expect(r.store.get("Store/Lines/Proxy cards.md")!.data.slots).toEqual({ open: 1, total: 5 }); }); it("shows sync jobs once they report", async () => { r.store.reportSync({ job: "personalreader", at: "2026-10-11T11:00:00Z", ok: false, message: "Suwayomi did not answer", counts: { updated: 0 } }); const page = await get("/"); expect(page.text).toContain("personalreader"); expect(page.text).toContain("Suwayomi did not answer"); }); it("edits a page's text and images from Site pages", async () => { const site = await get("/site"); expect(site.text).toContain("Add page text"); expect((await get("/edit/new?type=page&page=%2Fabout&title=About")).text).toContain('value="/about"'); // A tiny PNG: the content service checks the file's first bytes. const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); const fd = new FormData(); for (const [k, v] of Object.entries({ type: "page", title: "About", page: "/about", lede: "Maker of things", "has.publish": "1", publish: "1", "photos.0.file": "", "photos.0.alt": "" })) fd.append(k, v); fd.append("upload.portrait", new Blob([png], { type: "image/png" }), "Greg Portrait.png"); const res = await fetch(base + "/edit/save", { method: "POST", redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, body: fd }); expect(res.status).toBe(303); const note = r.store.list({ type: "page" }).find((n) => n.data.page === "/about")!; expect(note.data).toMatchObject({ lede: "Maker of things", portrait: "[[greg-portrait.png]]" }); expect(note.publish).toBe(true); expect((await get("/site")).text).toContain("/attachments/greg-portrait.png"); // Clearing a field removes it; a reorder only redraws the form. await post("/edit/save", { type: "page", path: note.path, title: "About", page: "/about", lede: "", portrait: "greg-portrait.png", "has.publish": "1", publish: "1" }); expect(r.store.get(note.path)!.data.lede).toBeUndefined(); const moved = await post("/edit/save", { type: "page", path: note.path, title: "About", page: "/about", "photos.0.file": "a.jpg", "photos.0.alt": "A", "photos.1.file": "b.jpg", "photos.1.alt": "B", move: "photos:1:up", }); expect(moved.status).toBe(200); expect(moved.text.indexOf('value="b.jpg"')).toBeLessThan(moved.text.indexOf('value="a.jpg"')); expect(r.store.get(note.path)!.data.photos).toBeUndefined(); }); });