import { describe, expect, test } from "bun:test"; import { collect, createFixtureHttp, createTestContext } from "@sttracker/plugin-sdk/testing"; import type { PluginContext } from "@sttracker/plugin-sdk/types"; import plugin from "../index.ts"; import history1 from "./fixtures/history-page1.json"; import history2 from "./fixtures/history-page2.json"; import ratings from "./fixtures/ratings.json"; import watchlist from "./fixtures/watchlist.json"; import lists from "./fixtures/lists.json"; import listItems from "./fixtures/list-items.json"; import lastActivities from "./fixtures/last-activities.json"; import tokenRefresh from "./fixtures/token-refresh.json"; import deviceCode from "./fixtures/device-code.json"; import search from "./fixtures/search.json"; const B = "https://api.trakt.tv"; const NOW = new Date("2024-04-01T00:00:00.000Z"); const FRESH = { accessToken: "old-access", refreshToken: "old-refresh", expiresAt: "2024-06-01T00:00:00.000Z" }; const paged = (req: Request) => { const page = new URL(req.url).searchParams.get("page"); return Response.json(page === "2" ? history2 : history1, { headers: { "X-Pagination-Page-Count": "2" } }); }; function setup(routes: Record, creds: Record | null = FRESH, cursor?: string) { const fx = createFixtureHttp(routes); const ctx = createTestContext({ fetch: fx.fetch, now: () => NOW, config: { clientId: "cid", clientSecret: "csec" } }); return (async () => { if (creds) await ctx.credentials.save(creds); ctx.saved.credentials.length = 0; if (cursor) await ctx.cursor.set(cursor); ctx.saved.cursor.length = 0; return { fx, ctx }; })(); } const fullRoutes = { [`GET ${B}/sync/history`]: paged, [`GET ${B}/sync/ratings`]: ratings, [`GET ${B}/sync/watchlist`]: watchlist, [`GET ${B}/users/me/lists`]: lists, [`GET ${B}/users/me/lists/7777/items`]: listItems, }; describe("trakt pull", () => { test("history pages through X-Pagination-Page-Count", async () => { const { fx, ctx } = await setup(fullRoutes); const recs = await collect(plugin.pull!(ctx)); const hist = recs.filter((r) => r.key.startsWith("history:")); expect(hist.map((r) => r.key)).toEqual(["history:9001", "history:9002", "history:9003"]); const pages = fx.calls.filter((c) => c.url.includes("/sync/history")).map((c) => new URL(c.url).searchParams.get("page")); expect(pages).toEqual(["1", "2"]); expect(new URL(fx.calls[0]!.url).searchParams.get("limit")).toBe("250"); expect(fx.calls[0]!.headers.get("trakt-api-version")).toBe("2"); expect(fx.calls[0]!.headers.get("trakt-api-key")).toBe("cid"); expect(fx.calls[0]!.headers.get("authorization")).toBe("Bearer old-access"); }); test("movie history: day precision, full datetime kept, ids", async () => { const { ctx } = await setup(fullRoutes); const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "history:9001")!; if (r.kind !== "engagement") throw new Error("kind"); expect(r.work.mediaType).toBe("movie"); expect(r.work.externalIds).toEqual({ trakt_movie: "1001", tmdb_movie: "329865", imdb: "tt2543164" }); expect(r.engagement.finishedAt).toEqual({ value: "2024-03-05T20:15:00.000Z", precision: "day" }); }); test("episode history: episode -> season -> show chain with position", async () => { const { ctx } = await setup(fullRoutes); const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "history:9002")!; if (r.kind !== "engagement") throw new Error("kind"); expect(r.work.mediaType).toBe("episode"); expect(r.work.position).toEqual({ season: 2, episode: 3 }); expect(r.work.externalIds).toEqual({ trakt_episode: "5003", tvdb_episode: "7003", tmdb_episode: "8003", imdb: "tt0000003" }); expect(r.work.parent?.mediaType).toBe("season"); expect(r.work.parent?.position).toEqual({ season: 2 }); const show = r.work.parent?.parent; expect(show?.mediaType).toBe("show"); expect(show?.externalIds).toEqual({ trakt_show: "2002", tmdb_show: "80002", tvdb_show: "70002", imdb: "tt7000002" }); }); test("rating 8 becomes 80", async () => { const { ctx } = await setup(fullRoutes); const recs = await collect(plugin.pull!(ctx)); const m = recs.find((x) => x.key === "rating:movie:1001")!; const s = recs.find((x) => x.key === "rating:show:2002")!; if (m.kind !== "entry" || s.kind !== "entry") throw new Error("kind"); expect(m.entry.rating).toBe(80); expect(s.entry.rating).toBe(100); }); test("watchlist becomes planned", async () => { const { ctx } = await setup(fullRoutes); const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "watchlist:movie:1004")!; if (r.kind !== "entry") throw new Error("kind"); expect(r.entry.status).toBe("planned"); expect(r.work.title).toBe("Dune: Part Two"); }); test("custom lists with items and notes", async () => { const { ctx } = await setup(fullRoutes); const r = (await collect(plugin.pull!(ctx))).find((x) => x.key === "list:7777")!; if (r.kind !== "list") throw new Error("kind"); expect(r.list.name).toBe("Favourites"); expect(r.list.items.map((i) => i.work.title)).toEqual(["Heat", "Great Show"]); expect(r.list.items[0]!.note).toBe("rewatch yearly"); }); test("a token near expiry is refreshed and saved before the first /sync call", async () => { let savedWhenSyncCalled = -1; const routes = { ...fullRoutes, [`POST ${B}/oauth/token`]: () => tokenRefresh, [`GET ${B}/sync/history`]: (req: Request) => { if (savedWhenSyncCalled < 0) savedWhenSyncCalled = ctxRef.saved.credentials.length; return paged(req); }, }; const { fx, ctx } = await setup(routes, { ...FRESH, expiresAt: "2024-04-01T12:00:00.000Z" }); ctxRef = ctx; await collect(plugin.pull!(ctx)); expect(fx.calls[0]!.method).toBe("POST"); expect(await fx.calls[0]!.json()).toEqual({ refresh_token: "old-refresh", client_id: "cid", client_secret: "csec", redirect_uri: "urn:ietf:wg:oauth:2.0:oob", grant_type: "refresh_token", }); expect(new URL(fx.calls[0]!.url).pathname).toBe("/oauth/token"); expect(new URL(fx.calls[1]!.url).pathname).toBe("/sync/history"); expect(savedWhenSyncCalled).toBe(1); expect(ctx.saved.credentials).toEqual([ { accessToken: "new-access", refreshToken: "new-refresh", expiresAt: new Date((1772000000 + 7776000) * 1000).toISOString() }, ]); expect(fx.calls[1]!.headers.get("authorization")).toBe("Bearer new-access"); }); test("a failed refresh saves nothing and never calls /sync", async () => { const { fx, ctx } = await setup( { ...fullRoutes, [`POST ${B}/oauth/token`]: () => new Response("{}", { status: 400 }) }, { ...FRESH, expiresAt: "2024-04-01T12:00:00.000Z" }, ); await expect(collect(plugin.pull!(ctx))).rejects.toThrow("400"); expect(ctx.saved.credentials).toEqual([]); expect(fx.calls.map((c) => new URL(c.url).pathname)).toEqual(["/oauth/token"]); }); test("a token with more than 24h left is not refreshed", async () => { const { fx, ctx } = await setup(fullRoutes); await collect(plugin.pull!(ctx)); expect(fx.calls.some((c) => c.url.includes("/oauth/token"))).toBe(false); expect(ctx.saved.credentials).toEqual([]); }); }); let ctxRef: ReturnType; describe("trakt pullChanges", () => { const cursorOf = (o: object) => JSON.stringify(o); const same = { history: "2024-03-06T21:30:00.000Z", ratings: "2024-03-07T10:00:00.000Z", watchlist: "2024-02-01T08:00:00.000Z", lists: "2024-01-02T00:00:00.000Z", }; const routes = { ...fullRoutes, [`GET ${B}/sync/last_activities`]: lastActivities }; test("unchanged last_activities yields nothing and fetches nothing else", async () => { const { fx, ctx } = await setup(routes, FRESH, cursorOf(same)); expect(await collect(plugin.pullChanges!(ctx))).toEqual([]); expect(fx.calls.map((c) => new URL(c.url).pathname)).toEqual(["/sync/last_activities"]); expect(ctx.saved.cursor).toEqual([]); }); test("only changed categories are re-fetched; history uses start_at; cursor advances", async () => { const prev = { ...same, history: "2024-03-01T00:00:00.000Z", watchlist: "2024-01-01T00:00:00.000Z" }; const { fx, ctx } = await setup(routes, FRESH, cursorOf(prev)); const recs = await collect(plugin.pullChanges!(ctx)); expect(recs.some((r) => r.key.startsWith("history:"))).toBe(true); expect(recs.some((r) => r.key.startsWith("watchlist:"))).toBe(true); expect(recs.some((r) => r.key.startsWith("rating:"))).toBe(false); const h = fx.calls.find((c) => c.url.includes("/sync/history"))!; expect(new URL(h.url).searchParams.get("start_at")).toBe("2024-03-01T00:00:00.000Z"); expect(fx.calls.some((c) => c.url.includes("/sync/ratings"))).toBe(false); expect(ctx.saved.cursor).toEqual([cursorOf(same)]); }); test("no cursor: full pull and cursor set", async () => { const { ctx } = await setup(routes); const recs = await collect(plugin.pullChanges!(ctx)); expect(recs.length).toBeGreaterThan(5); expect(ctx.saved.cursor).toEqual([cursorOf(same)]); }); }); describe("trakt auth and lookup", () => { test("manifest", () => { expect(plugin.manifest.id).toBe("trakt"); expect(plugin.manifest.capabilities).toEqual(["pull", "pullChanges", "lookup"]); expect(plugin.manifest.attribution).toBe("STTracker uses Trakt but is not endorsed or certified by Trakt."); }); test("device auth start and poll", async () => { let n = 0; const { ctx } = await setup({ [`POST ${B}/oauth/device/code`]: deviceCode, [`POST ${B}/oauth/device/token`]: () => (n++ === 0 ? new Response("", { status: 400 }) : tokenRefresh), }, null); const start = await plugin.deviceAuth!.start(ctx); expect(start).toEqual({ userCode: "ABCD1234", verificationUrl: "https://trakt.tv/activate", deviceCode: "dev123", intervalSec: 5, expiresInSec: 600 }); expect(await plugin.deviceAuth!.poll(ctx, "dev123")).toBe("pending"); expect(await plugin.deviceAuth!.poll(ctx, "dev123")).toEqual({ accessToken: "new-access", refreshToken: "new-refresh", expiresAt: new Date((1772000000 + 7776000) * 1000).toISOString(), }); }); test("device auth poll throws on terminal errors", async () => { const { ctx } = await setup({ [`POST ${B}/oauth/device/token`]: () => new Response("", { status: 418 }) }, null); await expect(plugin.deviceAuth!.poll(ctx as PluginContext, "x")).rejects.toThrow("418"); }); test("lookup searches movie,show", async () => { const { fx, ctx } = await setup({ [`GET ${B}/search/movie,show`]: search }, null); const hints = await plugin.lookup!(ctx, { title: "Arrival" }); expect(new URL(fx.calls[0]!.url).searchParams.get("query")).toBe("Arrival"); expect(hints.map((h) => [h.mediaType, h.title])).toEqual([["movie", "Arrival"], ["show", "Arrival Show"]]); }); });