Files
UHD_Media_Tool/apps/server/test/search/discover.test.ts
T
rootandClaude Opus 5.5 1dca95f883 feat(cache): TMDB / 豆瓣 / Bangumi 接入统一元数据缓存与策略表
- 策略表「来源 × 资源类型」(metaCachePolicy):搜索 / 识别 7 天(空 1 天)、详情 / 季 / 剧集组 30 天(空 1 天)、
  榜单 / 热门 / 每日放送 6 小时(空 1 小时)、人物 / 图片 90 天;取值来自 storage.cacheTtl,
  追加 tmdbListHours / tmdbListEmptyHours / imageDays / metaDetailDays / metaMissDays,metaSearchDays 缺省改 7 天
- TMDB 客户端共享缓存端口加 fetch(带请求类别与空结果判定),服务端整条读取交给 MetadataCache;
  识别缓存、douban.request / bangumi.request、douban.subject / bangumi.subject、meta.subject_miss、discover.match 都走这一层
- 豆瓣 / bgm 条目只在「不存在」时记查无,网络失败 / 被拦不缓存(本进程冷却 30 分钟)
- 关注中作品(isEntityWatched 口径,含选中的剧集组)的 TMDB 条目写入 pinned、命中续期
- 各进程启动 LISTEN 失效通知并每分钟把命中计数写 cache_stat;GET /cache/regions 给元数据 region 带近 7 天命中统计

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 19:37:34 +08:00

275 lines
12 KiB
TypeScript

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 = `<ul id="browserItemList"><li id="item_400602" class="item"><div class="inner">
<h3><a href="/subject/400602" class="l">葬送的芙莉莲</a></h3><p class="info tip">28话 / 2023年9月29日</p></div></li></ul>`;
describe.skipIf(!up)("发现页接口", () => {
let ts: TestSchema;
let db: Kysely<DB>;
let tmdb: MockTmdb;
let app: ReturnType<typeof createApp>;
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("<html>验证</html>", { 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 },
});
});
});