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,261 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* PersonalReader progress on Library rows and synced stock on Store options.
|
||||
* Synced values are the shapes the content service's jobs store (see
|
||||
* services/content/src/sync/jobs/); a note's own values always win, and a
|
||||
* note with nothing synced renders as before.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withSynced, type Note } from "../src/lib/content.ts";
|
||||
import { itemDetails, readerProgress, type Item } from "../src/lib/library.ts";
|
||||
import { optionGroups, type Line } from "../src/lib/store.ts";
|
||||
|
||||
const note = <T extends Note>(type: string, data: Record<string, unknown>, synced?: Record<string, unknown>) =>
|
||||
withSynced({ path: `${type}/x.md`, type, name: "x", slug: "x", title: "X", body: "", acceptedAt: "", data: { type, ...data }, synced } as unknown as T);
|
||||
|
||||
describe("Library progress from PersonalReader", () => {
|
||||
const base = { medium: "manhwa", state: "now", creator: "Someone", year: 2020 };
|
||||
|
||||
it("adds the chapter line to the details", () => {
|
||||
const i = note<Item>("library-item", base, { chapterProgress: { title: "X", status: "now", chapter: 142, readAt: "2026-10-03T05:00:00.000Z" } });
|
||||
expect(readerProgress(i)).toBe("Chapter 142, read Oct 3, 2026");
|
||||
expect(itemDetails(i)).toEqual(["Someone", 2020, "Chapter 142, read Oct 3, 2026"]);
|
||||
const book = note<Item>("library-item", { ...base, medium: "light novel" }, { bookProgress: { title: "X", percent: 62 } });
|
||||
expect(readerProgress(book)).toBe("62% read");
|
||||
});
|
||||
|
||||
it("lets the note's own progress win, and shows the same line when nothing is synced", () => {
|
||||
const own = note<Item>("library-item", { ...base, progress: "16/18 volumes" }, { chapterProgress: { chapter: 3 } });
|
||||
expect(itemDetails(own)).toEqual(["Someone", 2020, "16/18 volumes"]);
|
||||
const bare = note<Item>("library-item", base);
|
||||
expect(itemDetails(bare)).toEqual(["Someone", 2020]);
|
||||
expect(readerProgress(note<Item>("library-item", base, { bookProgress: { percent: 0 } }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Store options with synced stock", () => {
|
||||
const lineData = (options: unknown[]) => ({ category: "Tabletop", mode: "made to order", channel: "here", summary: "", priceTiers: [], options });
|
||||
const stock = {
|
||||
filamentStock: [
|
||||
{
|
||||
group: "Filament",
|
||||
options: [
|
||||
{ name: "Jade White", hex: "#f5f5dc", inStock: true },
|
||||
{ name: "Bambu Green", hex: "#00ae42", inStock: false },
|
||||
{ name: "Charcoal", hex: "#2b2b2b", inStock: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("is exactly the note's options when nothing is synced", () => {
|
||||
const opts = [
|
||||
{ group: "Filament", name: "Jade White", hex: "#ffffff", inStock: true },
|
||||
{ group: "Finish", name: "Matte", inStock: false },
|
||||
];
|
||||
expect(optionGroups(note<Line>("product-line", lineData(opts)))).toEqual([
|
||||
["Filament", [opts[0]]],
|
||||
["Finish", [opts[1]]],
|
||||
]);
|
||||
// Left out with nothing synced: in stock, as before the field was optional.
|
||||
expect(optionGroups(note<Line>("product-line", lineData([{ group: "Finish", name: "Gloss" }])))[0]![1][0]!.inStock).toBe(true);
|
||||
});
|
||||
|
||||
it("sits under the note: note values win, gaps take the synced value, unlisted in-stock colors follow", () => {
|
||||
const l = note<Line>(
|
||||
"product-line",
|
||||
lineData([
|
||||
{ group: "Filament", name: "Jade White" },
|
||||
{ group: "Filament", name: "Bambu Green", inStock: true },
|
||||
{ group: "Filament", name: "Galaxy Black" },
|
||||
{ group: "Finish", name: "Matte", inStock: true },
|
||||
]),
|
||||
stock,
|
||||
);
|
||||
const [[g, filament], [, finish]] = optionGroups(l) as [string, ReturnType<typeof optionGroups>[number][1]][];
|
||||
expect(g).toBe("Filament");
|
||||
expect(filament).toEqual([
|
||||
{ group: "Filament", name: "Jade White", hex: "#f5f5dc", inStock: true },
|
||||
// The note says in stock: the note wins over the source.
|
||||
{ group: "Filament", name: "Bambu Green", hex: "#00ae42", inStock: true },
|
||||
// Not at the source: out of stock.
|
||||
{ group: "Filament", name: "Galaxy Black", inStock: false },
|
||||
{ group: "Filament", name: "Charcoal", hex: "#2b2b2b", inStock: true },
|
||||
]);
|
||||
expect(finish).toEqual([{ group: "Finish", name: "Matte", inStock: true }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withSynced, type Note } from "../src/lib/content.ts";
|
||||
import { gamePlayers, gamePlayTime, type Game } from "../src/lib/library.ts";
|
||||
import { deckArt, deckColors } from "../src/lib/world.ts";
|
||||
|
||||
const note = <T extends Note>(type: string, data: Record<string, unknown>, synced?: Record<string, unknown>) =>
|
||||
({ path: `${type}/x.md`, type, name: "x", slug: "x", title: "X", body: "", acceptedAt: "", data: { type, ...data }, synced }) as unknown as T;
|
||||
|
||||
describe("synced values", () => {
|
||||
it("sit under the note's own values: the note always wins", () => {
|
||||
const n = withSynced(note("library-item", { cover: "[[mine.jpg]]", title: "X" }, { cover: "https://x/cover.jpg", year: 2006 }));
|
||||
expect(n.data).toMatchObject({ cover: "[[mine.jpg]]", year: 2006 });
|
||||
const bare = withSynced(note("library-item", { title: "X" }, { cover: "https://x/cover.jpg" }));
|
||||
expect((bare.data as { cover?: string }).cover).toBe("https://x/cover.jpg");
|
||||
});
|
||||
|
||||
it("leave a note with nothing synced exactly as it came", () => {
|
||||
const n = note("board-game", { bggId: 1, blurb: "b" }, {});
|
||||
expect(withSynced(n)).toBe(n);
|
||||
const none = note<Game>("board-game", { bggId: 1, blurb: "b" });
|
||||
expect(withSynced(none)).toBe(none);
|
||||
expect([gamePlayers(none), gamePlayTime(none)]).toEqual([undefined, undefined]);
|
||||
expect(deckArt(note("deck", { game: "[[Magic]]" }))).toBeUndefined();
|
||||
expect(deckColors(note("deck", { game: "[[Magic]]" }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("give the Shelf players and play time, and the decks art and colors", () => {
|
||||
const g = withSynced(note<Game>("board-game", { bggId: 1, blurb: "b" }, { minPlayers: 1, maxPlayers: 5, playTime: 70, minPlayTime: 40, maxPlayTime: 70 }));
|
||||
expect([gamePlayers(g), gamePlayTime(g)]).toEqual(["1–5 players", "40–70 min"]);
|
||||
const solo = withSynced(note<Game>("board-game", { bggId: 1, blurb: "b" }, { minPlayers: 1, maxPlayers: 1, playTime: 30 }));
|
||||
expect([gamePlayers(solo), gamePlayTime(solo)]).toEqual(["1 player", "30 min"]);
|
||||
const d = withSynced(note<Note<"deck">>("deck", { game: "[[Magic]]" }, { commanderArt: "https://cards.scryfall.io/a.jpg", colors: ["W", "U", "G"] }));
|
||||
expect(deckArt(d)).toBe("https://cards.scryfall.io/a.jpg");
|
||||
expect(deckColors(d)).toEqual(["White", "Blue", "Green"]);
|
||||
const own = withSynced(note<Note<"deck">>("deck", { game: "[[Magic]]", cover: "[[deck.jpg]]" }, { commanderArt: "https://cards.scryfall.io/a.jpg" }));
|
||||
expect(deckArt(own)).toBe("/media/deck.jpg");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user