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:
@@ -86,6 +86,37 @@ export type Change =
|
||||
| { op: "create"; data: Record<string, unknown>; body?: string }
|
||||
| { op: "update"; path: string; fields?: Record<string, unknown>; body?: string };
|
||||
|
||||
export interface Submission {
|
||||
id: number;
|
||||
kind: "inquiry" | "order";
|
||||
type: string;
|
||||
name: string;
|
||||
email: string;
|
||||
fields: { label: string; value: string }[];
|
||||
attachment: { name: string; type: string; size: number } | null;
|
||||
createdAt: string;
|
||||
delivery: "pending" | "sent" | "failed" | "skipped";
|
||||
deliveryError: string | null;
|
||||
deliveryAt: string | null;
|
||||
handled: boolean;
|
||||
handledAt: string | null;
|
||||
}
|
||||
|
||||
export interface SubmissionCounts {
|
||||
total: number;
|
||||
unhandled: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** A series in PersonalReader's Reading with no Library item (content service /api/suggestions). */
|
||||
export interface Suggestion {
|
||||
key: string;
|
||||
anilistId: number;
|
||||
title: string;
|
||||
medium: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
export type FullNote = Omit<Note, "text" | "backlinks" | "pending">;
|
||||
|
||||
export class ContentApi {
|
||||
@@ -130,6 +161,17 @@ export class ContentApi {
|
||||
rollback = (path: string, rev: number) => this.call<Outcome>("POST", "/api/note/rollback", { path, rev }, [200, 422]);
|
||||
scan = () => this.call<{ changed: number; held: number }>("POST", "/api/scan", {});
|
||||
|
||||
submissions = (handled?: boolean) =>
|
||||
this.get<{ counts: SubmissionCounts; submissions: Submission[] }>("/api/submissions", { handled: handled === undefined ? undefined : String(handled) });
|
||||
submission = (id: number) => this.get<Submission>(`/api/submissions/${id}`);
|
||||
setHandled = (id: number, handled: boolean) => this.call<Submission>("POST", `/api/submissions/${id}/handled`, { handled });
|
||||
/** A submission's attachment, to pass through as a download. */
|
||||
submissionFile = (id: number) =>
|
||||
fetch(new URL(`/api/submissions/${id}/attachment`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
||||
|
||||
suggestions = () => this.get<Suggestion[]>("/api/suggestions");
|
||||
dismissSuggestion = (key: string) => this.call<{ ok: boolean }>("POST", `/api/suggestions/${encodeURIComponent(key)}/dismiss`, {});
|
||||
|
||||
/** Fetch an attachment to pass through to the browser. */
|
||||
attachment = (name: string) =>
|
||||
fetch(new URL(`/attachments/${encodeURIComponent(name)}`, this.base), { headers: { authorization: `Bearer ${this.token}` } });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The Inbox: saved copies of inquiries and order requests sent through the
|
||||
* site, unhandled first and newest first. Each one shows whether its email
|
||||
* reached Greg, so a failed email is never a lost request.
|
||||
*/
|
||||
import type { Submission, SubmissionCounts } from "./client.ts";
|
||||
import { html, qs, when, type Html } from "./html.ts";
|
||||
|
||||
export const inboxHref = (id: number) => `/inbox/${id}`;
|
||||
|
||||
const kindLabel = (s: Submission) => (s.kind === "order" ? "Order" : "Inquiry");
|
||||
|
||||
const typeLabels: Record<string, string> = { job: "Job or role", freelance: "Freelance web work", patiun: "Patiun custom order" };
|
||||
const typeLabel = (s: Submission) => (s.kind === "inquiry" ? (typeLabels[s.type] ?? s.type) : s.type);
|
||||
|
||||
export function deliveryPill(s: Pick<Submission, "delivery">): Html {
|
||||
switch (s.delivery) {
|
||||
case "sent":
|
||||
return html`<span class="pill live">Emailed</span>`;
|
||||
case "failed":
|
||||
return html`<span class="pill bad">Email failed</span>`;
|
||||
case "skipped":
|
||||
return html`<span class="pill draft">Not emailed</span>`;
|
||||
default:
|
||||
return html`<span class="pill draft">Email unknown</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
const handledPill = (s: Submission) => (s.handled ? html`<span class="pill">Handled</span>` : html`<span class="pill draft">New</span>`);
|
||||
|
||||
const size = (n: number) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
|
||||
|
||||
/** The first line of the longest field, as a hint of what it is about. */
|
||||
function gist(s: Submission): string {
|
||||
const d = s.fields.find((f) => /^(details|notes)$/i.test(f.label))?.value ?? s.fields[0]?.value ?? "";
|
||||
const line = d.split("\n").find((l) => l.trim()) ?? "";
|
||||
return line.length > 90 ? `${line.slice(0, 89)}…` : line;
|
||||
}
|
||||
|
||||
export function inboxList(items: Submission[], counts: SubmissionCounts, show: string): Html {
|
||||
const filters: [string, string][] = [
|
||||
["open", "Not handled"],
|
||||
["all", "All"],
|
||||
["handled", "Handled"],
|
||||
];
|
||||
return html`<h1>Inbox</h1>
|
||||
<p class="lede">Inquiries and order requests sent through the site. Each one is emailed to you and saved here, so nothing is lost if the email is.</p>
|
||||
<div class="grid">
|
||||
<div class="panel"><div class="stat">${counts.unhandled}</div>not handled yet</div>
|
||||
<div class="panel"><div class="stat">${counts.failed}</div>not handled and the email failed</div>
|
||||
<div class="panel"><div class="stat">${counts.total}</div>saved in all</div>
|
||||
</div>
|
||||
<p class="actions">${filters.map(([v, l]) => (v === show ? html`<strong>${l}</strong>` : html`<a href="/inbox${qs({ show: v === "open" ? undefined : v })}">${l}</a>`))}</p>
|
||||
${items.length
|
||||
? html`<div class="table-wrap"><table><thead><tr><th>From</th><th>What</th><th>About</th><th>Received</th><th>Email</th><th></th></tr></thead><tbody>
|
||||
${items.map(
|
||||
(s) => html`<tr>
|
||||
<td><a href="${inboxHref(s.id)}">${s.name}</a><br><span class="muted">${s.email}</span></td>
|
||||
<td>${kindLabel(s)}<br><span class="muted">${typeLabel(s)}</span></td>
|
||||
<td>${gist(s)}${s.attachment ? html` <span class="pill">File</span>` : ""}</td>
|
||||
<td>${when(s.createdAt)}</td>
|
||||
<td>${deliveryPill(s)}</td>
|
||||
<td>${handledPill(s)}</td>
|
||||
</tr>`,
|
||||
)}
|
||||
</tbody></table></div>`
|
||||
: html`<p class="muted">${show === "open" ? "Nothing waiting. Every request is handled." : "None yet."}</p>`}`;
|
||||
}
|
||||
|
||||
export function inboxOne(s: Submission): Html {
|
||||
const reply = `mailto:${s.email}?subject=${encodeURIComponent(s.kind === "order" ? "Your order request" : "Your inquiry")}`;
|
||||
return html`<h1>${kindLabel(s)} from ${s.name}</h1>
|
||||
<p class="lede"><a href="/inbox">Back to the inbox</a> · Received ${when(s.createdAt)} · ${handledPill(s)} ${s.handled ? when(s.handledAt) : ""}</p>
|
||||
${s.delivery === "failed"
|
||||
? html`<div class="flash err" role="alert">The email for this ${kindLabel(s).toLowerCase()} did not go out${s.deliveryError ? html`: ${s.deliveryError}` : "."} This saved copy is the only one.</div>`
|
||||
: s.delivery === "skipped"
|
||||
? html`<div class="flash" role="status">No email was sent (SMTP is not set up on the site). This saved copy is the only one.</div>`
|
||||
: s.delivery === "pending"
|
||||
? html`<div class="flash" role="status">The site did not report whether the email went out. Check your mail before relying on it.</div>`
|
||||
: ""}
|
||||
<div class="panel">
|
||||
<dl class="fields">
|
||||
<dt>Kind</dt><dd>${kindLabel(s)}: ${typeLabel(s)}</dd>
|
||||
<dt>Name</dt><dd>${s.name}</dd>
|
||||
<dt>Email</dt><dd><a href="${reply}">${s.email}</a></dd>
|
||||
${s.fields.map((f) => html`<dt>${f.label}</dt><dd>${f.value.includes("\n") ? html`<pre>${f.value}</pre>` : f.value}</dd>`)}
|
||||
${s.attachment ? html`<dt>Attachment</dt><dd><a href="/inbox/${s.id}/attachment" download>${s.attachment.name}</a> <span class="muted">${size(s.attachment.size)}, downloads as a file</span></dd>` : ""}
|
||||
<dt>Email to you</dt><dd>${deliveryPill(s)} ${s.deliveryAt ? when(s.deliveryAt) : ""}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="button" href="${reply}">Reply by email</a>
|
||||
<form method="post" action="/inbox/${s.id}/handled" class="inline">
|
||||
<input type="hidden" name="handled" value="${s.handled ? "0" : "1"}">
|
||||
<button class="${s.handled ? "" : "primary"}">${s.handled ? "Mark as not handled" : "Mark as handled"}</button>
|
||||
</form>
|
||||
</div>`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface Flash {
|
||||
const nav: [string, string][] = [
|
||||
["/", "Overview"],
|
||||
["/review", "Review"],
|
||||
["/inbox", "Inbox"],
|
||||
["/notes", "Notes"],
|
||||
["/drafts", "Drafts"],
|
||||
["/capture", "Capture"],
|
||||
@@ -16,7 +17,7 @@ const nav: [string, string][] = [
|
||||
["/problems", "Problems"],
|
||||
];
|
||||
|
||||
export function layout(p: { title: string; path: string; held: number; flash?: Flash; body: Html }): Html {
|
||||
export function layout(p: { title: string; path: string; held: number; inbox?: number; flash?: Flash; body: Html }): Html {
|
||||
const current = (href: string) => (href === "/" ? p.path === "/" : p.path.startsWith(href));
|
||||
return html`<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -34,7 +35,7 @@ export function layout(p: { title: string; path: string; held: number; flash?: F
|
||||
<a class="brand" href="/">GregKilmer.com admin</a>
|
||||
<nav>${nav.map(
|
||||
([href, label]) =>
|
||||
html`<a href="${href}" ${current(href) ? html`aria-current="page"` : ""}>${label}${href === "/review" && p.held ? html`<span class="count">${p.held}</span>` : ""}</a>`,
|
||||
html`<a href="${href}" ${current(href) ? html`aria-current="page"` : ""}>${label}${href === "/review" && p.held ? html`<span class="count">${p.held}</span>` : ""}${href === "/inbox" && p.inbox ? html`<span class="count">${p.inbox}</span>` : ""}</a>`,
|
||||
)}</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { ApiError, type ContentApi, type Outcome } from "./client.ts";
|
||||
import { html, qs, type Html } from "./html.ts";
|
||||
import { layout, type Flash } from "./layout.ts";
|
||||
import * as pages from "./pages.ts";
|
||||
import * as inbox from "./inbox.ts";
|
||||
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
||||
|
||||
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
|
||||
|
||||
@@ -47,8 +49,13 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
async function get(path: string, q: URLSearchParams): Promise<Page | Redirect> {
|
||||
switch (path) {
|
||||
case "/": {
|
||||
const [status, held, problems] = await Promise.all([api.status(), api.held(), api.problems()]);
|
||||
return { title: "Overview", body: pages.overview(status, held, problems) };
|
||||
const [status, held, problems, suggestions] = await Promise.all([
|
||||
api.status(),
|
||||
api.held(),
|
||||
api.problems(),
|
||||
api.suggestions().catch(() => []),
|
||||
]);
|
||||
return { title: "Overview", body: html`${pages.overview(status, held, problems)}${suggestionsPanel(suggestions)}` };
|
||||
}
|
||||
case "/syncs": {
|
||||
const job = q.get("job") ?? "";
|
||||
@@ -106,6 +113,16 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
case "/problems":
|
||||
return { title: "Problems", body: pages.problemsView(await api.problems()) };
|
||||
}
|
||||
if (path === "/inbox") {
|
||||
const show = q.get("show") ?? "open";
|
||||
const { counts, submissions } = await api.submissions(show === "all" ? undefined : show === "handled");
|
||||
return { title: "Inbox", body: inbox.inboxList(submissions, counts, show) };
|
||||
}
|
||||
const inboxOne = path.match(/^\/inbox\/(\d+)$/);
|
||||
if (inboxOne) {
|
||||
const s = await api.submission(Number(inboxOne[1]));
|
||||
return { title: `${s.kind === "order" ? "Order" : "Inquiry"} from ${s.name}`, body: inbox.inboxOne(s) };
|
||||
}
|
||||
const review = path.match(/^\/review\/(\d+)$/);
|
||||
if (review) {
|
||||
const h = await api.heldOne(Number(review[1]));
|
||||
@@ -179,6 +196,17 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
}
|
||||
return after("/capture", o, "Added to the Library.");
|
||||
}
|
||||
case "/suggestions/add": {
|
||||
const key = required(f, "key");
|
||||
const s = (await api.suggestions()).find((x) => x.key === key);
|
||||
if (!s) throw new ApiError(409, "That suggestion is gone: it was added or dismissed already.", null);
|
||||
const o = await api.apply(suggestionChange(s));
|
||||
return after("/", o, `Added ${s.title} to the Library as Now.`);
|
||||
}
|
||||
case "/suggestions/dismiss": {
|
||||
await api.dismissSuggestion(required(f, "key"));
|
||||
return new Redirect(`/${qs({ msg: "Dismissed. It will not be suggested again." })}`);
|
||||
}
|
||||
case "/store/category": {
|
||||
const [settings] = await api.fullNotes("store-settings");
|
||||
if (!settings) throw new ApiError(404, "There is no Store/_settings note yet.", null);
|
||||
@@ -207,6 +235,12 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
return after("/store", o, "Saved.");
|
||||
}
|
||||
}
|
||||
const handled = path.match(/^\/inbox\/(\d+)\/handled$/);
|
||||
if (handled) {
|
||||
const id = Number(handled[1]);
|
||||
const s = await api.setHandled(id, f.get("handled") === "1");
|
||||
return new Redirect(`${s.handled ? "/inbox" : inbox.inboxHref(id)}${qs({ msg: s.handled ? `Marked ${s.name}'s ${s.kind} as handled.` : "Marked as not handled." })}`);
|
||||
}
|
||||
const decide = path.match(/^\/review\/(\d+)\/(accept|reject)$/);
|
||||
if (decide) {
|
||||
const id = Number(decide[1]);
|
||||
@@ -245,6 +279,21 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
return;
|
||||
}
|
||||
|
||||
const inboxFile = url.pathname.match(/^\/inbox\/(\d+)\/attachment$/);
|
||||
if (method === "GET" && inboxFile) {
|
||||
// Always a download, never shown inline: a visitor chose this file.
|
||||
const r = await api.submissionFile(Number(inboxFile[1]));
|
||||
if (!r.ok || !r.body) return text(res, r.status === 404 ? 404 : 502, "No such attachment.");
|
||||
res.writeHead(200, {
|
||||
"content-type": r.headers.get("content-type") ?? "application/octet-stream",
|
||||
"content-disposition": r.headers.get("content-disposition") ?? "attachment",
|
||||
"x-content-type-options": "nosniff",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
Readable.fromWeb(r.body as never).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
||||
const form = new URLSearchParams(await readBody(req));
|
||||
@@ -259,18 +308,21 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
res.writeHead(303, { location: out.to });
|
||||
return res.end();
|
||||
}
|
||||
const held = (await api.held().catch(() => [])).length;
|
||||
page(res, 200, out, url, held);
|
||||
const [held, unhandled] = await Promise.all([
|
||||
api.held().then((h) => h.length).catch(() => 0),
|
||||
api.submissions(false).then((s) => s.counts.unhandled).catch(() => 0),
|
||||
]);
|
||||
page(res, 200, out, url, held, unhandled);
|
||||
}
|
||||
|
||||
function page(res: ServerResponse, status: number, p: Page, url: URL, held: number) {
|
||||
function page(res: ServerResponse, status: number, p: Page, url: URL, held: number, unhandled = 0) {
|
||||
const flash: Flash = {
|
||||
msg: url.searchParams.get("msg") ?? undefined,
|
||||
err: url.searchParams.get("err") ?? undefined,
|
||||
reasons: url.searchParams.getAll("r"),
|
||||
};
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(layout({ title: p.title, path: url.pathname, held, flash, body: p.body }).toString());
|
||||
res.end(layout({ title: p.title, path: url.pathname, held, inbox: unhandled, flash, body: p.body }).toString());
|
||||
}
|
||||
|
||||
return createServer((req, res) => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Library suggestions on the Overview: a series moved to Reading in
|
||||
* PersonalReader with no Library item yet. One button adds it (title,
|
||||
* medium, AniList id, state now, published) through the normal write API;
|
||||
* Dismiss hides it for good.
|
||||
*/
|
||||
import type { Change, Suggestion } from "./client.ts";
|
||||
import { html, type Html } from "./html.ts";
|
||||
|
||||
export function suggestionsPanel(list: Suggestion[]): Html | "" {
|
||||
if (!list.length) return "";
|
||||
return html`<h2>Library suggestions</h2>
|
||||
<p class="muted">Series you are reading in PersonalReader with no Library item. Adding one puts it on the Library as Now.</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Series</th><th>Medium</th><th></th></tr></thead>
|
||||
<tbody>${list.map(
|
||||
(s) => html`<tr>
|
||||
<td>${s.title} <span class="muted mono">AniList ${s.anilistId}</span></td>
|
||||
<td>${s.medium}</td>
|
||||
<td><form method="post" action="/suggestions/add" class="actions"><input type="hidden" name="key" value="${s.key}"><button class="primary">Add to the Library</button></form>
|
||||
<form method="post" action="/suggestions/dismiss" class="actions"><input type="hidden" name="key" value="${s.key}"><button>Dismiss</button></form></td>
|
||||
</tr>`,
|
||||
)}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
/** The Library item a suggestion becomes. */
|
||||
export function suggestionChange(s: Suggestion): Change {
|
||||
return {
|
||||
op: "create",
|
||||
data: { type: "library-item", title: s.title, medium: s.medium, anilistId: s.anilistId, state: "now", publish: true },
|
||||
};
|
||||
}
|
||||
@@ -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