import { describe, expect, setSystemTime, test } from "bun:test"; import { HostError } from "../src/core/hosting/host"; import { MangaBakaError } from "../src/core/mangabaka/client"; import type { SubmissionImage } from "../src/core/mangabaka/types"; import type { SourceSeries } from "../src/core/model"; import type { RunResult } from "../src/core/pipeline"; import { HOSTED_KEY, Session, memoryStore, type SessionDeps } from "../src/core/session"; import { webtoons } from "../src/core/sources/webtoons"; import { inspected } from "./helpers/covers"; import { FakeTransport, json, text } from "./helpers/fake-transport"; const series: SourceSeries = { source: "webtoons", externalId: "95", title: "Tower of God", language: "en", canonicalUrl: "https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95", archiveUrls: [], mature: false, flags: {}, }; function result(): RunResult { const poster = inspected({ candidate: { role: "poster", current: true } }); const landing = inspected({ shape: "tall", type: "other", candidate: { role: "poster", current: false, seenAt: "20230401000000" } }); const already = inspected({ candidate: { role: "square" }, shape: "square", type: "other" }); return { seriesId: 9999, mbSeries: { id: 9999, title: "Tower of God", type: "manhwa", content_rating: "safe" }, source: webtoons, series, linkMatches: true, fresh: [poster, landing], present: [{ cover: already, existing: { id: 8000, sha256: already.sha256, language: "en", type: "other" } }], suggestedDefault: poster, warnings: [], }; } const deps = (transport: FakeTransport, over: Partial = {}): SessionDeps => ({ transport, serverBase: null, catboxVia: "direct", store: memoryStore(), persistHostedUrls: false, ...over }); describe("review cards", () => { test("landing-page art starts unselected; notes and rating filled in", () => { const s = new Session(deps(new FakeTransport())); s.load(result(), "suggestive"); expect(s.cards.map((c) => c.selected)).toEqual([true, false]); expect(s.cards.map((c) => c.note)).toEqual(["WEBTOON poster (current)", "WEBTOON poster (landing-page art), seen 2023-04"]); expect(s.cards.every((c) => c.contentRating === "suggestive")).toBe(true); }); test("main cover: the suggested poster, unless it's unticked or deselected", () => { const s = new Session(deps(new FakeTransport())); const r = result(); s.load(r); expect(s.defaultChoice()).toEqual({ kind: "new", sha256: r.suggestedDefault!.sha256 }); s.update(r.suggestedDefault!.sha256, { selected: false }); expect(s.defaultChoice()).toBeNull(); s.update(r.suggestedDefault!.sha256, { selected: true }); s.makeDefault = false; expect(s.defaultChoice()).toBeNull(); }); test("main cover already on MangaBaka: that existing image is marked", () => { const s = new Session(deps(new FakeTransport())); const r = result(); s.load({ ...r, suggestedDefault: r.present[0]!.cover }); expect(s.defaultChoice()).toEqual({ kind: "existing", id: 8000 }); }); }); describe("rehost and manual entry", () => { test("rehosted URLs are used in the copy-and-paste text and remembered (catbox only)", async () => { const fake = new FakeTransport().on("https://catbox.moe/user/api.php", () => text("https://files.catbox.moe/p.jpg")); const store = memoryStore(); const r = result(); const s = new Session(deps(fake, { store, persistHostedUrls: true })); s.load(r); const res = await s.rehost("catbox", {}); expect(res.failures).toEqual([]); expect(s.cards[0]!.hostedUrl).toBe("https://files.catbox.moe/p.jpg"); expect(s.manualText()).toStartWith("https://files.catbox.moe/p.jpg\ntype: volume · language: en · rating: safe · main cover"); expect(JSON.parse(store.get(HOSTED_KEY)!)).toEqual({ [`catbox:${s.cards[0]!.cover.sha256}`]: "https://files.catbox.moe/p.jpg" }); const again = new Session(deps(fake, { store, persistHostedUrls: true })); again.load(r); await again.rehost("catbox", {}); expect(fake.calls).toHaveLength(1); }); }); // Snapshot/preview/save shapes from the OpenAPI spec (not capturable without a token). const old: SubmissionImage = { id: 7193, series_id: 9999, work_id: null, url: "https://example.org/old.jpg", index: "0.1", index_numeric: 0.1, language: "ko", type: "volume", note: "Season Cover 3", content_rating: "safe", is_default: true, }; const SUB = "https://api.mangabaka.org/v0/my/submissions/series-images/9999"; const LITTERBOX = "https://litterbox.catbox.moe/resources/internals/api.php"; function mangabaka(saveStatuses: number[]) { return new FakeTransport() .on((u, i) => u === SUB && (i.method ?? "GET") === "GET", () => json({ status: 200, data: { data: { images: [old] }, version: 42 } })) .on(`${SUB}/preview`, () => json({ status: 200, data: { has_changes: true, changes: [] } })) .on((u, i) => u === SUB && i.method === "POST", () => { const status = saveStatuses.shift() ?? 200; return status === 200 ? json({ status: 200, data: { submission_id: 77, status: "pending", changes: [] } }) : json({ status, message: "x" }, status); }); } describe("submit with a token", () => { test("preview, then save for review with the whole set and one main cover", async () => { const fake = mangabaka([]); const s = new Session(deps(fake)); s.load(result()); const prepared = await s.preview("mb-token"); expect(prepared.seed.images.map((i) => [i.id, i.is_default])).toEqual([[7193, false], [null, true]]); const outcome = await s.submit("mb-token", prepared, "direct", {}); expect(outcome).toMatchObject({ status: "saved", result: { submission_id: 77 } }); const save = JSON.parse(fake.calls.at(-1)!.init.body as string); expect(save.save_mode).toBe("review"); expect(save.version).toBe(42); expect(save.data.images).toHaveLength(2); }); test("409 once: refetch and ask again; 409 twice: give up", async () => { const fake = mangabaka([409, 409]); const s = new Session(deps(fake)); s.load(result()); const first = await s.submit("mb-token", await s.preview("mb-token"), "direct", {}); expect(first.status).toBe("changed"); if (first.status !== "changed") throw new Error("unreachable"); await expect(s.submit("mb-token", first.prepared, "direct", {})).rejects.toBeInstanceOf(MangaBakaError); }); test("a failed upload is left out and the user gets a fresh preview; nothing is saved yet", async () => { let uploads = 0; const fake = mangabaka([]).on(LITTERBOX, () => (++uploads === 1 ? text("https://litter.catbox.moe/ok.jpg") : text("error", 500))); const s = new Session(deps(fake)); const r = result(); s.load(r); s.update(r.fresh[1]!.sha256, { selected: true }); const outcome = await s.submit("mb-token", await s.preview("mb-token"), "litterbox", {}); expect(outcome.status).toBe("changed"); expect(s.cards[1]!.selected).toBe(false); expect(fake.calls.some((c) => c.url === SUB && c.init.method === "POST")).toBe(false); }); test("every upload failing aborts the action", async () => { const fake = mangabaka([]).on(LITTERBOX, () => text("error", 500)); const s = new Session(deps(fake)); s.load(result()); await expect(s.submit("mb-token", await s.preview("mb-token"), "litterbox", {})).rejects.toBeInstanceOf(HostError); expect(fake.calls.some((c) => c.url === SUB && c.init.method === "POST")).toBe(false); }); }); describe("download", () => { test("bundle: only the selected covers, in shelf order, with the main cover marked", () => { const s = new Session(deps(new FakeTransport())); const r = result(); s.load(r); s.input = { mangabakaLink: "https://mangabaka.org/manhwa/9999/Tower-of-God", sourceLink: series.canonicalUrl, history: null }; s.update(r.fresh[1]!.sha256, { selected: true }); s.update(r.fresh[0]!.sha256, { selected: false }); const b = s.bundle(new Date("2026-10-15T00:00:00Z")); expect(b.files.map((f) => f.name)).toEqual(["01_poster_en_2023_480x623.jpg", "covers.txt"]); expect(new TextDecoder().decode(b.files[1]!.bytes)).not.toContain("MAIN COVER"); s.update(r.fresh[0]!.sha256, { selected: true }); expect(new TextDecoder().decode(s.bundle().files[2]!.bytes)).toContain("01_poster_en_480x623.jpg\n type: volume · language: en · rating: safe · MAIN COVER"); }); }); describe("hosting is remembered on the cards", () => { test("rehost records the host, the cache key and when the link expires", async () => { const fake = new FakeTransport().on(LITTERBOX, () => text("https://litter.catbox.moe/a.jpg")); const s = new Session(deps(fake)); s.load(result()); const before = Date.now(); await s.rehost("litterbox", { litterboxTime: "1h" }); const card = s.cards[0]!; expect(card.hosting?.by).toBe("litterbox"); expect(card.hosting?.key).toBe(`litterbox:1h:${card.cover.sha256}`); expect(card.hosting!.until!).toBeGreaterThanOrEqual(before + 3_600_000); }); test("restore puts hosted links back in the rehost cache: no second upload", async () => { const fake = new FakeTransport().on(LITTERBOX, () => text("https://litter.catbox.moe/a.jpg")); const first = new Session(deps(fake)); const r = result(); first.load(r); await first.rehost("litterbox", { litterboxTime: "1h" }); const uploads = fake.calls.length; const second = new Session(deps(fake)); second.restore(r, first.cards.map((c) => ({ ...c })), false, null); expect(second.makeDefault).toBe(false); await second.rehost("litterbox", { litterboxTime: "1h" }); expect(fake.calls.length).toBe(uploads); expect(second.cards[0]!.hostedUrl).toBe("https://litter.catbox.moe/a.jpg"); }); }); describe("hosted links that expire", () => { const T = Date.UTC(2026, 9, 15, 10); const MIN = 60_000; test("a link reused from the rehost cache keeps its original expiry", async () => { const fake = new FakeTransport().on(LITTERBOX, () => text("https://litter.catbox.moe/a.jpg")); const s = new Session(deps(fake)); s.load(result()); try { setSystemTime(new Date(T)); await s.rehost("litterbox", { litterboxTime: "1h" }); setSystemTime(new Date(T + 20 * MIN)); await s.rehost("litterbox", { litterboxTime: "1h" }); expect(s.cards[0]!.hosting!.until).toBe(T + 60 * MIN); } finally { setSystemTime(); } }); test("a restored link that has expired since is uploaded again, not reused", async () => { let n = 0; const fake = new FakeTransport().on(LITTERBOX, () => text(`https://litter.catbox.moe/${++n}.jpg`)); const r = result(); const first = new Session(deps(fake)); first.load(r); try { setSystemTime(new Date(T)); await first.rehost("litterbox", { litterboxTime: "1h" }); setSystemTime(new Date(T + 55 * MIN)); const second = new Session(deps(fake)); second.restore(r, first.cards.map((c) => ({ ...c })), true, null); setSystemTime(new Date(T + 90 * MIN)); await second.rehost("litterbox", { litterboxTime: "1h" }); expect(second.cards[0]!.hostedUrl).toBe("https://litter.catbox.moe/2.jpg"); expect(second.cards[0]!.hosting!.until).toBe(T + 150 * MIN); } finally { setSystemTime(); } }); });