fix(public-api): treat Simkl KO as pending and unwrap all-items responses

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 16:41:00 -04:00
1 parent 96a0771e8c
commit f05e63bab7
4 files changed
+139 -17

No files matched your search

+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "oxlint",
"format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@hono/zod-validator": "0.9.1",
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { simkl } from "./simkl";
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
function mockFetchJson(body: unknown, status = 200) {
const fetchMock = vi.fn<FetchFn>(
async (_input, _init) => new Response(JSON.stringify(body), { status }),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("simkl.pollForToken", () => {
test("KO (authorization pending) is pending, not denied", async () => {
mockFetchJson({ result: "KO", message: "Authorization pending" });
expect(await simkl.pollForToken("client-1", "", "ABC123")).toEqual({ status: "pending" });
});
test("OK with a token is authorized", async () => {
mockFetchJson({ result: "OK", access_token: "tok" });
expect(await simkl.pollForToken("client-1", "", "ABC123")).toEqual({
status: "authorized",
accessToken: "tok",
});
});
test("non-2xx is pending", async () => {
mockFetchJson({}, 500);
expect(await simkl.pollForToken("client-1", "", "ABC123")).toEqual({ status: "pending" });
});
test("sends client_id as a query parameter", async () => {
const fetchMock = mockFetchJson({ result: "KO" });
await simkl.pollForToken("client-1", "", "ABC123");
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/oauth/pin/ABC123?client_id=client-1");
});
});
const movieItem = {
status: "completed",
last_watched_at: "2024-01-15T20:00:00Z",
movie: { title: "M", year: 2010, ids: { tmdb: 1 } },
};
const showItem = {
status: "watching",
show: { title: "S", ids: { tvdb: 2 } },
seasons: [
{
number: 1,
episodes: [{ number: 1, watched_at: "2024-01-16T20:00:00Z" }, { number: 2 }],
},
],
};
type UserData = {
movies: { title?: string }[];
shows: { seasons?: { episodes?: unknown[] }[] }[];
anime: unknown[];
};
function stubAllItems(responses: { movies: unknown; shows: unknown; anime: unknown }) {
const fetchMock = vi.fn<FetchFn>(async (input, _init) => {
const url = String(input);
if (url.includes("/sync/all-items/movies")) {
return new Response(JSON.stringify(responses.movies));
}
if (url.includes("/sync/all-items/shows")) {
return new Response(JSON.stringify(responses.shows));
}
if (url.includes("/sync/all-items/anime")) {
return new Response(JSON.stringify(responses.anime));
}
return new Response("{}", { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("simkl.fetchUserData", () => {
test("unwraps type-keyed all-items responses", async () => {
stubAllItems({ movies: { movies: [movieItem] }, shows: { shows: [showItem] }, anime: {} });
const data = (await simkl.fetchUserData("token", "client-1")) as UserData;
expect(data.movies[0]?.title).toBe("M");
expect(data.shows[0]?.seasons?.[0]?.episodes?.length).toBe(1);
expect(data.anime.length).toBe(0);
});
test("still accepts bare arrays", async () => {
stubAllItems({ movies: [movieItem], shows: [showItem], anime: [] });
const data = (await simkl.fetchUserData("token", "client-1")) as UserData;
expect(data.movies[0]?.title).toBe("M");
expect(data.shows[0]?.seasons?.[0]?.episodes?.length).toBe(1);
expect(data.anime.length).toBe(0);
});
});
+28 -16
View File
@@ -60,6 +60,13 @@ function flattenSimklItems(items: SimklApiItem[], mediaKey: "movie" | "show") {
});
}
/** Simkl returns `{ movies: [...] }` / `{ shows: [...] }` / `{ anime: [...] }` (or `{}` when empty). */
function unwrapAllItems(body: unknown, key: "movies" | "shows" | "anime"): SimklApiItem[] {
if (Array.isArray(body)) return body as SimklApiItem[];
const list = (body as Record<string, unknown> | null)?.[key];
return Array.isArray(list) ? (list as SimklApiItem[]) : [];
}
// ─── Provider ────────────────────────────────────────────────
export const simkl: ImportProvider = {
@@ -86,23 +93,28 @@ export const simkl: ImportProvider = {
},
async pollForToken(clientId, _clientSecret, deviceCode): Promise<PollResult> {
const res = await fetch(`${API_BASE}/oauth/pin/${deviceCode}`, {
method: "GET",
headers: { "simkl-api-key": clientId },
signal: AbortSignal.timeout(10_000),
});
const res = await fetch(
`${API_BASE}/oauth/pin/${encodeURIComponent(deviceCode)}?client_id=${encodeURIComponent(clientId)}`,
{
method: "GET",
headers: { "simkl-api-key": clientId },
signal: AbortSignal.timeout(10_000),
},
);
if (!res.ok) return { status: "pending" };
const data = (await res.json()) as {
result?: string;
access_token?: string;
};
let data: { result?: string; access_token?: string };
try {
data = (await res.json()) as { result?: string; access_token?: string };
} catch {
return { status: "pending" };
}
if (data.result === "OK" && data.access_token) {
return { status: "authorized", accessToken: data.access_token };
}
if (data.result === "KO") return { status: "denied" };
// Simkl answers "KO" until the user enters the PIN; expiry is enforced by the client's expires_in deadline.
return { status: "pending" };
},
@@ -124,16 +136,16 @@ export const simkl: ImportProvider = {
}
const [moviesData, showsData, animeData] = await Promise.all([
moviesRes.ok ? (moviesRes.json() as Promise<SimklApiItem[]>) : ([] as SimklApiItem[]),
showsRes.ok ? (showsRes.json() as Promise<SimklApiItem[]>) : ([] as SimklApiItem[]),
animeRes.ok ? (animeRes.json() as Promise<SimklApiItem[]>) : ([] as SimklApiItem[]),
moviesRes.ok ? (moviesRes.json() as Promise<unknown>) : ([] as unknown),
showsRes.ok ? (showsRes.json() as Promise<unknown>) : ([] as unknown),
animeRes.ok ? (animeRes.json() as Promise<unknown>) : ([] as unknown),
]);
// Return flattened API response — parsing happens on the self-hosted server
return {
movies: flattenSimklItems(moviesData, "movie"),
shows: flattenSimklItems(showsData, "show"),
anime: flattenSimklItems(animeData, "show"),
movies: flattenSimklItems(unwrapAllItems(moviesData, "movies"), "movie"),
shows: flattenSimklItems(unwrapAllItems(showsData, "shows"), "show"),
anime: flattenSimklItems(unwrapAllItems(animeData, "anime"), "show"),
};
},
};
+8
View File
@@ -0,0 +1,8 @@
import { defineProject } from "vitest/config";
export default defineProject({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});