Files
rootandClaude Opus 5.5 b61335eaf6 test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行
- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀,
  w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名
- vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行);
  pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s
- CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争)
- ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:54:04 +08:00

149 lines
6.7 KiB
TypeScript

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<string, unknown> | 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<string, Final>;
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<string, Final | undefined>), 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?");
});
});