mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
0f78505e99
Every note gets an Edit form built from its note type's schema: image fields show a thumbnail with a pick from the vault or an upload, link fields offer note names, and lists of rows can be added to, removed and reordered. A new Site pages view lists each page of the site with its text and images and the notes that fill it. Image and link fields now carry a JSON Schema format so a form knows to offer a picker. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
189 lines
8.4 KiB
TypeScript
189 lines
8.4 KiB
TypeScript
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<void>((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<string, string>, headers: Record<string, string> = {}) => {
|
|
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('<div class="add">verdict: A heist.</div>');
|
|
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("<p>Allomancy.</p>");
|
|
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('<div class="remove">verdict: A heist.</div>');
|
|
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();
|
|
});
|
|
});
|