Files
plainpages/plugins/scheduling/shifts.test.ts
lilleman f189f88942 §7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114).
2026-06-19 14:48:27 +02:00

139 lines
7.3 KiB
TypeScript

import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import test from "node:test";
import type { PageChrome } from "../../src/chrome.ts";
import type { RequestContext } from "../../src/context.ts";
import { GuardError } from "../../src/guards.ts";
import type { RouteResult } from "../../src/plugin.ts";
import {
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts";
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
const SHIFTS: Shift[] = [
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
];
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
const asView = (r: RouteResult | void) => {
assert.ok(r && "view" in r, "expected a view result");
return r as { data: Record<string, unknown>; status?: number; view: string };
};
// ---- upstream client (fetch injected) ----
test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => {
let seen = "";
const http = (async (url, init) => {
seen = String(url);
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch;
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
});
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
});
test("createUpstream.create POSTs the input as JSON", async () => {
let body: unknown, method = "";
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream("http://up:4000", http).create(input);
assert.equal(method, "POST");
assert.deepEqual(body, input);
});
// ---- input + validation ----
test("readInput trims; validate requires title + assignee", () => {
assert.deepEqual(readInput(new URLSearchParams("title=%20Shift%20&assignee=Bo&start=1&end=2")), { assignee: "Bo", end: "2", start: "1", title: "Shift" });
assert.equal(validate({ assignee: "Bo", end: "", start: "", title: "Shift" }), null);
assert.deepEqual(Object.keys(validate({ assignee: "", end: "", start: "", title: "" }) ?? {}), ["title", "assignee"]);
});
// ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
assert.equal(r.data["canWrite"], true);
assert.equal(r.data["chrome"], CHROME);
const filtered = asView(await listShifts(fakeUpstream())(fakeCtx({ url: "http://localhost/scheduling/shifts?q=afternoon" })));
assert.deepEqual((filtered.data["table"] as { rows: { name: string }[] }).rows.map((x) => x.name), ["Afternoon support"]);
assert.equal(filtered.data["canWrite"], false); // no scheduling:write
});
test("listShifts degrades to a recoverable error page when the upstream is down (no throw)", async () => {
const r = asView(await listShifts(fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } }))(fakeCtx()));
assert.match(String(r.data["error"]), /scheduling service/i);
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
});
// ---- create handler ----
test("newShiftForm renders the empty form", async () => {
const r = asView(await newShiftForm()(fakeCtx()));
assert.equal(r.view, "shift-new");
assert.equal((r.data["form"] as { csrfToken: string }).csrfToken, "tok");
});
test("createShift rejects a bad CSRF token with a 403 GuardError", async () => {
await assert.rejects(
async () => { await createShift(fakeUpstream())(fakeCtx({ body: "title=T&assignee=A", verifyCsrf: () => false })); },
(e: unknown) => e instanceof GuardError && e.status === 403,
);
});
test("createShift re-renders the form (400) on a validation error, never touching the upstream", async () => {
let created = false;
const r = asView(await createShift(fakeUpstream({ create: async () => { created = true; } }))(fakeCtx({ body: "title=&assignee=" })));
assert.equal(r.status, 400);
assert.equal(r.view, "shift-new");
assert.equal(created, false);
});
test("createShift forwards a valid write upstream then POST-redirect-GETs", async () => {
let got: ShiftInput | undefined;
const r = await createShift(fakeUpstream({ create: async (i) => { got = i; } }))(fakeCtx({ body: "title=Night&assignee=Casey&start=22%3A00&end=06%3A00" }));
assert.deepEqual(got, { assignee: "Casey", end: "06:00", start: "22:00", title: "Night" });
assert.deepEqual(r, { redirect: SHIFTS_PATH });
});
test("createShift surfaces an upstream failure as a recoverable 502 form, keeping the input", async () => {
const r = asView(await createShift(fakeUpstream({ create: async () => { throw new UpstreamError("boom", 500); } }))(fakeCtx({ body: "title=Night&assignee=Casey" })));
assert.equal(r.status, 502);
assert.match(String(r.data["formError"]), /unavailable/i);
const fields = (r.data["form"] as { fields: { name: string; value: string }[] }).fields;
assert.equal(fields.find((f) => f.name === "title")?.value, "Night"); // input preserved for retry
});
test("buildFormModel marks title/assignee required and attaches field errors", () => {
const model = buildFormModel({ chrome: CHROME, errors: { title: "needed" }, values: { title: "x" } });
const fields = model.form.fields as { error?: string; name: string; required?: boolean; value: string }[];
const title = fields.find((f) => f.name === "title")!;
assert.equal(title.required, true);
assert.equal(title.error, "needed");
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
});