import { createTmdbClient, parseBangumiBrowser } from "@umt/adapters"; import { type DB, type Kysely, pgCacheFor } from "@umt/db"; import { createMockTmdb, type MockTmdb } from "@umt/testkit"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { createOutboxPort } from "../../src/context.ts"; import { createApp } from "../../src/http/app.ts"; import { EventBus } from "../../src/http/sse.ts"; import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; import { setTrendsParser } from "../../src/modules/search/discover.ts"; import { createSearchModule } from "../../src/modules/search/index.ts"; import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts"; import { META_REQUEST_REGIONS } from "../../src/modules/sources-identity/service/meta-subjects.ts"; import type { ServerContext } from "../../src/modules/types.ts"; import { SANDBOX_JOBS } from "../../src/sandbox/jobs.ts"; import { DEV_KEY, memoryLogger } from "../helpers.ts"; import { fakeEngine, fakeSecrets, plainSettings } from "../sources-identity/harness.ts"; import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts"; // 发现页接口:TMDB 列表分页、筛选参数校验、豆瓣 / bgm 请求缓存(命中、空结果短 TTL、失败不缓存)、本地状态标注、 // 豆瓣 / bgm 条目确定匹配后记住并用于标注。外站全部是 fixture(mock TMDB + 假 fetch)。 const up = await pgAvailable(); // biome-ignore lint/suspicious/noExplicitAny: 测试直接读 JSON type Loose = any; const DOUBAN_ITEM = (id: string, title: string, year = "2023") => ({ id, title, type: "movie", year, rating: { value: 8.1 }, pic: { large: `https://img.doubanio.com/${id}.jpg` }, card_subtitle: `${year} / 美国 / 剧情 传记 / 导演 / 主演`, }); const BGM_HTML = ``; describe.skipIf(!up)("发现页接口", () => { let ts: TestSchema; let db: Kysely; let tmdb: MockTmdb; let app: ReturnType; const meta: string[] = []; /** 下一次豆瓣榜单请求的应答 */ let doubanReply: () => Response = () => new Response("{}"); const req = async (path: string, body?: unknown) => { const res = await app.request(`/api/v1${path}`, { method: body === undefined ? "GET" : "POST", headers: { authorization: `Bearer ${DEV_KEY}`, ...(body === undefined ? {} : { "content-type": "application/json" }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); return { status: res.status, body: (await res.json()) as Loose }; }; const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json" } }); beforeAll(async () => { tmdb = await createMockTmdb(); ts = await createTestSchema(uniqueSchema("discover"), { max: 8 }); db = ts.db; const { logger } = memoryLogger(); const ctx = { role: "all", db, config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never, logger, events: new EventBus(5), engine: fakeEngine(), outbox: createOutboxPort(), secrets: fakeSecrets(() => db), settings: plainSettings(() => db), capabilities: createCapabilityRegistry({ logger }), } as ServerContext; const metaFetch: typeof fetch = async (input, init) => { const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); meta.push(`${init?.method ?? "GET"} ${u.host}${u.pathname}${u.search}`); if (u.pathname.startsWith("/rexxar/api/v2/subject_collection/")) return doubanReply(); if (u.pathname === "/j/subject_suggest") return json([{ id: "26100958", title: "奥本海默", year: "2023", episode: "", type: "movie" }]); if (u.pathname === "/j/search_subjects") return json({ subjects: [] }); if (u.host === "bgm.tv" && u.pathname === "/anime/browser") return new Response(BGM_HTML); if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects") return json({ data: [{ id: 1, name: "a", name_cn: "甲" }], total: 45 }); throw new Error(`测试不允许的请求 ${u.href}`); }; const sources = createSourcesIdentityModule({ tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }), metaFetch, }); const search = createSearchModule({ runtime: (c) => sources.runtime(c) }); app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger, bus: new EventBus(5), role: "all", version: "0.0.0-test", modules: [sources, createAcquisitionOrganizeModule(), search], ctx, }); // 趋势页解析缺省走沙箱子进程;这里在进程内跑同一个解析函数(沙箱任务登记见下面的用例) setTrendsParser(async (html) => parseBangumiBrowser(html)); // 本地作品:奥本海默(TMDB movie 872585)已订阅 const e = await db .insertInto("media_entity") .values({ kind: "movie", title: "奥本海默", year: 2023 }) .returning("id") .executeTakeFirstOrThrow(); await db .insertInto("external_identity") .values({ media_entity_id: e.id, provider: "tmdb", entity_type: "movie", external_id: "872585", status: "accepted", }) .execute(); await db .insertInto("subscription") .values({ media_entity_id: e.id, media_kind: "movie", status: "active" }) .execute(); }, 120_000); afterAll(async () => { await ts?.drop(); await tmdb?.close(); }); beforeEach(() => { meta.length = 0; }); it("TMDB 流行趋势:分页、TMDB 引用、本地状态", async () => { const r = await req("/discover/tmdb?list=trending&type=all&window=week"); expect(r.status).toBe(200); const d = r.body.data; expect(d.page).toBe(1); expect(d.hasMore).toBe(false); expect(d.items.length).toBeGreaterThan(3); const opp = d.items.find((i: Loose) => i.tmdb?.id === "872585"); expect(opp).toMatchObject({ source: "tmdb", id: "movie:872585", kind: "movie", posterPath: "/oppenheimer.jpg", genres: ["剧情", "历史"], local: { subscribed: true, versionCount: 0, downloadingCount: 0 }, }); expect(d.items.filter((i: Loose) => i.tmdb?.id !== "872585").every((i: Loose) => i.local === null)).toBe(true); expect(tmdb.callsTo("/3/trending/all/week")).toHaveLength(1); }); it("TMDB 榜单与筛选:参数传到 TMDB", async () => { const r = await req("/discover/tmdb?list=discover&type=movie&genre=878&sort=rating&year=2020s&minRating=6"); expect(r.status).toBe(200); const call = tmdb.callsTo("/3/discover/movie").at(-1)!; expect(call.query).toMatchObject({ with_genres: "878", sort_by: "vote_average.desc", "primary_release_date.gte": "2020-01-01", "primary_release_date.lte": "2029-12-31", "vote_average.gte": "6", }); const now = await req("/discover/tmdb?list=now_playing&type=movie&page=1"); expect(now.body.data.items.every((i: Loose) => i.kind === "movie")).toBe(true); }); it("筛选参数校验:不配的类型 / 列表 / 风格 / 关键词 / 页码 → 422", async () => { for (const q of [ "list=now_playing&type=tv", "list=popular&type=all", "list=popular&type=movie&genre=878", "list=discover&type=tv&genre=28", "list=discover&type=movie&year=1888", "list=discover&type=movie&minRating=11", "list=search&type=all", "list=trending&q=x", "list=trending&page=0", "list=trending&page=501", ]) { const r = await req(`/discover/tmdb?${q}`); expect(r.status, q).toBe(422); } for (const q of ["list=nope", "list=tag&tag=不存在", "list=explore&genre=不存在", "list=movie_top250&sort=S"]) { expect((await req(`/discover/douban?${q}`)).status, q).toBe(422); } expect((await req("/discover/bangumi?list=trends&year=2024")).status).toBe(422); expect((await req("/discover/bangumi?list=search")).status).toBe(422); }); it("豆瓣榜单:请求缓存命中;空结果短 TTL;失败不缓存", async () => { const cache = pgCacheFor(db); // 失败(403)不缓存 doubanReply = () => new Response("forbidden", { status: 403 }); expect((await req("/discover/douban?list=movie_showing")).status).toBe(502); expect(await cache.count(META_REQUEST_REGIONS.douban)).toBe(0); // 风控页(不是 JSON)不缓存 doubanReply = () => new Response("验证", { headers: { "content-type": "text/html" } }); expect((await req("/discover/douban?list=movie_showing")).status).toBe(502); expect(await cache.count(META_REQUEST_REGIONS.douban)).toBe(0); doubanReply = () => json({ subject_collection_items: [DOUBAN_ITEM("26100958", "奥本海默"), DOUBAN_ITEM("2", "乙")], total: 45 }); const a = await req("/discover/douban?list=movie_showing&page=2"); expect(a.status).toBe(200); expect(a.body.data).toMatchObject({ page: 2, hasMore: true }); expect(a.body.data.items[0]).toMatchObject({ source: "douban", id: "26100958", genres: ["剧情", "传记"] }); const b = await req("/discover/douban?list=movie_showing&page=2"); expect(b.body.data.items).toHaveLength(2); expect(meta.filter((m) => m.includes("movie_showing"))).toHaveLength(3); expect(meta.at(-1)).toContain("start=20&count=20"); // 空结果:写入但用空结果 TTL(缺省 1 小时),正常 6 小时(看新鲜期;过期后还有 50% 宽限期先用旧值) doubanReply = () => json({ subject_collection_items: [], total: 0 }); await req("/discover/douban?list=tv_korean"); const rows = await cache.items(META_REQUEST_REGIONS.douban); const ttlOf = (part: string) => { const row = rows.find((x) => x.key.includes(part))!; return Math.round((new Date(row.freshUntil).getTime() - Date.now()) / 60_000); }; expect(ttlOf("tv_korean")).toBeLessThanOrEqual(60); expect(ttlOf("tv_korean")).toBeGreaterThan(55); expect(ttlOf("movie_showing")).toBeGreaterThan(5 * 60); }); it("搜索与 bgm 浏览 / 趋势", async () => { const s = await req("/discover/douban?list=search&q=奥本海默"); expect(s.body.data).toMatchObject({ hasMore: false, items: [{ id: "26100958", kind: "movie", year: 2023 }] }); const t = await req("/discover/bangumi?list=trends"); expect(t.body.data.items[0]).toMatchObject({ source: "bangumi", id: "400602", episodes: 28 }); const rank = await req("/discover/bangumi?list=rank&year=2024&page=2"); expect(rank.body.data).toMatchObject({ page: 2, hasMore: true }); expect(meta.find((m) => m.includes("/v0/subjects"))).toContain("offset=20"); expect(SANDBOX_JOBS["bangumi.trends"]({ html: BGM_HTML }).items[0]!.id).toBe("400602"); }); it("豆瓣条目确定匹配后记住,列表据此标本地状态", async () => { const m = await req("/discover/tmdb-match", { source: "douban", id: "26100958", title: "奥本海默", aliases: ["Oppenheimer"], year: 2023, kind: "movie", }); expect(m.status).toBe(200); expect(m.body.data.match).toMatchObject({ type: "movie", id: "872585" }); const searches = tmdb.callsTo("/3/search").length; const again = await req("/discover/tmdb-match", { source: "douban", id: "26100958", title: "奥本海默", aliases: [], year: 2023, kind: "movie", }); expect(again.body.data.match.id).toBe("872585"); expect(tmdb.callsTo("/3/search").length).toBe(searches); const s = await req("/discover/douban?list=search&q=奥本海默"); expect(s.body.data.items[0]).toMatchObject({ tmdb: { type: "movie", id: "872585" }, local: { subscribed: true }, }); }); });