import { createMockTmdb, type MockTmdb } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { compareNames, createTmdbClient, matchTmdb, matchTmdbMulti, type TmdbConfig, TmdbError, yearRange, } from "./index.ts"; let mock: MockTmdb | null = null; afterEach(async () => { await mock?.close(); mock = null; }); async function setup(extra: Partial = {}) { mock = await createMockTmdb(); const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 2, retryBaseMs: 5, timeoutMs: 300, ...extra, }); return { mock, tmdb }; } describe("TmdbClient 搜索与详情", () => { it("中文搜索电影,中英标题合并为一个候选", async () => { const { tmdb } = await setup(); const r = await tmdb.search("奥本海默", { type: "movie" }); expect(r).toHaveLength(1); expect(r[0]).toMatchObject({ provider: "tmdb", entityType: "movie", externalId: "872585", year: 2023 }); expect(r[0]!.title).toBe("奥本海默"); expect(r[0]!.titles.map((t) => `${t.kind}:${t.lang}:${t.text}`)).toEqual([ "localized:zh-CN:奥本海默", "original:en:Oppenheimer", "localized:en-US:Oppenheimer", ]); }); it("英文搜剧 + 年份过滤", async () => { const { tmdb } = await setup(); const r = await tmdb.search("the bear", { type: "tv" }); expect(r.map((c) => c.externalId)).toEqual(["136315"]); expect(await tmdb.search("the bear", { type: "tv", year: 2019 })).toEqual([]); const all = await tmdb.searchAll("流浪地球"); expect(all.map((c) => `${c.entityType}:${c.externalId}`)).toEqual(["movie:842675"]); }); it("电影详情:别名与外部 ID", async () => { const { tmdb } = await setup(); const d = await tmdb.details("movie", 693134); expect(d.detailed).toBe(true); expect(d.title).toBe("沙丘2"); expect(d.aliases).toEqual(["Dune 2", "沙丘:第二部", "沙丘:第二部", "沙丘2", "Dune: Part Two"]); expect(d.titles.find((t) => t.kind === "alias" && t.region === "TW")?.text).toBe("沙丘:第二部"); expect(d.externalIds).toEqual({ imdb: "tt15239678" }); expect(d.seasons).toBeUndefined(); }); it("剧集详情:季列表与集列表", async () => { const { tmdb } = await setup(); const d = await tmdb.details("tv", 136315); expect(d.seasons?.map((s) => [s.seasonNumber, s.episodeCount])).toEqual([ [1, 8], [2, 10], [3, 10], ]); expect(d.externalIds).toEqual({ imdb: "tt14452776", tvdb: "401654" }); const s2 = await tmdb.season(136315, 2); expect(s2.episodes).toHaveLength(10); expect(s2.episodes[0]).toMatchObject({ seasonNumber: 2, episodeNumber: 1, name: "第 1 集" }); }); it("国产综艺:特别篇季 0、按期命名、简称别名可召回", async () => { const { tmdb } = await setup(); const r = await tmdb.search("向往", { type: "tv" }); expect(r.map((c) => c.externalId)).toEqual(["900001"]); const d = await tmdb.details("tv", 900001); expect(d.originalLanguage).toBe("zh"); expect(d.seasons?.map((s) => s.seasonNumber)).toEqual([0, 1, 2]); expect(d.externalIds).toEqual({}); const s1 = await tmdb.season(900001, 1); expect(s1.episodes.at(-1)).toMatchObject({ episodeNumber: 12, name: "第 12 期" }); }); }); describe("TmdbClient 缓存/限流/错误分类", () => { it("LRU+TTL 缓存:命中不发请求,过期后重新拉取", async () => { let now = 1_000_000; const { mock, tmdb } = await setup({ now: () => now, cache: { ttlMs: 1000, max: 100 } }); await tmdb.details("movie", 872585); const n1 = mock.calls.length; await tmdb.details("movie", 872585); expect(mock.calls.length).toBe(n1); expect(tmdb.cacheStats().hits).toBeGreaterThan(0); now += 5000; await tmdb.details("movie", 872585); expect(mock.calls.length).toBe(n1 * 2); }); it("并发合并:同一请求并发只发一次", async () => { const { mock, tmdb } = await setup(); await Promise.all(Array.from({ length: 5 }, () => tmdb.season(100088, 1))); expect(mock.callsTo("/3/tv/100088/season/1")).toHaveLength(1); expect(tmdb.cacheStats().coalesced).toBe(4); }); it("429 按 Retry-After 退避后成功;持续 429 → rate", async () => { const { mock, tmdb } = await setup(); mock.fault("/3/movie/842675", { status: 429, headers: { "retry-after": "0" }, times: 1 }); const d = await tmdb.details("movie", 842675); expect(d.title).toBe("流浪地球2"); mock.fault("/3/tv/100088", { status: 429, headers: { "retry-after": "0" }, times: 10 }); const e = await tmdb.details("tv", 100088).catch((x) => x); expect(e).toBeInstanceOf(TmdbError); expect(e.kind).toBe("rate"); }); it("失败不进缓存:5xx 后重试成功,notfound 分类", async () => { const { mock, tmdb } = await setup({ retries: 0 }); mock.fault("/3/tv/136315/season/3", { status: 503 }); expect((await tmdb.season(136315, 3).catch((e) => e)).kind).toBe("protocol"); expect((await tmdb.season(136315, 3)).episodes).toHaveLength(10); expect((await tmdb.details("movie", 1).catch((e) => e)).kind).toBe("notfound"); expect((await tmdb.season(136315, 9).catch((e) => e)).kind).toBe("notfound"); }); it("凭据错误 → auth;超时 → timeout;连接失败 → network", async () => { const { mock } = await setup(); const bad = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "wrong", retries: 0 }); expect((await bad.search("x", { type: "movie" }).catch((e) => e)).kind).toBe("auth"); const slow = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 50 }); mock.fault("/3/search", { delayMs: 300, times: 2 }); expect((await slow.search("dune", { type: "movie" }).catch((e) => e)).kind).toBe("timeout"); const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: "k", retries: 0 }); expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network"); }); it("网络/HTTP 错误的 message 与 cause 不含 api_key(验收 S3)", async () => { const KEY = "tmdbSecretKey0123456789abcdef"; // 真实连接失败:Deno fetch 的错误信息带完整 URL const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: KEY, retries: 0 }); const e1 = await dead.details("movie", 1).catch((x) => x); expect(e1.kind).toBe("network"); // 模拟把 URL(含 api_key 参数与编码后的明文)拼进信息的 fetch 实现 const leaky: typeof fetch = async (input) => { throw new TypeError(`error sending request for url (${String(input)}): key ${encodeURIComponent(KEY)}`); }; const e2 = await createTmdbClient({ baseUrl: "http://tmdb.local/3", apiKey: KEY, retries: 0, fetch: leaky }) .details("movie", 1) .catch((x) => x); expect(e2.kind).toBe("network"); expect(e2.message).toContain("api_key=[REDACTED]"); // HTTP 错误 const { mock } = await setup(); mock.fault("/3/movie/872585", { status: 503 }); const e3 = await createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: KEY, retries: 0 }) .details("movie", 872585) .catch((x) => x); expect(e3.kind).toBe("protocol"); for (const e of [e1, e2, e3]) { expect(e).toBeInstanceOf(TmdbError); expect(e.message).not.toContain(KEY); expect(e.stack ?? "").not.toContain(KEY); expect(JSON.stringify(e.detail)).not.toContain(KEY); expect(e.detail.cause instanceof Error).toBe(false); } }); it("Bearer token 鉴权可用", async () => { mock = await createMockTmdb({ accessToken: "v4-token" }); const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" }); expect((await tmdb.search("last of us", { type: "tv" }))[0]?.externalId).toBe("100088"); expect(mock.calls.every((c) => c.query.api_key === undefined)).toBe(true); }); }); describe("TMDB 匹配链(对照 MP TmdbApi.match)", () => { it("名称比较:去标点/空白、大小写不敏感、完全相等", () => { expect(compareNames("Dune Part Two", "Dune: Part Two")).toBe(true); expect(compareNames("沙丘 第二部", ["沙丘:第二部"])).toBe(true); expect(compareNames("Dune", "Dune: Part Two")).toBe(false); expect(yearRange(2023)).toEqual(["2023", "2024", "2022"]); expect(yearRange(undefined)).toEqual([undefined]); }); it("年份容差 ±1:2023 未命中后按 2024 命中", async () => { const { tmdb, mock } = await setup(); const r = await matchTmdb(tmdb, { name: "沙丘2", type: "movie", year: 2023 }); expect(r?.candidate.externalId).toBe("693134"); expect(r?.step).toEqual({ target: "movie_name", year: "2024" }); expect(mock.callsTo("/3/search/movie").map((c) => c.query.year)).toEqual(["2023", "2024"]); }); it("标题不直达时拉详情用别名/译名匹配", async () => { const { tmdb } = await setup(); const r = await matchTmdb(tmdb, { name: "奥本海默传", type: "movie" }); expect(r?.candidate).toMatchObject({ externalId: "872585", detailed: true }); const ja = await matchTmdb(tmdb, { name: "オッペンハイマー", type: "movie", year: 2023 }); expect(ja).toBeNull(); // mock 搜索不索引译名 → 搜不到候选,与 TMDB 行为一致:译名只在详情比较阶段参与 expect(await matchTmdb(tmdb, { name: "完全不存在", type: "movie", year: 2000 })).toBeNull(); }); it("季年份匹配优先:按季首播年份命中剧集;multi 电影优先", async () => { const { tmdb } = await setup(); const r = await matchTmdb(tmdb, { name: "熊家餐馆", type: "tv", seasonYear: 2024, seasonNumber: 3, year: 2022 }); expect(r?.candidate.externalId).toBe("136315"); expect(r?.step.target).toBe("tv_season"); const m = await matchTmdbMulti(tmdb, "The Last of Us"); expect(m).toMatchObject({ entityType: "tv", externalId: "100088" }); }); });