Files
com.gregkilmer.www/services/content/test/submissions.test.ts
T
Claude e3b0cf60c5 Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:50:06 +00:00

146 lines
7.8 KiB
TypeScript

import { readdirSync } from "node:fs";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { parseTokens } from "../src/config.ts";
import { createHttpServer } from "../src/http.ts";
import { extensionOf, safeName } from "../src/submissions.ts";
import { book, rig, type Rig } from "./helpers.ts";
const T = { reader: "r".repeat(24), agent: "a".repeat(24), editor: "e".repeat(24), admin: "d".repeat(24), intake: "i".repeat(24) };
const MAX = 1024;
let r: Rig;
let base: string;
let server: ReturnType<typeof createHttpServer>;
beforeAll(async () => {
r = rig();
r.put(book("Mistborn"));
r.service.scan();
server = createHttpServer(r.service, {
tokens: parseTokens(Object.entries(T).map(([k, v]) => `${k}:${v}`).join(",")),
submissionMaxBytes: MAX,
});
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 call = (method: string, path: string, token: string, body?: unknown) =>
fetch(base + path, {
method,
headers: { authorization: `Bearer ${token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const inquiry = (extra: Record<string, unknown> = {}) => ({
kind: "inquiry",
type: "job",
name: "Jane Doe",
email: "jane@example.com",
fields: [{ label: "Details", value: "A role on the platform team." }],
...extra,
});
describe("submissions", () => {
it("lets intake file a submission and nothing else", async () => {
const res = await call("POST", "/api/submissions", T.intake, inquiry());
expect(res.status).toBe(201);
const { id, receipt } = (await res.json()) as { id: number; receipt: string };
expect(id).toBeGreaterThan(0);
expect(receipt.length).toBeGreaterThan(20);
for (const path of ["/api/submissions", `/api/submissions/${id}`, `/api/submissions/${id}/attachment`, "/api/notes", "/api/note?type=library-item&slug=mistborn", "/api/schema", "/api/redirects", "/api/status", "/api/held", "/attachments/x.png"]) {
expect((await call("GET", path, T.intake)).status, path).toBe(403);
}
expect((await call("POST", `/api/submissions/${id}/handled`, T.intake, { handled: true })).status).toBe(403);
expect((await call("POST", "/api/changes", T.intake, { op: "create", data: book("X") })).status).toBe(403);
expect((await call("POST", "/api/syncs", T.intake, { job: "x", ok: true })).status).toBe(403);
expect((await call("POST", "/mcp", T.intake, {})).status).toBe(403);
});
it("keeps readers, agents and editors out of the saved copies", async () => {
for (const t of [T.reader, T.agent, T.editor]) {
expect((await call("GET", "/api/submissions", t)).status).toBe(403);
expect((await call("POST", "/api/submissions", t, inquiry())).status).toBe(403);
expect((await call("GET", "/api/submissions/1", t)).status).toBe(403);
}
expect((await call("GET", "/api/submissions", "nope".repeat(8))).status).toBe(401);
});
it("records delivery once, only with the matching receipt", async () => {
const { id, receipt } = (await (await call("POST", "/api/submissions", T.intake, inquiry())).json()) as { id: number; receipt: string };
expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { receipt: "wrong", status: "sent" })).status).toBe(403);
expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { status: "sent" })).status).toBe(403);
expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { receipt, status: "failed", error: "SMTP said no" })).status).toBe(200);
expect((await call("POST", `/api/submissions/${id}/delivery`, T.intake, { receipt, status: "sent" })).status).toBe(409);
const s = (await (await call("GET", `/api/submissions/${id}`, T.admin)).json()) as { delivery: string; deliveryError: string };
expect(s.delivery).toBe("failed");
expect(s.deliveryError).toBe("SMTP said no");
expect(JSON.stringify(s)).not.toContain(receipt);
});
it("lists unhandled first, newest first, and marks handled", async () => {
const list = async () =>
((await (await call("GET", "/api/submissions", T.admin)).json()) as { counts: { unhandled: number }; submissions: { id: number; handled: boolean }[] });
const before = await list();
const ids = before.submissions.map((s) => s.id);
expect(ids).toEqual([...ids].sort((a, b) => b - a));
const oldest = ids[ids.length - 1]!;
const res = await call("POST", `/api/submissions/${oldest}/handled`, T.admin, { handled: true });
expect(res.status).toBe(200);
const after = await list();
expect(after.submissions[after.submissions.length - 1]!.id).toBe(oldest);
expect(after.submissions[after.submissions.length - 1]!.handled).toBe(true);
expect(after.counts.unhandled).toBe(before.counts.unhandled - 1);
expect((await call("POST", "/api/submissions/9999/handled", T.admin, { handled: true })).status).toBe(404);
});
it("validates what it saves", async () => {
expect((await call("POST", "/api/submissions", T.intake, inquiry({ email: "not an email" }))).status).toBe(400);
expect((await call("POST", "/api/submissions", T.intake, inquiry({ kind: "spam" }))).status).toBe(400);
expect((await call("POST", "/api/submissions", T.intake, inquiry({ name: "" }))).status).toBe(400);
});
it("saves an attachment under a safe name and serves it as a download", async () => {
const data = Buffer.from("1 Sol Ring\n1 Command Tower\n").toString("base64");
const res = await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "../../etc/deck list.txt", data } }));
expect(res.status).toBe(201);
const { id } = (await res.json()) as { id: number };
const files = readdirSync(join(r.dir, "submissions"));
expect(files.some((f) => f.startsWith(`${id}-`) && f.endsWith(".txt"))).toBe(true);
const s = (await (await call("GET", `/api/submissions/${id}`, T.admin)).json()) as { attachment: { name: string; size: number } };
expect(s.attachment.name).toBe("deck list.txt");
const dl = await call("GET", `/api/submissions/${id}/attachment`, T.admin);
expect(dl.status).toBe(200);
expect(dl.headers.get("content-disposition")).toContain("attachment;");
expect(dl.headers.get("x-content-type-options")).toBe("nosniff");
expect(await dl.text()).toBe("1 Sol Ring\n1 Command Tower\n");
});
it("turns down attachments that are too large, of an unknown type, or not base64", async () => {
const big = Buffer.alloc(MAX + 1, 1).toString("base64");
const count = async () => ((await (await call("GET", "/api/submissions", T.admin)).json()) as { counts: { total: number } }).counts.total;
const n = await count();
expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.png", data: big } }))).status).toBe(413);
expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.exe", data: "AAAA" } }))).status).toBe(415);
expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.html", data: "AAAA" } }))).status).toBe(415);
expect((await call("POST", "/api/submissions", T.intake, inquiry({ attachment: { name: "a.png", data: "<script>" } }))).status).toBe(400);
expect(await count()).toBe(n);
});
});
describe("attachment names", () => {
it("strips paths and odd characters", () => {
expect(safeName("C:\\Users\\x\\photo.png")).toBe("photo.png");
expect(safeName("..")).toBe("attachment");
expect(safeName("a\r\nb.txt")).toBe("a_b.txt");
expect(extensionOf("deck.DEK")).toBe("dek");
expect(extensionOf("x.svg")).toBeNull();
expect(extensionOf("noext")).toBeNull();
});
});