import { describe, expect, 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); }); });