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:
Claude
2026-10-11 23:50:06 +00:00
parent 60dfeecd7f
commit e3b0cf60c5
76 changed files with 6050 additions and 86 deletions
@@ -0,0 +1,22 @@
# Sync test fixtures
Small sample payloads written by hand from each API's documented response
shape, for the parsing tests. They are **not** live responses: no request was
made to Archidekt, BoardGameGeek, Open Library, TMDB, AniList, Suwayomi,
Kavita, Bambuddy or Homebox to make them,
and ids, names and image addresses are illustrative.
| File | Shape of |
| --- | --- |
| `archidekt-deck.sample.json` | `GET https://archidekt.com/api/decks/<id>/` |
| `bgg-thing.sample.xml` | `GET https://boardgamegeek.com/xmlapi2/thing?id=…` |
| `openlibrary-search.sample.json` | `GET https://openlibrary.org/search.json?title=…` |
| `tmdb-search.sample.json` | `GET https://api.themoviedb.org/3/search/movie?query=…` |
| `anilist-media.sample.json` | `POST https://graphql.anilist.co` with the `Media` query |
| `suwayomi-library.sample.json` | `POST <SUWAYOMI_URL>/api/graphql` with `LIBRARY_QUERY` (Suwayomi-Server's GraphQL schema) |
| `anilist-page.sample.json` | `POST https://graphql.anilist.co` with the batched `Page { media(id_in:) }` query |
| `kavita-series.sample.json` | `POST <KAVITA_URL>/api/Series/all-v2` (SeriesDto list) |
| `kavita-metadata.sample.json` | `GET <KAVITA_URL>/api/Series/metadata?seriesId=`, one answer per series id |
| `bambuddy-spools.sample.json` | **A guess.** Bambuddy's API is not documented anywhere this project has; this only exercises `mapBambuddySpools`' tolerant reading. |
| `homebox-labels.sample.json` | `GET <HOMEBOX_URL>/api/v1/labels` |
| `homebox-items.sample.json` | `GET <HOMEBOX_URL>/api/v1/items?labels=…` |
@@ -0,0 +1,13 @@
{
"_sample": "Hand-written from AniList's GraphQL Media response shape; not a live response.",
"data": {
"Media": {
"id": 30013,
"title": { "english": "One Piece", "romaji": "ONE PIECE" },
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/sample-xl.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/sample-l.jpg"
}
}
}
}
@@ -0,0 +1,15 @@
{
"data": {
"Page": {
"media": [
{ "id": 100001, "title": { "english": "Sample Tower Climber", "romaji": "Sample Tou" }, "format": "MANGA", "countryOfOrigin": "KR",
"coverImage": { "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/sample-100001.jpg", "large": null } },
{ "id": 100003, "title": { "english": null, "romaji": "Sample Owari" }, "format": "MANGA", "countryOfOrigin": "JP",
"coverImage": { "extraLarge": null, "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/sample-100003.jpg" } },
{ "id": 100004, "title": { "english": "New Sample Series", "romaji": "Shin Sample" }, "format": "MANGA", "countryOfOrigin": "KR",
"coverImage": { "extraLarge": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/sample-100004.jpg" } },
{ "id": 100005, "title": { "english": "Sample Dropped" }, "format": "MANGA", "countryOfOrigin": "JP", "coverImage": {} }
]
}
}
}
@@ -0,0 +1,39 @@
{
"_sample": "Hand-written from Archidekt's deck response shape; not a live response.",
"id": 1234567,
"name": "Sample Atraxa Superfriends",
"deckFormat": 3,
"featured": "https://storage.googleapis.com/topdekt-user/images/sample-featured.jpg",
"customFeatured": "",
"private": false,
"categories": [
{ "id": 1, "name": "Commander", "isPremier": true, "includedInDeck": true, "includedInPrice": true },
{ "id": 2, "name": "Ramp", "isPremier": false, "includedInDeck": true, "includedInPrice": true },
{ "id": 3, "name": "Maybeboard", "isPremier": false, "includedInDeck": false, "includedInPrice": false }
],
"cards": [
{
"quantity": 1,
"categories": ["Commander"],
"card": {
"uid": "d0d33d52-3d28-4635-b985-51e126289259",
"oracleCard": { "name": "Atraxa, Praetors' Voice", "colorIdentity": ["White", "Blue", "Black", "Green"] }
}
},
{
"quantity": 1,
"categories": ["Ramp"],
"card": { "uid": "aaaa1111-0000-0000-0000-000000000000", "oracleCard": { "name": "Sol Ring", "colorIdentity": [] } }
},
{
"quantity": 34,
"categories": ["Land"],
"card": { "uid": "bbbb2222-0000-0000-0000-000000000000", "oracleCard": { "name": "Forest", "colorIdentity": ["Green"] } }
},
{
"quantity": 1,
"categories": ["Maybeboard"],
"card": { "uid": "cccc3333-0000-0000-0000-000000000000", "oracleCard": { "name": "Lightning Bolt", "colorIdentity": ["Red"] } }
}
]
}
@@ -0,0 +1,11 @@
{
"_comment": "GUESSED SHAPE. Bambuddy's API is undocumented here; this only exercises mapBambuddySpools' tolerant reading.",
"items": [
{ "id": 1, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DCFF", "remaining_weight": 640 },
{ "id": 2, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DC", "remaining_weight": 20 },
{ "id": 3, "material": "PLA", "colorName": "Bambu Green", "colorHex": "#00AE42", "remainingWeight": 15 },
{ "id": 4, "material": "PETG", "color": "Translucent Orange", "hex": "#FF6A13", "remaining": 900 },
{ "id": 5, "material": "PLA", "color_name": "Old Spool", "archived": true, "remaining_weight": 500 },
{ "id": 6, "material": "PLA", "color_name": "Charcoal", "color_hex": "#2b2b2b" }
]
}
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hand-written from BoardGameGeek's XML API2 "thing" response shape; not a live response. -->
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="266192">
<thumbnail>https://cf.geekdo-images.com/sample__thumb/img/wingspan.jpg</thumbnail>
<image>https://cf.geekdo-images.com/sample__original/img/wingspan.jpg</image>
<name type="primary" sortindex="1" value="Wingspan" />
<description>Attract a beautiful and diverse collection of birds &amp;amp; more.</description>
<yearpublished value="2019" />
<minplayers value="1" />
<maxplayers value="5" />
<playingtime value="70" />
<minplaytime value="40" />
<maxplaytime value="70" />
<minage value="10" />
</item>
<item type="boardgame" id="13">
<image>http://cf.geekdo-images.com/sample__original/img/catan.jpg?a=1&amp;b=2</image>
<name type="primary" sortindex="1" value="CATAN" />
<minplayers value="3" />
<maxplayers value="4" />
<playingtime value="120" />
<minplaytime value="60" />
<maxplaytime value="120" />
</item>
<item type="boardgame" id="99">
<name type="primary" sortindex="1" value="No Data Yet" />
<minplayers value="0" />
<maxplayers value="0" />
<playingtime value="0" />
</item>
</items>
@@ -0,0 +1,10 @@
{
"page": 1,
"pageSize": 100,
"total": 3,
"items": [
{ "id": "a1", "name": "Veg tan, Walnut", "quantity": 2, "description": "Swatch #5c3a21, 3-4 oz", "archived": false },
{ "id": "a2", "name": "Chrome tan, Black", "quantity": 0, "description": "", "archived": false },
{ "id": "a3", "name": "Retired hide", "quantity": 4, "description": "", "archived": true }
]
}
@@ -0,0 +1,5 @@
[
{ "id": "6f1c0a52-0000-4000-8000-000000000001", "name": "Leather", "description": "" },
{ "id": "6f1c0a52-0000-4000-8000-000000000002", "name": "Paper", "description": "" },
{ "id": "6f1c0a52-0000-4000-8000-000000000003", "name": "Tools", "description": "" }
]
@@ -0,0 +1,5 @@
{
"11": { "seriesId": 11, "publicationStatus": 0, "webLinks": "https://www.novelupdates.com/series/sample/,https://anilist.co/manga/200001/Sample-Light-Novel/" },
"12": { "seriesId": 12, "publicationStatus": 0, "webLinks": "" },
"13": { "seriesId": 13, "publicationStatus": 2, "webLinks": "https://anilist.co/manga/200003" }
}
@@ -0,0 +1,5 @@
[
{ "id": 11, "name": "Sample Light Novel", "localizedName": "", "pages": 400, "pagesRead": 400, "latestReadDate": "2026-10-02T21:15:00", "libraryId": 2 },
{ "id": 12, "name": "Sample Ongoing Novel", "localizedName": "Sample Ongoing Novel (EN)", "pages": 300, "pagesRead": 120, "latestReadDate": "2026-10-09T08:00:00", "libraryId": 2 },
{ "id": 13, "name": "Sample Unread Novel", "pages": 250, "pagesRead": 0, "latestReadDate": "0001-01-01T00:00:00", "libraryId": 2 }
]
@@ -0,0 +1,8 @@
{
"_sample": "Hand-written from Open Library's search.json response shape; not a live response.",
"numFound": 2,
"docs": [
{ "title": "The Way of Kings (study guide)" },
{ "title": "The Way of Kings", "cover_i": 8221255, "cover_edition_key": "OL24395542M" }
]
}
@@ -0,0 +1,42 @@
{
"data": {
"categories": {
"nodes": [
{ "name": "Default", "mangas": { "nodes": [
{ "title": "Uncategorised Series", "inLibrary": true, "latestReadChapter": null, "trackRecords": { "nodes": [] } }
] } },
{ "name": "Reading", "mangas": { "nodes": [
{ "title": "Sample Tower Climber (Source Title)", "inLibrary": true,
"latestReadChapter": { "chapterNumber": 142, "lastReadAt": "1759900000" },
"trackRecords": { "nodes": [
{ "trackerId": 1, "remoteId": "555", "title": "Sample Tower Climber MAL", "lastChapterRead": 140 },
{ "trackerId": 2, "remoteId": "100001", "title": "Sample Tower Climber", "lastChapterRead": 142 }
] } },
{ "title": "New Sample Series", "inLibrary": true,
"latestReadChapter": { "chapterNumber": 3, "lastReadAt": "1759990000" },
"trackRecords": { "nodes": [ { "trackerId": 2, "remoteId": "100004", "title": "New Sample Series", "lastChapterRead": 3 } ] } },
{ "title": "Untracked Sample", "inLibrary": true,
"latestReadChapter": { "chapterNumber": 7, "lastReadAt": "0" },
"trackRecords": { "nodes": [] } }
] } },
{ "name": "Plan to read", "mangas": { "nodes": [
{ "title": "Sample Later", "inLibrary": true, "latestReadChapter": null,
"trackRecords": { "nodes": [ { "trackerId": 2, "remoteId": "100002", "title": "Sample Later", "lastChapterRead": 0 } ] } }
] } },
{ "name": "Completed", "mangas": { "nodes": [
{ "title": "Sample Tower Climber (Source Title)", "inLibrary": true,
"latestReadChapter": { "chapterNumber": 142, "lastReadAt": "1759900000" },
"trackRecords": { "nodes": [ { "trackerId": 2, "remoteId": "100001", "title": "Sample Tower Climber", "lastChapterRead": 142 } ] } },
{ "title": "Sample Finished", "inLibrary": true,
"latestReadChapter": { "chapterNumber": 88.5, "lastReadAt": "1750000000000" },
"trackRecords": { "nodes": [ { "trackerId": 2, "remoteId": "100003", "title": "Sample Finished", "lastChapterRead": 88 } ] } }
] } },
{ "name": "Dropped", "mangas": { "nodes": [
{ "title": "Sample Dropped", "inLibrary": true,
"latestReadChapter": { "chapterNumber": 12, "lastReadAt": "1740000000" },
"trackRecords": { "nodes": [ { "trackerId": 2, "remoteId": "100005", "title": "Sample Dropped", "lastChapterRead": 12 } ] } }
] } }
]
}
}
}
@@ -0,0 +1,9 @@
{
"_sample": "Hand-written from TMDB's search/movie response shape; not a live response.",
"page": 1,
"results": [
{ "id": 1, "title": "Arrival (short)", "poster_path": null },
{ "id": 329865, "title": "Arrival", "release_date": "2016-11-10", "poster_path": "/x2FJsf1ElAgr63Y3PNPtJrcmpoe.jpg" }
],
"total_results": 2
}
+410
View File
@@ -0,0 +1,410 @@
/**
* The homelab jobs (Suwayomi, Kavita, Bambuddy, Homebox) and Library
* suggestions, against the hand-written sample payloads in ./fixtures. Those
* are documented shapes (Bambuddy's is a guess), never live responses; see
* fixtures/README.md.
*/
import { readFileSync } from "node:fs";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { parseTokens } from "../../src/config.ts";
import { createHttpServer } from "../../src/http.ts";
import { listSuggestions } from "../../src/suggestions.ts";
import { bambuddyJob, filamentOptions, mapBambuddySpools } from "../../src/sync/jobs/bambuddy.ts";
import { homelabSettings } from "../../src/sync/jobs/homelab.ts";
import { homeboxJob, materialOptions, parseFilters, parseItems } from "../../src/sync/jobs/homebox.ts";
import { kavitaJob, kavitaState, parseMetadata, parseSeries } from "../../src/sync/jobs/kavita.ts";
import { stateForCategory, targetState } from "../../src/sync/jobs/reader.ts";
import { ANILIST_TRACKER_ID, parseAniListBatch, parseLibrary, suwayomiJob } from "../../src/sync/jobs/suwayomi.ts";
import { enabledJobs, syncSettings } from "../../src/sync/index.ts";
import { Scheduler } from "../../src/sync/scheduler.ts";
import type { SyncJob } from "../../src/sync/types.ts";
import { rig, type Rig } from "../helpers.ts";
const fixture = (name: string) => JSON.parse(readFileSync(join(import.meta.dirname, "fixtures", name), "utf8"));
const ok = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
type Call = { url: string; init?: RequestInit };
function fakeFetch(answer: (url: string, init?: RequestInit) => Response) {
const calls: Call[] = [];
const fn = (async (input: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(input), init });
return answer(String(input), init);
}) as typeof fetch;
return { fn, calls };
}
const headerOf = (c: Call, k: string) => (c.init?.headers as Record<string, string> | undefined)?.[k];
const bodyOf = (c: Call) => JSON.parse(String(c.init?.body ?? "{}"));
let r: Rig;
beforeEach(() => {
r = rig();
});
afterEach(() => r.done());
const scheduler = (job: SyncJob, fetchFn: typeof fetch) =>
new Scheduler(r.service, [job], { fetch: fetchFn, now: () => new Date(r.clock.t), log: () => {} });
const manga = (title: string, extra: Record<string, unknown> = {}) => ({
type: "library-item",
title,
medium: "manhwa",
state: "up next",
publish: true,
...extra,
});
// ---------------------------------------------------------------- state mapping
describe("PersonalReader state mapping", () => {
it("maps categories: Reading now, Plan to read up next, Completed finished, Dropped left alone", () => {
expect(stateForCategory("Reading")).toBe("now");
expect(stateForCategory(" plan to Read ")).toBe("up next");
expect(stateForCategory("Completed")).toBe("finished");
expect(stateForCategory("Dropped")).toBeNull();
expect(stateForCategory("Default")).toBeUndefined();
});
it("proposes only a different state, and keeps caught up and finished", () => {
expect(targetState("up next", "now")).toBe("now");
expect(targetState("now", "now")).toBeNull();
expect(targetState("now", null)).toBeNull();
expect(targetState("caught up", "now")).toBeNull();
expect(targetState("finished", "caught up")).toBeNull();
expect(targetState("dropped", "finished")).toBe("finished");
});
});
// ---------------------------------------------------------------- Suwayomi
describe("Suwayomi", () => {
it("parses the library: AniList tracker id, furthest chapter, when, best category", () => {
const s = parseLibrary(fixture("suwayomi-library.sample.json"));
const by = (t: string) => s.find((x) => x.title === t);
// In Reading and Completed: Reading wins. The AniList record's title is used, not the source's.
expect(by("Sample Tower Climber")).toEqual({ title: "Sample Tower Climber", category: "Reading", anilistId: 100001, chapter: 142, readAt: "2025-10-08T05:06:40.000Z" });
expect(by("Sample Finished")).toMatchObject({ category: "Completed", anilistId: 100003, chapter: 88.5, readAt: new Date(1750000000000).toISOString() });
expect(by("Untracked Sample")).toEqual({ title: "Untracked Sample", category: "Reading", chapter: 7 });
expect(by("Uncategorised Series")).toBeUndefined();
expect(s).toHaveLength(6);
expect(ANILIST_TRACKER_ID).toBe(2);
expect(() => parseLibrary({ errors: [{ message: "Unauthorized" }] })).toThrow(/Unauthorized/);
});
it("parses AniList's batch: cover, title and medium", () => {
const m = parseAniListBatch(fixture("anilist-page.sample.json"));
expect(m.get(100001)).toEqual({ title: "Sample Tower Climber", medium: "manhwa", cover: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/sample-100001.jpg" });
expect(m.get(100003)).toMatchObject({ title: "Sample Owari", medium: "manga" });
expect(m.get(100005)?.cover).toBeUndefined();
});
function suwayomiFetch() {
return fakeFetch((url, init) => {
if (url === "https://graphql.anilist.co") return ok(fixture("anilist-page.sample.json"));
const q = String(JSON.parse(String(init?.body)).query);
if (q.includes("login")) return ok({ data: { login: { accessToken: "jwt-access" } } });
return ok(fixture("suwayomi-library.sample.json"));
});
}
it("logs in (ui_login), caches progress and cover, writes states as sync, holds Greg's notes, suggests", async () => {
// A note the sync wrote earlier (so it may change it) and notes Greg wrote.
const created = r.service.apply({ op: "create", data: manga("Sample Tower Climber", { anilistId: 100001, publish: true }) }, "sync");
expect(created.status).toBe("accepted");
const later = r.put(manga("Sample Later", { anilistId: 100002, state: "now" }));
const finished = r.put(manga("Sample Finished", { medium: "manga", anilistId: 100003, state: "now" }));
const dropped = r.put(manga("Sample Dropped", { medium: "manga", anilistId: 100005, state: "now" }));
r.service.scan();
const f = suwayomiFetch();
const job = suwayomiJob({ url: "http://suwayomi:4567/", user: "site-reader", pass: "pw" });
const report = await scheduler(job, f.fn).runNow("suwayomi");
expect(report).toMatchObject({ ok: true, counts: { series: 6, matched: 4, untracked: 1, accepted: 1, held: 2, suggestions: 1 } });
// Login, then the library with the access token.
expect(f.calls[0]!.url).toBe("http://suwayomi:4567/api/graphql");
expect(bodyOf(f.calls[0]!).variables).toEqual({ username: "site-reader", password: "pw" });
expect(headerOf(f.calls[1]!, "authorization")).toBe("Bearer jwt-access");
// The sync's own note changed; Greg's are held for review; Dropped is untouched.
const tower = created.status === "accepted" ? created.path : "";
expect(r.store.get(tower)!.data).toMatchObject({ state: "now", updatedBy: "sync" });
expect(r.store.get(later)!.data.state).toBe("now");
expect(r.store.pendingFor(later)?.reasons.map((x) => x.code)).toContain("agent-edit");
expect(r.store.pendingFor(finished)?.text).toContain("state: finished");
expect(r.store.pendingFor(dropped)).toBeNull();
// Cached for the site: title, chapter, status, when, AniList cover. Nothing else.
const synced = r.store.syncCache.forPath(tower);
expect(synced).toEqual({
chapterProgress: { title: "Sample Tower Climber", status: "now", chapter: 142, readAt: "2025-10-08T05:06:40.000Z" },
cover: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/sample-100001.jpg",
});
const everything = JSON.stringify([...r.store.syncCache.all()]);
expect(everything).not.toMatch(/suwayomi|Source Title|4567|thumbnail|scanlator/i);
// The new Reading series is a suggestion; the untracked one cannot be (no AniList id).
expect(listSuggestions(r.store)).toEqual([
{
key: "anilist-100004",
anilistId: 100004,
title: "New Sample Series",
medium: "manhwa",
cover: "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/sample-100004.jpg",
},
]);
});
it("proposes a held change once: not again while it waits, nor after Greg rejects it", async () => {
const later = r.put(manga("Sample Later", { anilistId: 100002, state: "now" }));
r.service.scan();
const s = scheduler(suwayomiJob({ url: "http://suwayomi:4567", auth: "none" }), suwayomiFetch().fn);
expect((await s.runNow("suwayomi")).counts).toMatchObject({ held: 1 });
expect((await s.runNow("suwayomi")).counts).toMatchObject({ held: 0, waiting: 0, accepted: 0 });
r.service.rejectHeld(r.store.pendingFor(later)!.id);
expect((await s.runNow("suwayomi")).counts).toMatchObject({ held: 0 });
expect(r.store.pendingFor(later)).toBeNull();
expect(r.store.get(later)!.data.state).toBe("now");
});
it("keeps the last cover when AniList is down, and reports the run as failed", async () => {
r.service.apply({ op: "create", data: manga("Sample Tower Climber", { anilistId: 100001, state: "now" }) }, "sync");
const good = suwayomiFetch();
await scheduler(suwayomiJob({ url: "http://s", auth: "none" }), good.fn).runNow("suwayomi");
const down = fakeFetch((url) => (url.includes("anilist") ? ok({}, 503) : ok(fixture("suwayomi-library.sample.json"))));
const report = await scheduler(suwayomiJob({ url: "http://s", auth: "none" }), down.fn).runNow("suwayomi");
expect(report).toMatchObject({ ok: false, message: expect.stringContaining("AniList answered 503") });
const [path] = [...r.store.syncCache.all().keys()].filter((p) => p.endsWith(".md"));
expect(r.store.syncCache.forPath(path!).cover).toMatch(/sample-100001/);
});
it("uses basic auth when set, and fails the run on a refused login", async () => {
const f = fakeFetch(() => ok(fixture("suwayomi-library.sample.json")));
await scheduler(suwayomiJob({ url: "http://s", user: "u", pass: "p", auth: "basic" }), f.fn).runNow("suwayomi");
expect(headerOf(f.calls[0]!, "authorization")).toBe(`Basic ${Buffer.from("u:p").toString("base64")}`);
const no = fakeFetch(() => ok({ data: { login: null }, errors: [{ message: "Invalid credentials" }] }));
const report = await scheduler(suwayomiJob({ url: "http://s", user: "u", pass: "bad" }), no.fn).runNow("suwayomi");
expect(report).toMatchObject({ ok: false, message: expect.stringContaining("Invalid credentials") });
});
});
// ---------------------------------------------------------------- suggestions
describe("Library suggestions", () => {
it("are listed, dismissed for good, and leave once a Library item has the AniList id", async () => {
await scheduler(suwayomiJob({ url: "http://s", auth: "none" }), fakeFetch((url) =>
ok(url.includes("anilist") ? fixture("anilist-page.sample.json") : fixture("suwayomi-library.sample.json")),
).fn).runNow("suwayomi");
// Every Reading series with an AniList id and no note: 100001 and 100004.
const ADMIN = "a".repeat(24);
const READER = "r".repeat(24);
const server = createHttpServer(r.service, { tokens: parseTokens(`admin:${ADMIN},reader:${READER}`) });
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const get = (token = ADMIN) => fetch(`${base}/api/suggestions`, { headers: { authorization: `Bearer ${token}` } });
try {
expect((await get(READER)).status).toBe(403);
const list = (await (await get()).json()) as { key: string; anilistId: number }[];
// By title: New Sample Series, Sample Tower Climber.
expect(list.map((s) => s.key)).toEqual(["anilist-100004", "anilist-100001"]);
// Dismiss one.
const dis = await fetch(`${base}/api/suggestions/anilist-100004/dismiss`, { method: "POST", headers: { authorization: `Bearer ${ADMIN}` } });
expect(dis.status).toBe(200);
// Add the other through the normal write path.
const add = await fetch(`${base}/api/changes`, {
method: "POST",
headers: { authorization: `Bearer ${ADMIN}`, "content-type": "application/json" },
body: JSON.stringify({ op: "create", data: { type: "library-item", title: "Sample Tower Climber", medium: "manhwa", anilistId: 100001, state: "now", publish: true } }),
});
expect(((await add.json()) as { status: string }).status).toBe("accepted");
expect(await (await get()).json()).toEqual([]);
} finally {
server.close();
}
});
});
// ---------------------------------------------------------------- Kavita
describe("Kavita", () => {
const series = fixture("kavita-series.sample.json") as unknown[];
const metadata = fixture("kavita-metadata.sample.json") as Record<string, unknown>;
it("parses series and metadata; AniList only from an explicit anilist.co link", () => {
expect(parseSeries(series[1])).toEqual({ id: 12, name: "Sample Ongoing Novel (EN)", pages: 300, pagesRead: 120, latestReadDate: new Date("2026-10-09T08:00:00").toISOString() });
expect(parseSeries(series[2])).not.toHaveProperty("latestReadDate");
expect(parseMetadata(metadata["11"])).toEqual({ anilistId: 200001, publicationStatus: 0 });
expect(parseMetadata(metadata["12"])).toEqual({ publicationStatus: 0 });
});
it("maps progress to a state", () => {
const s = (pages: number, pagesRead: number) => ({ id: 1, name: "x", pages, pagesRead });
expect(kavitaState(s(10, 10), { publicationStatus: 2 }, false)).toBe("finished");
expect(kavitaState(s(10, 10), { publicationStatus: 0 }, false)).toBe("caught up");
expect(kavitaState(s(10, 3), {}, false)).toBe("now");
expect(kavitaState(s(10, 0), {}, true)).toBe("up next");
expect(kavitaState(s(10, 0), {}, false)).toBeNull();
});
it("links by kavitaSeriesId or by AniList id in metadata, never by title", async () => {
const byId = r.put({ type: "library-item", title: "Sample Ongoing Novel", medium: "light novel", state: "up next", kavitaSeriesId: 12, publish: true });
const byAniList = r.service.apply(
{ op: "create", data: { type: "library-item", title: "Sample Light Novel", medium: "light novel", state: "now", anilistId: 200001 } },
"sync",
);
const sameTitle = r.put({ type: "library-item", title: "Sample Unread Novel", medium: "light novel", state: "now", publish: true });
r.service.scan();
const f = fakeFetch((url) => {
if (url.includes("/api/Plugin/authenticate")) return ok({ username: "greg", token: "kavita-jwt" });
if (url.includes("/api/Series/all-v2")) return ok(series);
if (url.includes("/api/Series/metadata")) return ok(metadata[new URL(url).searchParams.get("seriesId")!]);
if (url.endsWith("/api/Series/12")) return ok(series[1]);
if (url.includes("/api/want-to-read/v2")) return ok([series[2]]);
return ok({}, 404);
});
const report = await scheduler(kavitaJob({ url: "http://kavita:5000", apiKey: "key-123", delayMs: 0 }), f.fn).runNow("kavita");
expect(report).toMatchObject({ ok: true, counts: { linked: 2, synced: 2, accepted: 1, held: 1 } });
expect(f.calls[0]!.url).toBe("http://kavita:5000/api/Plugin/authenticate?apiKey=key-123&pluginName=GregKilmer.com");
expect(headerOf(f.calls[1]!, "authorization")).toBe("Bearer kavita-jwt");
// Read to the end of an ongoing series: caught up, written as sync.
const lnPath = byAniList.status === "accepted" ? byAniList.path : "";
expect(r.store.get(lnPath)!.data).toMatchObject({ state: "caught up", updatedBy: "sync" });
expect(r.store.syncCache.forPath(lnPath)).toEqual({ bookProgress: { title: "Sample Light Novel", percent: 100, readAt: new Date("2026-10-02T21:15:00").toISOString() } });
// Greg's note: partly read is now, held for review.
expect(r.store.pendingFor(byId)?.text).toContain("state: now");
// Same title, no link: untouched and nothing cached.
expect(r.store.syncCache.forPath(sameTitle)).toEqual({});
expect(r.store.pendingFor(sameTitle)).toBeNull();
});
});
// ---------------------------------------------------------------- Bambuddy
describe("Bambuddy (unverified API shape)", () => {
it("maps spools to colors: merged by name, enough grams is in stock, archived skipped", () => {
const spools = mapBambuddySpools(fixture("bambuddy-spools.sample.json"));
expect(spools.map((s) => s.name)).toEqual(["Jade White", "Jade White", "Bambu Green", "Translucent Orange", "Charcoal"]);
expect(filamentOptions(spools)).toEqual([
{ name: "Charcoal", hex: "#2b2b2b", inStock: true },
{ name: "Jade White", hex: "#f5f5dc", inStock: true },
{ name: "Translucent Orange", hex: "#ff6a13", inStock: true },
{ name: "Bambu Green", hex: "#00ae42", inStock: false },
]);
expect(filamentOptions(spools, { materials: ["PETG"] }).map((o) => o.name)).toEqual(["Translucent Orange"]);
expect(mapBambuddySpools([{ name: "Plain list" }])).toEqual([{ name: "Plain list" }]);
expect(() => mapBambuddySpools({ nothing: true })).toThrow(/no list/);
});
it("stores filamentStock on lines with a Filament option, at the configured path", async () => {
const line = (title: string, group: string) => ({
type: "product-line",
title,
category: "Tabletop",
mode: "made to order",
channel: "here",
summary: "s",
slots: { open: 1, total: 2 },
priceTiers: [],
options: [{ group, name: "Jade White", inStock: true }],
publish: true,
});
const printed = r.put(line("Printed terrain", "Filament"));
const cards = r.put(line("Cards", "Finish"));
r.service.scan();
const f = fakeFetch(() => ok(fixture("bambuddy-spools.sample.json")));
const report = await scheduler(bambuddyJob({ url: "http://bambuddy:8000/", path: "/api/v1/spools", apiKey: "k" }), f.fn).runNow("bambuddy");
expect(report).toMatchObject({ ok: true, counts: { colors: 4, inStock: 3, lines: 1 } });
expect(f.calls[0]!.url).toBe("http://bambuddy:8000/api/v1/spools");
expect(headerOf(f.calls[0]!, "authorization")).toBe("Bearer k");
expect(r.store.syncCache.forPath(printed).filamentStock).toEqual([{ group: "Filament", options: expect.arrayContaining([{ name: "Jade White", hex: "#f5f5dc", inStock: true }]) }]);
expect(r.store.syncCache.forPath(cards)).toEqual({});
// Notes are never written.
expect(r.store.get(printed)!.author).toBe("greg");
});
});
// ---------------------------------------------------------------- Homebox
describe("Homebox", () => {
it("parses filters, items and materials", () => {
expect(parseFilters("leather, paper=Paper goods")).toEqual([
{ name: "leather", group: "Leather" },
{ name: "paper", group: "Paper goods" },
]);
const { items, total } = parseItems(fixture("homebox-items.sample.json"));
expect(total).toBe(3);
expect(materialOptions(items)).toEqual([
{ name: "Veg tan, Walnut", hex: "#5c3a21", inStock: true },
{ name: "Chrome tan, Black", inStock: false },
]);
});
it("logs in, filters by label and stores materialStock on Leather lines", async () => {
const wallet = r.put({
type: "product-line",
title: "Wallet",
category: "Leather",
mode: "made to order",
channel: "here",
summary: "s",
slots: { open: 1, total: 2 },
priceTiers: [],
options: [{ group: "Leather", name: "Chrome tan, Black" }],
publish: true,
});
r.service.scan();
const f = fakeFetch((url) => {
if (url.endsWith("/api/v1/users/login")) return ok({ token: "Bearer hb-token", expiresAt: "2026-10-12T00:00:00Z" });
if (url.endsWith("/api/v1/labels")) return ok(fixture("homebox-labels.sample.json"));
if (url.includes("/api/v1/items?")) {
return new URL(url).searchParams.get("labels")?.endsWith("01") ? ok(fixture("homebox-items.sample.json")) : ok({ items: [], total: 0 });
}
return ok({}, 404);
});
const report = await scheduler(homeboxJob({ url: "http://homebox:7745", user: "u", pass: "p", labels: parseFilters("leather,paper") }), f.fn).runNow("homebox");
expect(report).toMatchObject({ ok: true, counts: { lines: 1, leather: 1, paper: 0 } });
expect(headerOf(f.calls[2]!, "authorization")).toBe("Bearer hb-token");
expect(r.store.syncCache.forPath(wallet).materialStock).toEqual([
{ group: "Leather", options: [{ name: "Veg tan, Walnut", hex: "#5c3a21", inStock: true }, { name: "Chrome tan, Black", inStock: false }] },
]);
const missing = await scheduler(homeboxJob({ url: "http://homebox:7745", user: "u", pass: "p", labels: parseFilters("vinyl") }), f.fn).runNow("homebox");
expect(missing).toMatchObject({ ok: false, message: expect.stringContaining("vinyl") });
});
});
// ---------------------------------------------------------------- settings
describe("homelab settings", () => {
it("enable each job by its own URL and login, not by SYNC_JOBS", () => {
expect(enabledJobs(syncSettings({})).map((j) => j.name)).toEqual([]);
const env = {
SUWAYOMI_URL: "http://suwayomi:4567",
SUWAYOMI_USER: "u",
SUWAYOMI_PASS: "p",
KAVITA_URL: "http://kavita:5000",
KAVITA_API_KEY: "k",
BAMBUDDY_URL: "http://bambuddy:8000",
HOMEBOX_URL: "http://homebox:7745",
HOMEBOX_USER: "u",
HOMEBOX_PASS: "p",
SYNC_KAVITA_MINUTES: "720",
SYNC_SUWAYOMI_HEALTHCHECK_URL: "https://hc/s",
};
const jobs = enabledJobs(syncSettings(env));
expect(jobs.map((j) => [j.name, j.everyMinutes])).toEqual([
["suwayomi", 60],
["kavita", 720],
["bambuddy", 60],
["homebox", 60],
]);
expect(jobs[0]!.healthcheckUrl).toBe("https://hc/s");
expect(homelabSettings(env).homebox?.labels).toEqual(parseFilters("leather,paper"));
expect(homelabSettings({ KAVITA_URL: "http://k" }).kavita).toBeUndefined();
expect(() => homelabSettings({ SUWAYOMI_URL: "http://s", SUWAYOMI_AUTH: "simple_login" })).toThrow(/SUWAYOMI_AUTH/);
});
});
@@ -0,0 +1,204 @@
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseTokens } from "../../src/config.ts";
import { createHttpServer } from "../../src/http.ts";
import { Scheduler } from "../../src/sync/scheduler.ts";
import { syncSettings } from "../../src/sync/settings.ts";
import { enabledJobs } from "../../src/sync/index.ts";
import type { SyncContext, SyncJob, SyncResult } from "../../src/sync/types.ts";
import { book, rig, type Rig } from "../helpers.ts";
let r: Rig;
beforeEach(() => {
r = rig();
});
afterEach(() => {
vi.useRealTimers();
r.done();
});
const pings: string[] = [];
const fakeFetch = (async (url: string | URL | Request) => {
pings.push(String(url));
return new Response("OK");
}) as typeof fetch;
const job = (name: string, run: (ctx: SyncContext) => Promise<SyncResult>, extra: Partial<SyncJob> = {}): SyncJob => ({
name,
everyMinutes: 60,
run,
...extra,
});
const deferred = () => {
let resolve!: (v: SyncResult) => void;
const promise = new Promise<SyncResult>((ok) => (resolve = ok));
return { promise, resolve };
};
describe("scheduler", () => {
it("reports each run to sync_runs and pings Healthchecks start, success and fail", async () => {
pings.length = 0;
let fail = false;
const s = new Scheduler(
r.service,
[
job("demo", async () => {
if (fail) throw new Error("source down");
return { message: "2 synced", counts: { synced: 2 } };
}, { healthcheckUrl: "https://hc-ping.com/abc/" }),
],
{ fetch: fakeFetch, log: () => {} },
);
expect(await s.runNow("demo")).toMatchObject({ ok: true, message: "2 synced" });
fail = true;
expect(await s.runNow("demo")).toMatchObject({ ok: false, message: "source down" });
expect(r.store.syncRuns("demo").map((x) => [x.ok, x.message])).toEqual([
[false, "source down"],
[true, "2 synced"],
]);
expect(r.store.syncJobs()[0]).toMatchObject({ job: "demo", ok: false, counts: {} });
expect(pings).toEqual(["https://hc-ping.com/abc/start", "https://hc-ping.com/abc", "https://hc-ping.com/abc/start", "https://hc-ping.com/abc/fail"]);
});
it("never runs one job twice at once", async () => {
const d = deferred();
let runs = 0;
const s = new Scheduler(r.service, [job("slow", () => (runs++, d.promise))], { fetch: fakeFetch, log: () => {} });
const first = s.runNow("slow");
expect(await s.runNow("slow")).toMatchObject({ skipped: true });
expect(runs).toBe(1);
d.resolve({});
await first;
await Promise.resolve();
expect(s.isRunning("slow")).toBe(false);
});
it("aborts a run that passes its timeout and stays locked until the job really ends", async () => {
vi.useFakeTimers();
const d = deferred();
let aborted = false;
const s = new Scheduler(
r.service,
[
job("stuck", (ctx) => {
ctx.signal.addEventListener("abort", () => (aborted = true));
return d.promise;
}, { timeoutMs: 1000 }),
],
{ fetch: fakeFetch, log: () => {} },
);
const run = s.runNow("stuck");
await vi.advanceTimersByTimeAsync(1001);
expect(await run).toMatchObject({ ok: false, message: "Timed out after 1 seconds" });
expect(aborted).toBe(true);
expect(s.isRunning("stuck")).toBe(true);
d.resolve({});
await vi.advanceTimersByTimeAsync(0);
expect(s.isRunning("stuck")).toBe(false);
});
it("runs each job at start, staggered, then on its interval", async () => {
vi.useFakeTimers();
const ran: string[] = [];
const mk = (name: string) => job(name, async () => (ran.push(name), {}), { everyMinutes: 10 });
const s = new Scheduler(r.service, [mk("a"), mk("b")], { staggerMs: 1000, fetch: fakeFetch, log: () => {} });
s.start();
await vi.advanceTimersByTimeAsync(0);
expect(ran).toEqual(["a"]);
await vi.advanceTimersByTimeAsync(1000);
expect(ran).toEqual(["a", "b"]);
await vi.advanceTimersByTimeAsync(10 * 60_000);
expect(ran).toEqual(["a", "b", "a", "b"]);
s.stop();
await vi.advanceTimersByTimeAsync(60 * 60_000);
expect(ran).toHaveLength(4);
});
});
describe("sync cache", () => {
it("keeps the last good values when a run fails, and shows them on the notes API", async () => {
const path = r.put(book("Mistborn"));
r.service.scan();
let fail = false;
const s = new Scheduler(
r.service,
[
job("demo", async (ctx) => {
if (fail) throw new Error("down");
ctx.set(path, { cover: "https://example.com/a.jpg", pages: 541 });
return {};
}),
],
{ fetch: fakeFetch, log: () => {} },
);
await s.runNow("demo");
const first = r.store.syncCache.get("demo", path);
fail = true;
await s.runNow("demo");
expect(r.store.syncCache.get("demo", path)).toEqual(first);
// Values from several jobs merge per note.
r.store.syncCache.set("other", path, { year: 2006 }, "2026-10-11T00:00:00Z");
const token = "r".repeat(24);
const server = createHttpServer(r.service, { tokens: parseTokens(`reader:${token}`) });
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const get = (p: string) => fetch(base + p, { headers: { authorization: `Bearer ${token}` } }).then((x) => x.json());
try {
const [listed] = (await get("/api/notes?full=true")) as { synced: unknown }[];
expect(listed!.synced).toEqual({ cover: "https://example.com/a.jpg", pages: 541, year: 2006 });
expect(((await get(`/api/note?path=${encodeURIComponent(path)}`)) as { synced: unknown }).synced).toMatchObject({ pages: 541 });
expect(await get("/api/notes")).toEqual([expect.not.objectContaining({ synced: expect.anything() })]);
} finally {
server.close();
}
});
it("retain drops values for notes that left a job's scope", () => {
r.store.syncCache.set("j", "a.md", { x: 1 }, "t");
r.store.syncCache.set("j", "b.md", { x: 2 }, "t");
r.store.syncCache.set("k", "b.md", { y: 3 }, "t");
r.store.syncCache.retain("j", ["a.md"]);
expect([...r.store.syncCache.all().entries()]).toEqual([
["a.md", { x: 1 }],
["b.md", { y: 3 }],
]);
});
});
describe("writing notes as sync", () => {
it("runs the agent rules: a note Greg wrote is held unless it allows agent edits", async () => {
const mine = r.put(book("Mine", { progress: "1/10" }));
const open = r.put(book("Open", { progress: "1/10", agentEdits: true }));
r.service.scan();
const outcomes: string[] = [];
const s = new Scheduler(
r.service,
[
job("reader", async (ctx) => {
for (const p of [mine, open]) outcomes.push(ctx.apply({ op: "update", path: p, fields: { progress: "2/10" } }).status);
outcomes.push(ctx.apply({ op: "update", path: open, fields: { note: "Mail me at someone@example.com" } }).status);
return {};
}),
],
{ fetch: fakeFetch, log: () => {} },
);
await s.runNow("reader");
expect(outcomes).toEqual(["held", "accepted", "held"]);
expect(r.store.heldList()[0]!.reasons.map((x) => x.code)).toContain("contact");
expect(r.store.get(open)!.data).toMatchObject({ progress: "2/10", updatedBy: "sync" });
expect(r.store.get(open)!.author).toBe("sync");
});
});
describe("settings", () => {
it("enables only the jobs whose config is present", () => {
const s = syncSettings({ SYNC_JOBS: "bgg, covers", SYNC_BGG_MINUTES: "720", SYNC_COVERS_HEALTHCHECK_URL: "https://hc/x", BGG_TOKEN: "t" });
expect(enabledJobs(s).map((j) => [j.name, j.everyMinutes, j.healthcheckUrl])).toEqual([
["bgg", 720, undefined],
["covers", 15, "https://hc/x"],
]);
expect(enabledJobs(syncSettings({}))).toEqual([]);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Each source's parsing and its job, against the hand-written sample payloads
* in ./fixtures (documented shapes, not live responses; see fixtures/README.md).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { archidektJob, parseDeck, scryfallArt, type RawDeck } from "../../src/sync/jobs/archidekt.ts";
import { bggJob, parseThings } from "../../src/sync/jobs/bgg.ts";
import { coversJob, parseAniList, parseOpenLibrary, parseTmdb } from "../../src/sync/jobs/covers.ts";
import { Scheduler } from "../../src/sync/scheduler.ts";
import type { SyncJob } from "../../src/sync/types.ts";
import { book, rig, type Rig } from "../helpers.ts";
const fixture = (name: string) => readFileSync(join(import.meta.dirname, "fixtures", name), "utf8");
const json = (name: string) => JSON.parse(fixture(name));
type Call = { url: string; init?: RequestInit };
function fakeFetch(answer: (url: string, init?: RequestInit) => Response | Promise<Response>) {
const calls: Call[] = [];
const fn = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
calls.push({ url, init });
return answer(url, init);
}) as typeof fetch;
return { fn, calls };
}
const ok = (body: unknown, status = 200) =>
new Response(typeof body === "string" ? body : JSON.stringify(body), { status });
describe("Archidekt", () => {
it("parses commander art, color identity and a list summary", () => {
const v = parseDeck(json("archidekt-deck.sample.json") as RawDeck);
expect(v).toEqual({
deckUrl: "https://archidekt.com/decks/1234567",
commanderArt: scryfallArt("d0d33d52-3d28-4635-b985-51e126289259"),
colors: ["W", "U", "B", "G"],
commanders: ["Atraxa, Praetors' Voice"],
deckSize: 36,
format: "Commander",
});
expect(v.commanderArt).toBe("https://cards.scryfall.io/art_crop/front/d/0/d0d33d52-3d28-4635-b985-51e126289259.jpg");
});
it("falls back to the featured image and the whole list's colors without a commander", () => {
const v = parseDeck({
id: 5,
featured: "http://example.com/f.jpg",
cards: [{ quantity: 4, categories: ["Main"], card: { oracleCard: { name: "Bolt", colorIdentity: ["R"] } } }],
});
expect(v).toMatchObject({ commanderArt: "https://example.com/f.jpg", colors: ["R"], commanders: [], deckSize: 4 });
});
});
describe("BoardGameGeek", () => {
it("parses players, play time and box art per id", () => {
const m = parseThings(fixture("bgg-thing.sample.xml"));
expect(m.get(266192)).toEqual({
minPlayers: 1,
maxPlayers: 5,
playTime: 70,
minPlayTime: 40,
maxPlayTime: 70,
cover: "https://cf.geekdo-images.com/sample__original/img/wingspan.jpg",
});
expect(m.get(13)?.cover).toBe("https://cf.geekdo-images.com/sample__original/img/catan.jpg?a=1&b=2");
// Zeros mean BGG has no value: left out, so nothing wrong shows.
expect(m.get(99)).toEqual({});
});
});
describe("cover art", () => {
it("parses Open Library, TMDB and AniList answers", () => {
expect(parseOpenLibrary(json("openlibrary-search.sample.json"))).toBe("https://covers.openlibrary.org/b/id/8221255-L.jpg");
expect(parseTmdb(json("tmdb-search.sample.json"))).toBe("https://image.tmdb.org/t/p/w500/x2FJsf1ElAgr63Y3PNPtJrcmpoe.jpg");
expect(parseAniList(json("anilist-media.sample.json"))).toBe(
"https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/sample-xl.jpg",
);
expect(parseOpenLibrary({ docs: [] })).toBeNull();
expect(parseAniList({ data: { Media: null } })).toBeNull();
});
});
// ---------------------------------------------------------------- the jobs, end to end
let r: Rig;
beforeEach(() => {
r = rig();
});
afterEach(() => r.done());
const scheduler = (job: SyncJob, fetchFn: typeof fetch) =>
new Scheduler(r.service, [job], { fetch: fetchFn, now: () => new Date(r.clock.t), log: () => {} });
describe("jobs", () => {
it("Archidekt syncs each deck with an id and forgets decks that lose it", async () => {
r.put({ type: "card-game", title: "Magic", publish: true, stance: "Main event" });
const atraxa = r.put({ type: "deck", title: "Atraxa", game: "[[Magic]]", archidektId: 1234567, publish: true });
r.put({ type: "deck", title: "Paper only", game: "[[Magic]]", listUrl: "https://example.com/list", publish: true });
r.service.scan();
const f = fakeFetch(() => ok(fixture("archidekt-deck.sample.json")));
const report = await scheduler(archidektJob({ delayMs: 0 }), f.fn).runNow("archidekt");
expect(report).toMatchObject({ ok: true, counts: { decks: 1, synced: 1, failed: 0 } });
expect(f.calls.map((c) => c.url)).toEqual(["https://archidekt.com/api/decks/1234567/"]);
expect(r.store.syncCache.forPath(atraxa)).toMatchObject({ colors: ["W", "U", "B", "G"] });
});
it("BoardGameGeek batches 20 ids a request, sends the token and waits out a 202", async () => {
for (let i = 1; i <= 25; i++) r.put({ type: "board-game", title: `Game ${i}`, bggId: i === 1 ? 266192 : 1000 + i, blurb: "Fun.", publish: true });
r.service.scan();
let queued = true;
const f = fakeFetch(() => {
if (queued) {
queued = false;
return ok("", 202);
}
return ok(fixture("bgg-thing.sample.xml"));
});
const report = await scheduler(bggJob({ token: "app-token", delayMs: 0 }), f.fn).runNow("bgg");
expect(f.calls).toHaveLength(3); // 202, then the first 20, then the last 5
const ids = (c: Call) => new URL(c.url).searchParams.get("id")!.split(",");
expect(f.calls[1]!.url).toMatch(/^https:\/\/boardgamegeek\.com\/xmlapi2\/thing\?id=/);
expect([ids(f.calls[1]!).length, ids(f.calls[2]!).length]).toEqual([20, 5]);
expect(new Set([...ids(f.calls[1]!), ...ids(f.calls[2]!)]).size).toBe(25);
expect((f.calls[0]!.init?.headers as Record<string, string>).authorization).toBe("Bearer app-token");
// Only Wingspan is in the sample answer; the rest count as not synced.
expect(report).toMatchObject({ ok: false, counts: { games: 25, synced: 1, failed: 24 } });
expect(r.store.syncCache.forPath("Shelf/Game 1.md")).toMatchObject({ minPlayers: 1, maxPlayers: 5, playTime: 70 });
});
it("covers: picks the source by medium, skips TMDB without a key, keeps a found cover on a miss", async () => {
const b = r.put(book("The Way of Kings", { creator: "Brandon Sanderson" }));
const m = r.put(book("One Piece", { medium: "manga", anilistId: 30013 }));
r.put(book("Arrival", { medium: "movie" }));
r.put(book("Has its own", { cover: "https://example.com/own.jpg" }));
r.service.scan();
const f = fakeFetch((url) =>
url.startsWith("https://openlibrary.org/") ? ok(fixture("openlibrary-search.sample.json")) : ok(fixture("anilist-media.sample.json")),
);
const s = scheduler(coversJob({ delayMs: 0 }), f.fn);
expect(await s.runNow("covers")).toMatchObject({ ok: true, counts: { checked: 2, found: 2 } });
expect(f.calls.map((c) => new URL(c.url).host).sort()).toEqual(["graphql.anilist.co", "openlibrary.org"]);
expect(f.calls.find((c) => c.url.includes("openlibrary"))!.url).toContain("author=Brandon+Sanderson");
expect(JSON.parse(String(f.calls.find((c) => c.url.includes("anilist"))!.init?.body)).variables).toEqual({ type: "MANGA", id: 30013 });
expect(r.store.syncCache.forPath(b).cover).toBe("https://covers.openlibrary.org/b/id/8221255-L.jpg");
// Nothing is due again until its retry time.
expect(await s.runNow("covers")).toMatchObject({ counts: { checked: 0 } });
// A month on, the sources find nothing: the earlier covers stay.
r.clock.advance(31 * 24 * 60);
const empty = fakeFetch(() => ok({ docs: [], data: { Media: null } }));
await scheduler(coversJob({ delayMs: 0 }), empty.fn).runNow("covers");
expect(r.store.syncCache.forPath(m).cover).toMatch(/anilist/);
});
it("covers: TMDB with a key", async () => {
const a = r.put(book("Arrival", { medium: "movie", year: 2016 }));
r.service.scan();
const f = fakeFetch(() => ok(fixture("tmdb-search.sample.json")));
await scheduler(coversJob({ delayMs: 0, tmdbApiKey: "k".repeat(32) }), f.fn).runNow("covers");
expect(f.calls[0]!.url).toMatch(/^https:\/\/api\.themoviedb\.org\/3\/search\/movie\?query=Arrival&include_adult=false&year=2016&api_key=k+$/);
expect(r.store.syncCache.forPath(a).cover).toMatch(/image\.tmdb\.org/);
});
});