156 lines
7.5 KiB
TypeScript
156 lines
7.5 KiB
TypeScript
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." });
|
|
});
|
|
|
|
describe("hardcover lookup", () => {
|
|
const search = (results: unknown) => {
|
|
const calls: unknown[] = [];
|
|
const f = (async (_i: unknown, init?: RequestInit) => {
|
|
calls.push(JSON.parse(String(init?.body)));
|
|
return Response.json({ data: { search: { results } } });
|
|
}) as typeof fetch;
|
|
return { ctx: createTestContext({ fetch: f, config: { token: "tok" } }), calls };
|
|
};
|
|
|
|
test("maps hits to WorkHints", async () => {
|
|
const { ctx, calls } = search({
|
|
hits: [
|
|
{ document: { id: 10, title: "Dune", release_year: 1965, author_names: ["Frank Herbert"], image: { url: "https://img/dune.jpg" } } },
|
|
{ document: { id: "11", title: "Bare" } },
|
|
],
|
|
});
|
|
const out = await plugin.lookup!(ctx, { title: "dune" });
|
|
expect(out).toEqual([
|
|
{ title: "Dune", mediaType: "book", year: 1965, creators: [{ name: "Frank Herbert" }], externalIds: { hardcover_book: "10" }, coverUrl: "https://img/dune.jpg" },
|
|
{ title: "Bare", mediaType: "book", externalIds: { hardcover_book: "11" } },
|
|
]);
|
|
expect((calls[0] as { variables: { q: string } }).variables.q).toBe("dune");
|
|
});
|
|
|
|
test("null results returns []", async () => {
|
|
expect(await plugin.lookup!(search(null).ctx, { title: "x" })).toEqual([]);
|
|
});
|
|
|
|
test("non-book mediaType returns [] without a request", async () => {
|
|
const { ctx, calls } = search({ hits: [] });
|
|
expect(await plugin.lookup!(ctx, { title: "x", mediaType: "movie" })).toEqual([]);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("status mapping", () => {
|
|
const cases: [number | null, string][] = [[1, "planned"], [2, "in_progress"], [3, "completed"], [4, "paused"], [5, "dropped"], [6, "none"], [null, "none"]];
|
|
for (const [id, want] of cases) {
|
|
test(`status_id ${id} -> ${want}`, async () => {
|
|
const row = { ...short.data.me[0].user_books[1], status_id: id, merged_at: null };
|
|
const ctx = createTestContext({
|
|
fetch: (async () => Response.json({ data: { me: [{ user_books: [row], lists: [] }] } })) as unknown as typeof fetch,
|
|
});
|
|
const e = (await collect(plugin.pull!(ctx))).find((r) => r.key === "ub:2");
|
|
if (e?.kind !== "entry") throw new Error();
|
|
expect(e.entry.status).toBe(want as never);
|
|
});
|
|
}
|
|
});
|