import { createTmdbClient } from "@umt/adapters"; import { PgCache } from "@umt/db"; import { createMockTmdb, HEART_SIGNAL_FIXTURE, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts"; import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; import { applyOverviewRecompute, planOverviewRecompute, } from "../../src/modules/sources-identity/service/overview-recompute.ts"; import { CACHE_REGIONS } from "../../src/shared-cache.ts"; import { createHarness, type Harness, pgAvailable } from "./harness.ts"; const up = await pgAvailable(); type Final = { value: unknown; source: string; language: string | null }; const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; describe.skipIf(!up)("MD-2 简介只回退到中文变体 + 存量重算", () => { let h: Harness; let tmdb: MockTmdb; let id: string; const actor = { type: "user" as const, id: "tester" }; const now = () => new Date("2026-01-01T00:00:00Z"); beforeAll(async () => { tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, HEART_SIGNAL_FIXTURE] }); // 走 PG 请求缓存(kv_cache tmdb.request),与生产一致:存量重算只读这里 const cache = { get: (k: string) => new PgCache(h.db).get(CACHE_REGIONS.tmdbRequest, k), set: (k: string, v: unknown, ttlMs: number) => new PgCache(h.db).set(CACHE_REGIONS.tmdbRequest, k, v, { ttl: ttlMs / 1000 }), }; h = await createHarness("md2", { now, tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0, sharedCache: cache }), }); }); afterAll(async () => { await h?.close(); await tmdb?.close(); }); const setMeta = async (v: Record | null) => { await h.db.deleteFrom("settings").where("key", "=", "metadata").execute(); if (v) await h.db .insertInto("settings") .values({ key: "metadata", value: JSON.stringify(v) } as never) .execute(); }; const entityMeta = async () => (await h.db.selectFrom("media_entity").select("metadata").where("id", "=", id).executeTakeFirstOrThrow()) .metadata as Record; const eps = async () => ( await h.db .selectFrom("content_unit") .select(["episode_number", "title", "metadata"]) .where("media_entity_id", "=", id) .where("kind", "=", "episode") .orderBy("episode_number") .execute() ).map( (u) => ({ ...(u.metadata as Record), ep: u.episode_number, title: u.title }) as { ep: number | null; title: string | null; overview?: Final; name?: Final; }, ); it("允许回退到非中文时(旧行为):英文简介进库", async () => { await setMeta({ languages: CHAIN, overviewNonChineseFallback: true }); const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "103147", kind: "tv" }, actor); id = r.mediaEntityId; expect((await refreshMetadata(h.rt, id, { full: true })).status).toBe("refreshed"); expect((await entityMeta()).overview).toMatchObject({ value: "A Chinese dating reality show.", language: "en-US" }); const [e1, e2] = await eps(); expect(e1!.overview).toMatchObject({ value: "If Your Heart Skips a Beat, Play Your Cards Openly!", language: "en-US", }); expect(e2!.overview).toMatchObject({ value: "Who Is the One?", language: "en-US" }); }); it("存量重算:dry-run 只列清单,不请求 TMDB、不改库;--apply 按缓存清空,标题不动", async () => { await setMeta({ languages: CHAIN }); const calls = tmdb.calls.length; const plan = await planOverviewRecompute(h.db, { languages: CHAIN, now: now() }); expect(tmdb.calls.length).toBe(calls); expect(plan.overviewLanguages).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]); expect(plan.scanned).toEqual({ entities: 1, units: 2 }); expect(plan.cache.misses).toBe(0); const brief = plan.changes.map((c) => [c.target, c.episode, c.field, c.from.language, c.to.value, c.basis]); expect(brief).toEqual([ ["entity", null, "overview", "en-US", null, "cache"], ["episode", 1, "overview", "en-US", null, "cache"], ["episode", 2, "overview", "en-US", null, "cache"], ]); // dry-run 不改库 expect((await entityMeta()).overview?.language).toBe("en-US"); const r = await h.db.transaction().execute((trx) => applyOverviewRecompute(trx, plan, { now: now() })); expect(r).toEqual({ entities: 1, units: 2 }); expect(tmdb.calls.length).toBe(calls); expect((await entityMeta()).overview).toMatchObject({ value: null, language: null, source: "tmdb" }); const [e1, e2, e3] = await eps(); expect(e1!.overview?.value).toBeNull(); expect(e1!.title).toBe("心动不如行动,大胆亮出底牌!"); expect(e2!.overview?.value).toBeNull(); // 标题仍可回退到英文 expect(e2!.name).toMatchObject({ value: "Who Is the One?", language: "en-US" }); expect(e3!.overview).toMatchObject({ value: "約會之夜。", language: "zh-TW" }); // 旧语言行删掉,只留一行 tmdb 来源 const rows = await h.db .selectFrom("metadata_field") .select(["language", "value"]) .where("media_entity_id", "=", id) .where("field", "=", "overview") .execute(); expect(rows).toEqual([{ language: null, value: null }]); // 再跑一次没有要改的 expect((await planOverviewRecompute(h.db, { languages: CHAIN, now: now() })).changes).toEqual([]); }); it("缓存被淘汰时不请求 TMDB,按「当时中文为空」清空;刷新按新规则也不再写英文", async () => { await setMeta({ languages: CHAIN, overviewNonChineseFallback: true }); await refreshMetadata(h.rt, id, { full: true }); expect((await eps())[0]!.overview?.language).toBe("en-US"); await setMeta({ languages: CHAIN }); await h.db.deleteFrom("kv_cache" as never).execute(); const calls = tmdb.calls.length; const plan = await planOverviewRecompute(h.db, { languages: CHAIN, now: now() }); expect(tmdb.calls.length).toBe(calls); expect(plan.changes.every((c) => c.basis === "no_cache" && c.to.value === null)).toBe(true); expect(plan.changes).toHaveLength(3); // 不跑重算,直接刷新:按新规则清掉英文简介 await refreshMetadata(h.rt, id, { full: true }); expect((await entityMeta()).overview?.value).toBeNull(); const [e1, e2] = await eps(); expect(e1!.overview?.value).toBeNull(); expect(e2!.overview?.value).toBeNull(); expect(e2!.name?.value).toBe("Who Is the One?"); }); });