mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { parseTokens } from "../../content/src/config.ts";
|
||||
import { createHttpServer } from "../../content/src/http.ts";
|
||||
import { 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);
|
||||
const INTAKE = "i".repeat(24);
|
||||
let r: Rig;
|
||||
let content: Server;
|
||||
let admin: Server;
|
||||
let base: string;
|
||||
let contentUrl: 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}`;
|
||||
};
|
||||
|
||||
const file = (body: unknown) =>
|
||||
fetch(`${contentUrl}/api/submissions`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${INTAKE}`, "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then((res) => res.json() as Promise<{ id: number; receipt: string }>);
|
||||
|
||||
let first: number;
|
||||
let second: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
r = rig();
|
||||
r.service.scan();
|
||||
content = createHttpServer(r.service, { tokens: parseTokens(`admin:${ADMIN},intake:${INTAKE}`) });
|
||||
contentUrl = await listen(content);
|
||||
admin = createAdminServer(new ContentApi(contentUrl, ADMIN));
|
||||
base = await listen(admin);
|
||||
const a = await file({
|
||||
kind: "inquiry",
|
||||
type: "patiun",
|
||||
name: "Ann <b>Bold</b>",
|
||||
email: "ann@example.com",
|
||||
fields: [{ label: "Details", value: "Sixty proxies\nfor a Golgari deck" }],
|
||||
attachment: { name: "list.txt", data: Buffer.from("1 Sol Ring").toString("base64") },
|
||||
});
|
||||
first = a.id;
|
||||
await fetch(`${contentUrl}/api/submissions/${a.id}/delivery`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${INTAKE}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ receipt: a.receipt, status: "failed", error: "Connection refused" }),
|
||||
});
|
||||
second = (await file({ kind: "order", type: "proxy-cards", name: "Bo", email: "bo@example.com", fields: [{ label: "Quantity", value: "75 cards" }] })).id;
|
||||
});
|
||||
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(), headers: res.headers };
|
||||
};
|
||||
const post = async (path: string, form: Record<string, string>, headers: Record<string, string> = { "sec-fetch-site": "same-origin" }) => {
|
||||
const res = await fetch(base + path, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
|
||||
body: new URLSearchParams(form).toString(),
|
||||
});
|
||||
return { status: res.status, location: res.headers.get("location") ?? "" };
|
||||
};
|
||||
|
||||
describe("admin inbox", () => {
|
||||
it("lists submissions newest first with their email status, escaped", async () => {
|
||||
const page = await get("/inbox");
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.text.indexOf("Bo</a>")).toBeLessThan(page.text.indexOf("Ann <b>"));
|
||||
expect(page.text).not.toContain("<b>Bold</b>");
|
||||
expect(page.text).toContain("Email failed");
|
||||
expect(page.text).toContain('<span class="count">2</span>');
|
||||
});
|
||||
|
||||
it("shows one with the failure and the attachment", async () => {
|
||||
const page = await get(`/inbox/${first}`);
|
||||
expect(page.text).toContain("Connection refused");
|
||||
expect(page.text).toContain("<pre>Sixty proxies\nfor a Golgari deck</pre>");
|
||||
expect(page.text).toContain(`/inbox/${first}/attachment`);
|
||||
const dl = await fetch(`${base}/inbox/${first}/attachment`);
|
||||
expect(dl.headers.get("content-disposition")).toContain("attachment");
|
||||
expect(await dl.text()).toBe("1 Sol Ring");
|
||||
expect((await get(`/inbox/${second}/attachment`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("marks handled and back, from this page only", async () => {
|
||||
expect((await post(`/inbox/${second}/handled`, { handled: "1" }, { "sec-fetch-site": "cross-site" })).status).toBe(403);
|
||||
const done = await post(`/inbox/${second}/handled`, { handled: "1" });
|
||||
expect(done.status).toBe(303);
|
||||
expect(done.location).toMatch(/^\/inbox\?msg=/);
|
||||
const open = await get("/inbox");
|
||||
expect(open.text).not.toContain("Bo</a>");
|
||||
expect((await get("/inbox?show=handled")).text).toContain("Bo</a>");
|
||||
await post(`/inbox/${second}/handled`, { handled: "0" });
|
||||
expect((await get("/inbox")).text).toContain("Bo</a>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Library suggestions on the Overview: one button adds the Library item
|
||||
* through the normal write API, Dismiss hides it. The suggestion list is
|
||||
* seeded the way the Suwayomi job leaves it in the sync cache.
|
||||
*/
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { parseTokens } from "../../content/src/config.ts";
|
||||
import { createHttpServer } from "../../content/src/http.ts";
|
||||
import { SUGGESTIONS_KEY } from "../../content/src/sync/jobs/reader.ts";
|
||||
import { rig, type Rig } from "../../content/test/helpers.ts";
|
||||
import { ContentApi } from "../src/client.ts";
|
||||
import { createAdminServer } from "../src/server.ts";
|
||||
|
||||
const ADMIN = "s".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}`;
|
||||
};
|
||||
const page = (path: string) => fetch(`${base}${path}`).then((res) => res.text());
|
||||
const flash = (res: Response) => new URL(res.headers.get("location") ?? "/", "http://admin").searchParams;
|
||||
const post = (path: string, form: Record<string, string>) =>
|
||||
fetch(`${base}${path}`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin" },
|
||||
body: new URLSearchParams(form).toString(),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
r = rig();
|
||||
r.service.scan();
|
||||
r.store.syncCache.set(
|
||||
"suwayomi",
|
||||
SUGGESTIONS_KEY,
|
||||
{
|
||||
items: [
|
||||
{ anilistId: 100004, title: "New Sample Series", medium: "manhwa" },
|
||||
{ anilistId: 100006, title: "Another <Sample>", medium: "manga" },
|
||||
],
|
||||
},
|
||||
"2026-10-11T12:00:00Z",
|
||||
);
|
||||
content = createHttpServer(r.service, { tokens: parseTokens(`admin:${ADMIN}`) });
|
||||
admin = createAdminServer(new ContentApi(await listen(content), ADMIN));
|
||||
base = await listen(admin);
|
||||
});
|
||||
afterAll(() => {
|
||||
admin.close();
|
||||
content.close();
|
||||
r.done();
|
||||
});
|
||||
|
||||
describe("Library suggestions", () => {
|
||||
it("show on the Overview, escaped", async () => {
|
||||
const html = await page("/");
|
||||
expect(html).toContain("Library suggestions");
|
||||
expect(html).toContain("New Sample Series");
|
||||
expect(html).toContain("Another <Sample>");
|
||||
expect(html).toContain('value="anilist-100004"');
|
||||
});
|
||||
|
||||
it("add creates the Library item through the write API, and the suggestion leaves", async () => {
|
||||
const res = await post("/suggestions/add", { key: "anilist-100004" });
|
||||
expect(res.status).toBe(303);
|
||||
expect(flash(res).get("msg")).toBe("Added New Sample Series to the Library as Now.");
|
||||
const note = r.store.list({ type: "library-item" }).find((n) => n.data.anilistId === 100004)!;
|
||||
expect(note.data).toMatchObject({ title: "New Sample Series", medium: "manhwa", state: "now", publish: true, updatedBy: "admin" });
|
||||
expect(r.vault.exists(note.path)).toBe(true);
|
||||
expect(await page("/")).not.toContain("New Sample Series");
|
||||
});
|
||||
|
||||
it("dismiss hides it for good; the panel goes when nothing is left", async () => {
|
||||
expect((await post("/suggestions/dismiss", { key: "anilist-100006" })).status).toBe(303);
|
||||
const html = await page("/");
|
||||
expect(html).not.toContain("Library suggestions");
|
||||
// A gone suggestion cannot be added.
|
||||
const again = await post("/suggestions/add", { key: "anilist-100006" });
|
||||
expect(flash(again).get("err")).toContain("That suggestion is gone");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user