Files
UHD_Media_Tool/apps/server/test/sources-identity/episode-mapping-api.test.ts
T

311 lines
14 KiB
TypeScript

import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts";
// 集数映射接口(docs/plan/episode-resolution.md 批次 4):规则增删改与唯一约束、写入校验、模拟预览(纯读)、
// 写入后按作品重算、待处理 episode_mapping 的确认建议 / 忽略并建忽略规则。
const pg = await pgAvailable();
const weekly = (n: number, start: string): TmdbFixtureEpisode[] =>
Array.from({ length: n }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
}));
// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集)
const KUSURIYA: TmdbFixture = {
type: "tv",
id: 990101,
original_title: "Kusuriya no Hitorigoto",
original_language: "ja",
origin_country: ["JP"],
date: "2023-10-22",
titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" },
popularity: 30,
alternative_titles: [],
external_ids: {},
rich: { status: "Ended" },
seasons: [
{ season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") },
{ season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") },
],
} as TmdbFixture;
describe.skipIf(!pg)("集数映射接口", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
let kId: string;
let lilith40: string;
let lilith41: string;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] });
h = await createHarness("episode_mapping_api", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const resolve = async (title: string, publishedAt: string | null = null) => {
const r = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: String(810000 + ++n),
title,
subtitle: "药屋少女的呢喃",
source_published_at: publishedAt ? new Date(publishedAt) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, entityId: res.mediaEntityId! };
};
// biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
const er = async (id: string): Promise<any> =>
(
await h.db
.selectFrom("source_release")
.select("episode_resolution")
.where("id", "=", id)
.executeTakeFirstOrThrow()
).episode_resolution;
const openDecisions = () =>
h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) =>
json(await h.req(path, init));
const draft = { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 };
it("准备:季表就绪,Lilith-Raws 连续编号 40 / 41 待确认并开待处理(带建议规则与处理选项)", async () => {
kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId;
expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed");
// 发布时间贴着 S02E10 / S02E11 的播出日期(2025-03-14 / 03-21)
lilith40 = (
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z")
).id;
lilith41 = (
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z")
).id;
expect((await er(lilith40)).method).toBe("unresolved");
const ds = await openDecisions();
expect(ds).toHaveLength(1);
expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } });
expect((ds[0]!.options as { key: string; input: unknown }[]).map((o) => o.key)).toEqual([
"confirm_suggestion",
"ignore_rule",
]);
expect((ds[0]!.options as { input: unknown }[])[0]!.input).toEqual({
season: 1,
releaseGroup: "Lilith-Raws",
expression: "EP-30",
targetSeason: 2,
});
});
it("写入校验:表达式解析不了、表格区间重叠、special 没选类型都拒绝;只读 Key 不能写", async () => {
const path = `/media-entities/${kId}/episode-mapping-rules`;
const r1 = await call(path, { body: { kind: "expression", expression: "EP+abc" } });
expect(r1.status).toBe(400);
expect(r1.body.error.message).toContain("偏移表达式无法解析");
const r2 = await call(path, {
body: {
kind: "table",
tableEntries: [
{ from: { start: 26, end: 28 }, to: { season: 2, episode: 1 } },
{ from: { start: 28, end: 28 }, to: { season: 2, episode: 2 } },
],
},
});
expect(r2.status).toBe(400);
expect(r2.body.error.message).toContain("重叠");
expect((await call(path, { body: { kind: "special" } })).status).toBe(400);
expect((await call(path, { body: { kind: "expression", expression: "-12" }, key: READ_KEY })).status).toBe(403);
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
});
it("模拟预览:季表摘要、按发布组统计与建议;草稿规则把待确认变成按规则 S02E10,纯读不落库", async () => {
const base = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: {} });
expect(base.status).toBe(200);
expect(base.body.data.applicable).toBe(true);
expect(base.body.data.seasons).toMatchObject({
ended: true,
latestSeason: 2,
seasons: expect.arrayContaining([
{ season: 1, total: 12, aired: 12 },
{ season: 2, total: 13, aired: 13 },
]),
});
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
const lilith = base.body.data.groups.find((g: any) => g.releaseGroup === "Lilith-Raws");
expect(lilith).toMatchObject({
total: 2,
pending: 2,
methods: [{ method: "unresolved", count: 2 }],
suggestion: { season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 },
});
expect(base.body.data.changed).toBe(0);
const r = await call(`/media-entities/${kId}/episode-mapping/preview`, {
body: { releaseGroup: "lilith-raws", draft },
});
expect(r.status).toBe(200);
const items = r.body.data.items;
expect(items).toHaveLength(2);
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
const i40 = items.find((x: any) => x.sourceReleaseId === lilith40);
expect(i40).toMatchObject({
releaseGroup: "Lilith-Raws",
current: { method: "unresolved", source: { season: 1, episodes: { start: 40, end: 40 } } },
preview: { method: "rule", ruleId: "draft", canonical: { season: 2, episodes: { start: 10, end: 10 } } },
validation: "pass",
changed: true,
});
expect(r.body.data.changed).toBe(2);
expect(r.body.data.groups).toEqual([
expect.objectContaining({ releaseGroup: "Lilith-Raws", pending: 0, methods: [{ method: "rule", count: 2 }] }),
]);
// 草稿算出越界 → 校验不通过
const badDraft = await call(`/media-entities/${kId}/episode-mapping/preview`, {
body: { releaseGroup: "Lilith-Raws", draft: { ...draft, expression: "EP-20" } },
});
expect(badDraft.body.data.items[0]).toMatchObject({ validation: "fail", preview: { ruleFailed: "draft" } });
// 季过滤:规范季 2 也算命中(ANi 01 是第 1 季,不在其中)
const s2 = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: { season: 2, draft } });
expect(s2.body.data.items.map((x: { sourceReleaseId: string }) => x.sourceReleaseId).sort()).toEqual(
[lilith40, lilith41].sort(),
);
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
expect((await er(lilith40)).method).toBe("unresolved");
expect(await openDecisions()).toHaveLength(1);
});
let ruleA: string;
it("待处理「确认建议规则」:建规则 + 重算,资源解决,事项按用户选择关闭", async () => {
const d = (await openDecisions())[0]!;
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data).toMatchObject({
status: "resolved",
resolution: { optionKey: "confirm_suggestion", execution: { reresolve: { resolved: 2 } } },
});
const rules = await h.db.selectFrom("episode_mapping_rule").selectAll().execute();
expect(rules).toHaveLength(1);
expect(rules[0]).toMatchObject({
season: 1,
release_group: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
target_season: 2,
status: "active",
created_by_type: "user",
});
ruleA = rules[0]!.id;
expect(await er(lilith40)).toMatchObject({
method: "rule",
ruleId: ruleA,
canonical: { season: 2, episodes: { start: 10, end: 10 } },
});
expect(await openDecisions()).toHaveLength(0);
// 资源接口照常带出集数解析结果
const rel = await call(`/source-releases/${lilith41}`);
expect(rel.body.data.episodeResolution).toMatchObject({
method: "rule",
canonical: { season: 2, episodes: { start: 11, end: 11 } },
});
});
it("规则增改停删:同作用域只能一条启用(发布组忽略大小写),每次写入都重算并返回受影响数量", async () => {
const path = `/media-entities/${kId}/episode-mapping-rules`;
const dup = await call(path, { body: { ...draft, releaseGroup: "lilith-raws", expression: "EP-29" } });
expect(dup.status).toBe(409);
expect(dup.body.error.message).toContain("已有一条启用的规则");
// 修改表达式:两条都变
const upd = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { expression: "-31" } });
expect(upd.status, JSON.stringify(upd.body)).toBe(200);
expect(upd.body.data.rule).toMatchObject({ expression: "EP-31", status: "active" });
expect(upd.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 });
expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } });
// 停用:资源回到待确认、重新开事项;停用原因记为手动
const off = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "disabled" } });
expect(off.body.data.rule.status).toBe("disabled");
expect(JSON.parse(off.body.data.rule.disabledReason)).toMatchObject({ manual: true });
expect((await er(lilith40)).method).toBe("unresolved");
expect(await openDecisions()).toHaveLength(1);
// 停用后同作用域可以再建一条(表格规则);待处理随资源解决自动关闭
const b = await call(path, {
body: {
season: 1,
releaseGroup: "LILITH-RAWS",
kind: "table",
tableEntries: [
{ from: { start: 40, end: 40 }, to: { season: 2, episode: 10 } },
{ from: { start: 41, end: 41 }, to: { season: 2, episode: 11 } },
],
},
});
expect(b.status, JSON.stringify(b.body)).toBe(200);
expect(b.body.data.reresolve).toMatchObject({ resolved: 2 });
expect(await openDecisions()).toHaveLength(0);
const ruleB = b.body.data.rule.id as string;
// 再启用 A 与 B 冲突
expect(
(await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "active" } })).status,
).toBe(409);
// 列表:含停用的与停用原因
const list = await call(path);
expect(list.body.data.items.map((x: { id: string; status: string }) => [x.id, x.status])).toEqual(
expect.arrayContaining([
[ruleA, "disabled"],
[ruleB, "active"],
]),
);
// 删除 B:资源回到待确认
const del = await call(`/episode-mapping-rules/${ruleB}`, { method: "DELETE" });
expect(del.status).toBe(200);
expect(del.body.data.reresolve).toMatchObject({ changed: 2 });
expect(await h.db.selectFrom("episode_mapping_rule").select("id").where("id", "=", ruleB).execute()).toHaveLength(
0,
);
expect((await er(lilith41)).method).toBe("unresolved");
expect(await openDecisions()).toHaveLength(1);
});
it("待处理「忽略并建忽略规则」:建 special=ignore 规则,事项记为已忽略,资源不再待确认", async () => {
const d = (await openDecisions())[0]!;
// A 仍停用,同作用域没有启用的规则
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.status).toBe("dismissed");
const rule = await h.db
.selectFrom("episode_mapping_rule")
.selectAll()
.where("status", "=", "active")
.executeTakeFirstOrThrow();
expect(rule).toMatchObject({ kind: "special", special: "ignore", season: 1, release_group: "Lilith-Raws" });
expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null });
expect(await openDecisions()).toHaveLength(0);
});
});