mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
e3b0cf60c5
- 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
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { Note } from "../src/lib/content.ts";
|
|
import {
|
|
checkOrder,
|
|
clientIp,
|
|
DRAFT_LIMIT,
|
|
encodeDraft,
|
|
formValues,
|
|
inquiryFields,
|
|
inquirySent,
|
|
isBot,
|
|
orderFields,
|
|
orderSent,
|
|
parseDraft,
|
|
parseOrderSent,
|
|
RateLimiter,
|
|
saveAndSend,
|
|
validateInquiry,
|
|
validateOrderFields,
|
|
} from "../src/lib/forms.ts";
|
|
import { SiteIndex } from "../src/lib/site.ts";
|
|
import { storeState } from "../src/lib/store.ts";
|
|
import { parseSentSummary } from "../src/lib/work.ts";
|
|
|
|
const MB = 1024 * 1024;
|
|
|
|
const form = (fields: Record<string, string | File>) => {
|
|
const f = new FormData();
|
|
for (const [k, v] of Object.entries(fields)) f.append(k, v);
|
|
return f;
|
|
};
|
|
|
|
const base = { name: "Jane Doe", email: "jane@example.com", details: "A senior role on the platform team." };
|
|
|
|
describe("inquiry validation", () => {
|
|
it("takes a job inquiry and only the job questions", () => {
|
|
const r = validateInquiry(
|
|
form({ ...base, type: "job", job_company: "Acme", job_kind: "Contract", job_link: "https://acme.example/jobs/1", patiun_quantity: "60", freelance_need: "An app" }),
|
|
{ maxBytes: MB },
|
|
);
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.value.rows).toEqual([
|
|
{ label: "Company", value: "Acme" },
|
|
{ label: "Kind of role", value: "Contract" },
|
|
{ label: "Posting", value: "https://acme.example/jobs/1" },
|
|
]);
|
|
expect(inquiryFields(r.value).at(-1)).toEqual({ label: "Details", value: base.details });
|
|
});
|
|
|
|
it("marks what is wrong", () => {
|
|
const r = validateInquiry(form({ type: "nope", name: "", email: "x@", details: "hi", job_link: "javascript:alert(1)" }), { maxBytes: MB });
|
|
expect(r.ok).toBe(false);
|
|
if (r.ok) return;
|
|
expect(Object.keys(r.errors).sort()).toEqual(["details", "email", "name", "type"]);
|
|
});
|
|
|
|
it("checks choices, links and dates for the chosen type", () => {
|
|
const bad = validateInquiry(
|
|
form({ ...base, type: "freelance", freelance_need: "World peace", freelance_site: "ftp://x", freelance_timing: "date", freelance_date: "soon" }),
|
|
{ maxBytes: MB },
|
|
);
|
|
expect(bad.ok).toBe(false);
|
|
if (!bad.ok) expect(Object.keys(bad.errors).sort()).toEqual(["freelance_date", "freelance_need", "freelance_site"]);
|
|
const good = validateInquiry(form({ ...base, type: "patiun", patiun_item: "Deck box", patiun_timing: "date", patiun_date: "2026-12-01" }), { maxBytes: MB });
|
|
expect(good.ok && good.value.rows).toEqual([
|
|
{ label: "Item", value: "Deck box" },
|
|
{ label: "Need it by", value: "Dec 1, 2026" },
|
|
]);
|
|
});
|
|
|
|
it("limits attachments by type and size", () => {
|
|
const ok = validateInquiry(form({ ...base, type: "patiun", attachment: new File(["1 Sol Ring"], "deck.dek") }), { maxBytes: MB });
|
|
expect(ok.ok && ok.value.attachment?.name).toBe("deck.dek");
|
|
const exe = validateInquiry(form({ ...base, type: "patiun", attachment: new File(["MZ"], "x.exe") }), { maxBytes: MB });
|
|
expect(!exe.ok && exe.errors.attachment).toBeTruthy();
|
|
const big = validateInquiry(form({ ...base, type: "patiun", attachment: new File([new Uint8Array(MB + 1)], "big.png") }), { maxBytes: MB });
|
|
expect(!big.ok && big.errors.attachment).toContain("1 MB");
|
|
const empty = validateInquiry(form({ ...base, type: "patiun", attachment: new File([], "") }), { maxBytes: MB });
|
|
expect(empty.ok && empty.value.attachment).toBeNull();
|
|
});
|
|
|
|
it("builds the sent cookie the sent page reads", () => {
|
|
const r = validateInquiry(form({ ...base, type: "patiun", patiun_quantity: "60 cards" }), { maxBytes: MB });
|
|
if (!r.ok) throw new Error("should pass");
|
|
const parsed = parseSentSummary(encodeURIComponent(JSON.stringify(inquirySent(r.value))));
|
|
expect(parsed).toEqual({ type: "patiun", name: "Jane Doe", email: "jane@example.com", rows: [{ label: "How many", value: "60 cards" }] });
|
|
});
|
|
|
|
it("spots the honeypot", () => {
|
|
expect(isBot(form({ leave_blank: "http://spam" }))).toBe(true);
|
|
expect(isBot(form({ leave_blank: "" }))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Sample store notes, written for these tests.
|
|
const settings = (open: Record<string, boolean>) =>
|
|
({
|
|
path: "Store/_settings.md",
|
|
type: "store-settings",
|
|
name: "_settings",
|
|
slug: "settings",
|
|
title: "Store settings",
|
|
body: "",
|
|
acceptedAt: "2026-10-01",
|
|
data: { type: "store-settings", title: "Store settings", categories: Object.entries(open).map(([name, ordersOpen]) => ({ name, ordersOpen })) },
|
|
}) as unknown as Note;
|
|
|
|
const line = (slug: string, data: Record<string, unknown>) =>
|
|
({
|
|
path: `Store/${slug}.md`,
|
|
type: "product-line",
|
|
name: slug,
|
|
slug,
|
|
title: slug.replace(/-/g, " "),
|
|
body: "",
|
|
acceptedAt: "2026-10-01",
|
|
data: { type: "product-line", title: slug, category: "Tabletop", channel: "here", mode: "made to order", summary: "", priceTiers: [], ...data },
|
|
}) as unknown as Note;
|
|
|
|
const store = (open: Record<string, boolean>, lines: Note[]) => storeState(new SiteIndex([settings(open), ...lines]));
|
|
|
|
const cards = line("proxy-cards", {
|
|
slots: { open: 2, total: 5 },
|
|
options: [
|
|
{ group: "Finish", name: "Matte", inStock: true },
|
|
{ group: "Finish", name: "Foil", inStock: false },
|
|
],
|
|
});
|
|
const orderForm = (extra: Record<string, string> = {}) =>
|
|
form({ name: "Bo", email: "bo@example.com", line: "proxy-cards", quantity: "75 cards", terms: "accepted", ...extra });
|
|
|
|
const fieldsOf = (f: FormData) => {
|
|
const r = validateOrderFields(f);
|
|
if (!r.ok) throw new Error(JSON.stringify(r.errors));
|
|
return r.value;
|
|
};
|
|
|
|
describe("order validation", () => {
|
|
it("needs the terms, a line, a quantity and contact details", () => {
|
|
const r = validateOrderFields(form({ line: "../x" }));
|
|
expect(!r.ok && Object.keys(r.errors).sort()).toEqual(["email", "line", "name", "quantity", "terms"]);
|
|
});
|
|
|
|
it("takes an order for an open line with room and an in-stock option", () => {
|
|
const f = orderForm({ "option.proxy-cards.Finish": "Matte", "option.other-line.Color": "Red" });
|
|
const r = checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [cards]));
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.value.options).toEqual([{ label: "Finish", value: "Matte" }]);
|
|
expect(orderFields(r.value).map((x) => x.label)).toEqual(["Line", "Finish", "Quantity", "Terms"]);
|
|
expect(parseOrderSent(encodeURIComponent(JSON.stringify(orderSent(r.value))))?.rows).toEqual([
|
|
{ label: "Finish", value: "Matte" },
|
|
{ label: "Quantity", value: "75 cards" },
|
|
]);
|
|
});
|
|
|
|
it("turns down a closed category, whatever the post says", () => {
|
|
const f = orderForm();
|
|
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: false }, [cards]))).toMatchObject({ ok: false, code: "closed" });
|
|
// No settings note at all counts as closed.
|
|
expect(checkOrder(fieldsOf(f), f, storeState(new SiteIndex([cards])))).toMatchObject({ ok: false, code: "closed" });
|
|
});
|
|
|
|
it("turns down a line with no open slot or no stock", () => {
|
|
const f = orderForm();
|
|
const full = line("proxy-cards", { slots: { open: 0, total: 5 } });
|
|
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [full]))).toMatchObject({ ok: false, code: "full" });
|
|
const sold = line("proxy-cards", { mode: "in stock", quantity: 0 });
|
|
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [sold]))).toMatchObject({ ok: false, code: "full" });
|
|
const stocked = line("proxy-cards", { mode: "in stock", quantity: 3 });
|
|
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [stocked])).ok).toBe(true);
|
|
});
|
|
|
|
it("turns down lines sold elsewhere or unknown, and out-of-stock options", () => {
|
|
const f = orderForm();
|
|
const etsy = line("proxy-cards", { channel: "Patiun.com", slots: { open: 2, total: 5 } });
|
|
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [etsy]))).toMatchObject({ ok: false, code: "invalid" });
|
|
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, []))).toMatchObject({ ok: false, code: "invalid" });
|
|
const foil = orderForm({ "option.proxy-cards.Finish": "Foil" });
|
|
expect(checkOrder(fieldsOf(foil), foil, store({ Tabletop: true }, [cards]))).toMatchObject({ ok: false, code: "invalid" });
|
|
const made = orderForm({ "option.proxy-cards.Finish": "Gold leaf" });
|
|
expect(checkOrder(fieldsOf(made), made, store({ Tabletop: true }, [cards]))).toMatchObject({ ok: false, code: "invalid" });
|
|
});
|
|
});
|
|
|
|
describe("try-again drafts", () => {
|
|
it("round-trips and fits in a cookie", () => {
|
|
const values = formValues(form({ name: "Jane", details: "x".repeat(20_000), leave_blank: "", "cf-turnstile-response": "tok", terms: "accepted" }));
|
|
expect(Object.keys(values)).toEqual(["name", "details"]);
|
|
const raw = encodeDraft({ code: "invalid", errors: { email: "Add an email" }, values });
|
|
expect(encodeURIComponent(raw).length).toBeLessThanOrEqual(DRAFT_LIMIT);
|
|
const d = parseDraft(encodeURIComponent(raw));
|
|
expect(d?.code).toBe("invalid");
|
|
expect(d?.values.name).toBe("Jane");
|
|
expect(d?.errors.email).toBe("Add an email");
|
|
expect(parseDraft("garbage")).toBeUndefined();
|
|
expect(parseDraft(JSON.stringify({ code: "nope" }))).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("saving and emailing", () => {
|
|
const quiet = { log: () => {} };
|
|
|
|
it("records a failed email and still succeeds when the copy was saved", async () => {
|
|
const report = vi.fn(async () => {});
|
|
const r = await saveAndSend({
|
|
...quiet,
|
|
save: async () => ({ id: 7, receipt: "rcpt" }),
|
|
email: async () => {
|
|
throw new Error("ECONNREFUSED");
|
|
},
|
|
report,
|
|
});
|
|
expect(r).toEqual({ ok: true, savedId: 7, email: "failed" });
|
|
expect(report).toHaveBeenCalledWith(7, "rcpt", "failed", "ECONNREFUSED");
|
|
});
|
|
|
|
it("still emails when saving fails, and fails only when both do", async () => {
|
|
const email = vi.fn(async (id: number | null) => (id === null ? ("sent" as const) : ("skipped" as const)));
|
|
const report = vi.fn(async () => {});
|
|
const failSave = async (): Promise<{ id: number; receipt: string }> => {
|
|
throw new Error("down");
|
|
};
|
|
expect(await saveAndSend({ ...quiet, save: failSave, email, report })).toEqual({ ok: true, savedId: null, email: "sent" });
|
|
expect(report).not.toHaveBeenCalled();
|
|
const r = await saveAndSend({
|
|
...quiet,
|
|
save: failSave,
|
|
email: async () => {
|
|
throw new Error("smtp down");
|
|
},
|
|
report,
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
// Nothing configured for mail and nothing saved: not a success either.
|
|
expect((await saveAndSend({ ...quiet, save: failSave, email: async () => "skipped", report })).ok).toBe(false);
|
|
});
|
|
|
|
it("counts a skipped email as fine when the copy was saved", async () => {
|
|
const report = vi.fn(async () => {});
|
|
const r = await saveAndSend({ ...quiet, save: async () => ({ id: 1, receipt: "r" }), email: async () => "skipped", report });
|
|
expect(r.ok).toBe(true);
|
|
expect(report).toHaveBeenCalledWith(1, "r", "skipped", undefined);
|
|
});
|
|
});
|
|
|
|
describe("rate limit and address", () => {
|
|
it("allows N posts per window per address", () => {
|
|
let t = 0;
|
|
const rl = new RateLimiter(() => 2, () => 1000, () => t);
|
|
expect([rl.hit("a"), rl.hit("a"), rl.hit("a"), rl.hit("b")]).toEqual([false, false, true, false]);
|
|
t = 1001;
|
|
expect(rl.hit("a")).toBe(false);
|
|
});
|
|
|
|
it("reads the forwarded address first", () => {
|
|
expect(clientIp(new Headers({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" }), "10.0.0.2")).toBe("203.0.113.9");
|
|
expect(clientIp(new Headers(), "10.0.0.2")).toBe("10.0.0.2");
|
|
});
|
|
});
|