import { describe, expect, test } from "bun:test"; import { HttpError } from "@sttracker/plugin-sdk/http"; import { collect, createFixtureHttp, createTestContext } from "@sttracker/plugin-sdk/testing"; import plugin from "./index.ts"; import { PAGE_SIZE } from "./src/graphql.ts"; const short = await Bun.file(new URL("./test/fixtures/page-short.json", import.meta.url)).json(); const lists = await Bun.file(new URL("./test/fixtures/lists.json", import.meta.url)).json(); const errors = await Bun.file(new URL("./test/fixtures/errors.json", import.meta.url)).json(); const URL_ = "POST https://api.hardcover.app/v1/graphql"; type Body = { query: string; variables: { offset?: number; where?: unknown } }; function ctxFor(userBooks: (b: Body) => unknown) { const calls: Body[] = []; const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { const body = JSON.parse(String(init?.body)) as Body; calls.push(body); const out = body.query.includes("query Lists") ? lists : userBooks(body); const inner = createFixtureHttp({ [URL_]: out }); return inner.fetch(input, init); }) as typeof fetch; return { ctx: createTestContext({ fetch: fetchImpl, config: { token: "tok" } }), calls }; } describe("hardcover pull", () => { test("maps status, rating, series parent, favorite", async () => { const { ctx } = ctxFor(() => short); const recs = await collect(plugin.pull!(ctx)); const e = recs.find((r) => r.key === "ub:1"); if (e?.kind !== "entry") throw new Error("no entry"); expect(e.entry).toMatchObject({ status: "completed", rating: 90, review: "Great", notes: "lent to Sam", favorite: true }); expect(e.work.externalIds).toEqual({ hardcover_book: "10", hardcover_edition: "500", isbn13: "9780441013593", isbn10: "0441013597" }); expect(e.work.parent).toMatchObject({ title: "Dune Chronicles", externalIds: { hardcover_series: "77" } }); expect(e.work.position).toEqual({ index: 1 }); const e2 = recs.find((r) => r.key === "ub:2"); if (e2?.kind !== "entry") throw new Error("no entry"); expect(e2.entry).toMatchObject({ status: "in_progress", rating: null, favorite: false }); }); test("re-reads yield two engagements with day precision", async () => { const { ctx } = ctxFor(() => short); const eng = (await collect(plugin.pull!(ctx))).filter((r) => r.kind === "engagement"); expect(eng.map((r) => r.key)).toEqual(["read:900", "read:901"]); if (eng[0]?.kind !== "engagement") throw new Error(); expect(eng[0].engagement.startedAt).toEqual({ value: "2024-01-05", precision: "day" }); expect(eng[0].engagement.progress).toEqual({ pages: 412 }); }); test("merge yields synthetic record with mergedIntoKey", async () => { const { ctx } = ctxFor(() => short); const m = (await collect(plugin.pull!(ctx))).find((r) => r.key === "ub:2@book:20"); expect(m?.mergedIntoKey).toBe("ub:2"); if (m?.kind !== "entry") throw new Error(); expect(m.work.externalIds).toEqual({ hardcover_book: "20" }); expect((await collect(plugin.pull!(ctxFor(() => short).ctx))).some((r) => r.key === "ub:1@book:10")).toBe(false); }); test("lists yield list records", async () => { const { ctx } = ctxFor(() => short); const l = (await collect(plugin.pull!(ctx))).find((r) => r.kind === "list"); expect(l?.key).toBe("list:5"); if (l?.kind !== "list") throw new Error(); expect(l.list.items[0]?.work.externalIds).toEqual({ hardcover_book: "10" }); }); test("sends bearer token", async () => { let auth = ""; const f = (async (_i: unknown, init?: RequestInit) => { auth = new Headers(init?.headers).get("authorization") ?? ""; return Response.json({ data: { me: [{ user_books: [], lists: [] }] } }); }) as typeof fetch; await collect(plugin.pull!(createTestContext({ fetch: f, config: { token: "tok" } }))); expect(auth).toBe("Bearer tok"); }); test("GraphQL errors throw HttpError", async () => { const { ctx } = ctxFor(() => errors); await expect(collect(plugin.pull!(ctx))).rejects.toBeInstanceOf(HttpError); }); test("offset paging continues on full page and stops on short page", async () => { const row = short.data.me[0].user_books[0]; const full = { data: { me: [{ user_books: Array.from({ length: PAGE_SIZE }, (_, i) => ({ ...row, id: 1000 + i, user_book_reads: [] })) }] } }; const { ctx, calls } = ctxFor((b) => (b.variables.offset === 0 ? full : short)); await collect(plugin.pull!(ctx)); const ub = calls.filter((c) => c.query.includes("UserBooks")); expect(ub.map((c) => c.variables.offset)).toEqual([0, 100]); }); }); describe("hardcover pullChanges", () => { test("filters by cursor and advances it", async () => { const { ctx, calls } = ctxFor(() => short); await ctx.cursor.set("2024-05-01T00:00:00+00:00"); const recs = await collect(plugin.pullChanges!(ctx)); expect(calls[0]?.variables.where).toEqual({ updated_at: { _gt: "2024-05-01T00:00:00+00:00" } }); expect(recs.some((r) => r.kind === "list")).toBe(false); expect(ctx.saved.cursor.at(-1)).toBe("2024-06-01T10:00:00+00:00"); }); }); test("manifest", () => { expect(plugin.manifest).toMatchObject({ id: "hardcover", capabilities: ["pull", "pullChanges", "lookup"], mediaTypes: ["book"], attribution: "Book data from Hardcover." }); });