- 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]>
213 lines
9.8 KiB
TypeScript
213 lines
9.8 KiB
TypeScript
import { createMockTmdb, type MockTmdb } 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 { seasonsToRefresh } from "../../src/modules/sources-identity/service/metadata-rich.ts";
|
|
import { createHarness, type Harness, json, pgAvailable } from "./harness.ts";
|
|
|
|
const up = await pgAvailable();
|
|
type Final = { value: unknown; source: string; language: string | null; locked: boolean };
|
|
|
|
describe("seasonsToRefresh", () => {
|
|
it("full 拉全部正片季;默认 = 追踪 ∪ 库里已有,最多 8 季;都没有时不拉", () => {
|
|
expect(seasonsToRefresh([0, 1, 2, 3], { full: true, tracked: [], inLibrary: [] })).toEqual([1, 2, 3]);
|
|
expect(seasonsToRefresh([0, 1, 2, 3], { full: false, tracked: [], inLibrary: [] })).toEqual([]);
|
|
expect(seasonsToRefresh([1, 2, 3, 4], { full: false, tracked: [2], inLibrary: [1, 9] })).toEqual([1, 2]);
|
|
const many = Array.from({ length: 20 }, (_, i) => i + 1);
|
|
expect(seasonsToRefresh(many, { full: false, tracked: [], inLibrary: many })).toEqual(many.slice(-8));
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!up)("MD-1 语言回退链 + 每集元数据", () => {
|
|
let h: Harness;
|
|
let tmdb: MockTmdb;
|
|
let id: string;
|
|
const actor = { type: "user" as const, id: "tester" };
|
|
|
|
beforeAll(async () => {
|
|
tmdb = await createMockTmdb();
|
|
h = await createHarness("md1", { tmdbUrl: tmdb.url, now: () => new Date("2026-01-01T00:00:00Z") });
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await tmdb?.close();
|
|
});
|
|
|
|
const entity = () => h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
|
const episodes = () =>
|
|
h.db
|
|
.selectFrom("content_unit")
|
|
.selectAll()
|
|
.where("media_entity_id", "=", id)
|
|
.where("kind", "=", "episode")
|
|
.orderBy("season_number")
|
|
.orderBy("episode_number")
|
|
.execute();
|
|
|
|
it("建档后 full 刷新:标题取 CN 别名「人生切割术」,中文名 / 简介带语言,别名标语言", async () => {
|
|
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "95396", kind: "tv" }, actor);
|
|
id = r.mediaEntityId;
|
|
// details() 的 zh-CN name 是原名(TMDB 缺 zh-CN 译名时回退)
|
|
expect((await entity()).title).toBe("Severance");
|
|
const before = await episodes();
|
|
expect(before.map((u) => [u.season_number, u.episode_number, u.status])).toEqual([
|
|
[1, 1, "provisional"],
|
|
[1, 2, "provisional"],
|
|
[1, 3, "provisional"],
|
|
[2, 1, "provisional"],
|
|
]);
|
|
|
|
const acc = await json(await h.req(`/media-entities/${id}/refresh?full=1`, { method: "POST" }));
|
|
expect(acc.status).toBe(200);
|
|
expect(h.engine.enqueued.at(-1)).toMatchObject({
|
|
task: "refreshMetadata",
|
|
input: { mediaEntityId: id, full: true },
|
|
});
|
|
|
|
const res = await refreshMetadata(h.rt, id, { full: true });
|
|
expect(res.status).toBe("refreshed");
|
|
expect(res.titles).toEqual({ title: "人生切割术", language: "zh-CN", titleZh: "人生切割术" });
|
|
const e = await entity();
|
|
expect(e.title).toBe("人生切割术");
|
|
const meta = e.metadata as Record<string, Final>;
|
|
expect(meta.title).toMatchObject({ value: "人生切割术", language: "zh-CN", source: "tmdb" });
|
|
expect(meta.titleZh).toMatchObject({ value: "人生切割术", language: "zh-CN" });
|
|
expect(meta.overview).toMatchObject({ value: "故事发生在一家名为卢蒙的超级大公司内。", language: "zh-CN" });
|
|
expect(meta.genres?.value).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]);
|
|
expect(meta.voteAverage?.value).toBe(8.4);
|
|
expect(meta.airStatus?.value).toBe("Returning Series");
|
|
expect(meta.platform?.value).toBe("Apple TV");
|
|
expect(meta.backdropPath?.value).toBe("/severance-bg.jpg");
|
|
expect((meta.cast?.value as { name: string }[] | undefined)?.[0]?.name).toBe("亚当·斯科特");
|
|
expect(meta.externalIds?.value).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null });
|
|
// 同字段只留一行 tmdb 来源(语言变了删旧行)
|
|
const titleRows = await h.db
|
|
.selectFrom("metadata_field")
|
|
.select(["language"])
|
|
.where("media_entity_id", "=", id)
|
|
.where("field", "=", "title")
|
|
.execute();
|
|
expect(titleRows).toEqual([{ language: "zh-CN" }]);
|
|
|
|
const aliases = await h.db
|
|
.selectFrom("title_alias")
|
|
.select(["alias", "language", "source", "kind"])
|
|
.where("media_entity_id", "=", id)
|
|
.execute();
|
|
const lang = (a: string) => aliases.find((x) => x.alias === a)?.language;
|
|
expect(lang("人生切割术")).toBe("zh-CN");
|
|
// 繁简归一化后与「人生切割术」同键,不重复登记
|
|
expect(aliases.filter((a) => a.alias === "人生切割術")).toHaveLength(0);
|
|
expect(lang("生活割離術")).toBe("zh-HK");
|
|
expect(aliases.every((a) => a.source === "tmdb")).toBe(true);
|
|
});
|
|
|
|
it("每集元数据:字段级写入 content_unit,编号与已有单元不变,名称按链回退", async () => {
|
|
const units = await episodes();
|
|
expect(units).toHaveLength(4);
|
|
const [e1, e2, e3, s2e1] = units;
|
|
const m = (u: (typeof units)[number]) => u.metadata as Record<string, Final>;
|
|
expect(e1!.title).toBe("有关地狱的好消息");
|
|
expect(e1!.air_date).toBeTruthy();
|
|
expect(m(e1!).name).toMatchObject({ value: "有关地狱的好消息", language: "zh-CN", source: "tmdb" });
|
|
expect(m(e1!).overview?.value).toBe("马克升职成为部门主管。");
|
|
expect(m(e1!).runtime?.value).toBe(59);
|
|
expect(m(e1!).stillPath?.value).toBe("/sev-101.jpg");
|
|
expect(m(e1!).voteAverage?.value).toBe(8.2);
|
|
expect(m(e2!).name).toMatchObject({ value: "半循環", language: "zh-TW" });
|
|
expect(m(e3!).name).toMatchObject({ value: "In Perpetuity", language: "en-US" });
|
|
expect(m(e3!).overview?.value).toBeNull();
|
|
// 未播出:占位名,语言未知
|
|
expect(m(s2e1!).name).toMatchObject({ value: "第 1 集", language: null });
|
|
// 编号不被元数据刷新改动;与 TMDB 季集结构对上的临时单元转正式(DH-1,id 不变)
|
|
expect(units.map((u) => [u.season_number, u.episode_number, u.status, u.numbering_locked])).toEqual([
|
|
[1, 1, "confirmed", false],
|
|
[1, 2, "confirmed", false],
|
|
[1, 3, "confirmed", false],
|
|
[2, 1, "confirmed", false],
|
|
]);
|
|
const s1 = await h.db
|
|
.selectFrom("content_unit")
|
|
.selectAll()
|
|
.where("media_entity_id", "=", id)
|
|
.where("kind", "=", "season")
|
|
.where("season_number", "=", 1)
|
|
.executeTakeFirstOrThrow();
|
|
expect((s1.metadata as Record<string, Final>).posterPath?.value).toBe("/sev-s1.jpg");
|
|
expect((s1.metadata as Record<string, Final>).overview?.value).toContain("卢蒙");
|
|
});
|
|
|
|
it("增量:再刷新没有变化的单元不改写;锁定集名后刷新不覆盖,解锁回到来源值", async () => {
|
|
const at = async () =>
|
|
(
|
|
await h.db
|
|
.selectFrom("metadata_field")
|
|
.select(["fetched_at"])
|
|
.where("content_unit_id", "is not", null)
|
|
.execute()
|
|
)
|
|
.map((x) => x.fetched_at.getTime())
|
|
.sort();
|
|
const t0 = await at();
|
|
const again = await refreshMetadata(h.rt, id, { full: true });
|
|
expect(again.episodes?.every((s) => s.changedUnits === 0 && s.created === 0)).toBe(true);
|
|
expect(await at()).toEqual(t0);
|
|
|
|
const e1 = (await episodes())[0]!;
|
|
const put = await json(
|
|
await h.req(`/content-units/${e1.id}/fields/name`, { method: "PUT", body: { value: "自定义标题" } }),
|
|
);
|
|
expect(put.status).toBe(200);
|
|
expect(put.body.data).toMatchObject({ contentUnitId: e1.id, lockedFields: ["name"] });
|
|
const bad = await json(
|
|
await h.req(`/content-units/${e1.id}/fields/airDate`, { method: "PUT", body: { value: "x" } }),
|
|
);
|
|
expect(bad.status).toBe(400);
|
|
|
|
await refreshMetadata(h.rt, id, { full: true });
|
|
let u = (await episodes())[0]!;
|
|
expect(u.title).toBe("自定义标题");
|
|
expect(u.field_locks).toEqual(["name"]);
|
|
expect((u.metadata as Record<string, Final>).name).toMatchObject({
|
|
value: "自定义标题",
|
|
source: "user",
|
|
locked: true,
|
|
});
|
|
|
|
const del = await json(await h.req(`/content-units/${e1.id}/fields/name`, { method: "DELETE" }));
|
|
expect(del.status).toBe(200);
|
|
u = (await episodes())[0]!;
|
|
expect(u.title).toBe("有关地狱的好消息");
|
|
expect(u.field_locks).toEqual([]);
|
|
});
|
|
|
|
it("设置 metadata.languages 换链:en-US 优先时标题取英文,中文名仍是 zh-CN 值", async () => {
|
|
await h.db
|
|
.insertInto("settings")
|
|
.values({ key: "metadata", value: JSON.stringify({ languages: ["en-US", "zh-CN"] }) } as never)
|
|
.execute();
|
|
const r = await refreshMetadata(h.rt, id);
|
|
expect(r.titles).toEqual({ title: "Severance", language: "en-US", titleZh: "人生切割术" });
|
|
const e = await entity();
|
|
expect(e.title).toBe("Severance");
|
|
expect((e.metadata as Record<string, Final>).overview).toMatchObject({
|
|
value: "Mark leads a team of office workers.",
|
|
language: "en-US",
|
|
});
|
|
await h.db.deleteFrom("settings").where("key", "=", "metadata").execute();
|
|
await refreshMetadata(h.rt, id);
|
|
expect((await entity()).title).toBe("人生切割术");
|
|
});
|
|
|
|
it("作品详情 API 带内容单元元数据;作品级字段来源不含集字段", async () => {
|
|
const d = await json(await h.req(`/media-entities/${id}/detail`));
|
|
expect(d.status).toBe(200);
|
|
const ep = d.body.data.contentUnits.find(
|
|
(u: { kind: string; episodeNumber: number | null; seasonNumber: number | null }) =>
|
|
u.kind === "episode" && u.seasonNumber === 1 && u.episodeNumber === 2,
|
|
);
|
|
expect(ep.metadata.name.value).toBe("半循環");
|
|
expect(d.body.data.metadataFields.some((f: { field: string }) => f.field === "stillPath")).toBe(false);
|
|
});
|
|
});
|