/** * 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 | 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 = {}) => ({ 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((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; 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") }); }); it("falls back to tags on Homebox releases without /labels", async () => { 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/tags")) return ok(fixture("homebox-labels.sample.json")); if (url.includes("/api/v1/items?")) { return new URL(url).searchParams.get("tags")?.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 } }); }); }); // ---------------------------------------------------------------- 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/); }); });