mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
a9fb5b3e85
A Posts page lists every post with Write a post: title, topic, kind, date, summary, a cover picked from the vault or uploaded, the pages it is about, the Markdown body with a rendered preview, and Save draft or Publish. An Images page uploads several images into an _attachments folder, shows which notes use each one, copies its embed line, and deletes unused ones. The content service gains the attachment list, upload and delete endpoints behind the editor and admin tokens. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
142 lines
7.4 KiB
TypeScript
142 lines
7.4 KiB
TypeScript
import type { AddressInfo } from "node:net";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import { parseTokens } from "../src/config.ts";
|
|
import { createHttpServer } from "../src/http.ts";
|
|
import { book, post, rig, type Rig } from "./helpers.ts";
|
|
|
|
const T = { reader: "r".repeat(24), agent: "a".repeat(24), editor: "e".repeat(24), admin: "d".repeat(24) };
|
|
let r: Rig;
|
|
let base: string;
|
|
let server: ReturnType<typeof createHttpServer>;
|
|
|
|
beforeAll(async () => {
|
|
r = rig();
|
|
r.put({ type: "status-kinds", title: "Status kinds", publish: true, kinds: [{ label: "Reading", icon: "book" }] });
|
|
r.put(book("Mistborn"), "About [[Elantris]].");
|
|
r.put(book("Elantris"));
|
|
r.put(post("Secret draft", { publish: false }));
|
|
r.service.scan();
|
|
server = createHttpServer(r.service, { tokens: parseTokens(Object.entries(T).map(([k, v]) => `${k}:${v}`).join(",")) });
|
|
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
|
|
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
});
|
|
afterAll(() => {
|
|
server.close();
|
|
r.done();
|
|
});
|
|
|
|
const get = (path: string, token?: string) =>
|
|
fetch(base + path, { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
const post_ = (path: string, token: string, body: unknown) =>
|
|
fetch(base + path, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
|
|
describe("HTTP API", () => {
|
|
it("needs a token for everything but health", async () => {
|
|
expect((await get("/health")).status).toBe(200);
|
|
expect((await get("/api/notes")).status).toBe(401);
|
|
expect((await get("/api/notes", "wrong".repeat(6))).status).toBe(401);
|
|
});
|
|
|
|
it("shows readers published notes only", async () => {
|
|
const reader = (await (await get("/api/notes?type=post", T.reader)).json()) as unknown[];
|
|
const editor = (await (await get("/api/notes?type=post", T.editor)).json()) as unknown[];
|
|
expect(reader).toHaveLength(0);
|
|
expect(editor).toHaveLength(1);
|
|
expect((await get("/api/note?path=Posts/Secret%20draft.md", T.reader)).status).toBe(404);
|
|
});
|
|
|
|
it("finds a note by slug with its backlinks", async () => {
|
|
const n = (await (await get("/api/note?type=library-item&slug=elantris", T.reader)).json()) as { title: string; backlinks: { title: string }[] };
|
|
expect(n.title).toBe("Elantris");
|
|
expect(n.backlinks.map((b) => b.title)).toEqual(["Mistborn"]);
|
|
});
|
|
|
|
it("keeps readers from writing and agents from reviewing", async () => {
|
|
expect((await post_("/api/changes", T.reader, { op: "create", data: book("X") })).status).toBe(403);
|
|
expect((await post_("/api/held/1/accept", T.agent, {})).status).toBe(403);
|
|
});
|
|
|
|
it("applies a change and reports rejections plainly", async () => {
|
|
const ok = await post_("/api/changes", T.editor, { op: "create", data: book("Warbreaker") });
|
|
expect(ok.status).toBe(200);
|
|
const bad = await post_("/api/changes", T.editor, { op: "create", data: { type: "library-item", title: "Y" } });
|
|
expect(bad.status).toBe(422);
|
|
});
|
|
});
|
|
|
|
describe("sync reports", () => {
|
|
it("records a run and shows it in the status", async () => {
|
|
expect((await post_("/api/syncs", T.agent, { job: "personalreader", ok: true, counts: { updated: 3 } })).status).toBe(200);
|
|
expect((await post_("/api/syncs", T.agent, { job: "Bad Name", ok: true })).status).toBe(400);
|
|
const status = (await (await get("/api/status", T.admin)).json()) as { syncs: { job: string; lastOkAt: string }[] };
|
|
expect(status.syncs[0]).toMatchObject({ job: "personalreader" });
|
|
expect(status.syncs[0]!.lastOkAt).toBeTruthy();
|
|
expect((await get("/api/status", T.reader)).status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("attachments", () => {
|
|
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)]);
|
|
const upload = (name: string, bytes: Buffer, token = T.admin) =>
|
|
fetch(`${base}/api/attachments?folder=Posts&name=${encodeURIComponent(name)}`, {
|
|
method: "POST",
|
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/octet-stream" },
|
|
body: new Uint8Array(bytes),
|
|
});
|
|
|
|
it("adds an image under a safe, unused name", async () => {
|
|
const a = (await (await upload("My Photo!.PNG", png)).json()) as { name: string };
|
|
const b = (await (await upload("my photo.png", png)).json()) as { name: string };
|
|
expect(a.name).toBe("my-photo.png");
|
|
expect(b.name).toBe("my-photo-2.png");
|
|
expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(200);
|
|
});
|
|
|
|
it("turns away other files and other roles", async () => {
|
|
expect((await upload("x.svg", Buffer.from("<svg/>"))).status).toBe(400);
|
|
expect((await upload("x.png", Buffer.from("not a png"))).status).toBe(400);
|
|
expect((await upload("x.png", png, T.agent)).status).toBe(403);
|
|
expect((await get("/api/attachments", T.reader)).status).toBe(403);
|
|
});
|
|
|
|
it("lists images with the notes that use them, and deletes only unused ones", async () => {
|
|
await post_("/api/changes", T.admin, { op: "create", data: post("With a photo"), body: "![[my-photo.png]]" });
|
|
const list = (await (await get("/api/attachments", T.admin)).json()) as { name: string; folder: string; usedBy: { title: string }[] }[];
|
|
expect(list.find((a) => a.name === "my-photo.png")).toMatchObject({ folder: "Posts", usedBy: [{ title: "With a photo" }] });
|
|
expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo.png" })).status).toBe(409);
|
|
expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo-2.png" })).status).toBe(200);
|
|
expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("MCP server", () => {
|
|
it("lets an agent search, post a status and draft a post, but not publish it", async () => {
|
|
const client = new Client({ name: "test", version: "1.0.0" });
|
|
await client.connect(
|
|
new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { authorization: `Bearer ${T.agent}` } } }),
|
|
);
|
|
const tools = (await client.listTools()).tools.map((t) => t.name).sort();
|
|
expect(tools).toEqual(["add_library_item", "draft_post", "get_note", "get_schema", "list_held", "search", "set_status", "update_fields"]);
|
|
|
|
const found = await client.callTool({ name: "search", arguments: { query: "mistborn" } });
|
|
expect(JSON.stringify(found.content)).toContain("Library/Books/Mistborn.md");
|
|
|
|
const status = await client.callTool({ name: "set_status", arguments: { kind: "reading", title: "Mistborn", subject: "Mistborn" } });
|
|
expect(status.isError).toBeFalsy();
|
|
const badKind = await client.callTool({ name: "set_status", arguments: { kind: "Juggling", title: "Balls" } });
|
|
expect(badKind.isError).toBe(true);
|
|
|
|
const draft = await client.callTool({
|
|
name: "draft_post",
|
|
arguments: { title: "Agent draft", topic: "Making", summary: "S.", body: "Text." },
|
|
});
|
|
expect(draft.isError).toBeFalsy();
|
|
const publish = await client.callTool({ name: "update_fields", arguments: { path: "Posts/Agent draft.md", fields: { publish: true } } });
|
|
expect(JSON.stringify(publish.content)).toContain("agent-publish");
|
|
expect(r.store.get("Posts/Agent draft.md")!.publish).toBe(false);
|
|
await client.close();
|
|
});
|
|
});
|