mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
4 files changed
+139
-17
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user