- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
514 lines
24 KiB
TypeScript
514 lines
24 KiB
TypeScript
import { type DB, sql } from "@umt/db";
|
||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||
import type { Kysely } from "kysely";
|
||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
import {
|
||
createHarness,
|
||
fakeDisk,
|
||
type Harness,
|
||
pgAvailable,
|
||
seedBase,
|
||
seedEntity,
|
||
seedRelease,
|
||
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||
import {
|
||
evaluateAcquisition,
|
||
subscriptionSavePath,
|
||
} from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||
import { submitDownload } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||
import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||
import { allOfAny, chainHard, mergeHard } from "../../src/modules/acquisition-organize/services/rules.ts";
|
||
import { calendarLabel, releaseDateOf } from "../../src/modules/acquisition-organize/services/subscription-calendar.ts";
|
||
import {
|
||
parseMpEffect,
|
||
parseMpResolution,
|
||
parseMpSource,
|
||
presetConditions,
|
||
} from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
|
||
|
||
// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 收录版本、
|
||
// 保存路径经路径映射传给 qB、库根用于首次整理、下载器实例、重置进度、日历。
|
||
|
||
const hasPg = await pgAvailable();
|
||
const SIZE = 300_000;
|
||
|
||
describe("C1-A 纯函数", () => {
|
||
it("MP 质量字段:标签 / 正则值 / 枚举都能识别", () => {
|
||
expect(parseMpResolution("4K|2160p|x2160")).toBe("4k");
|
||
expect(parseMpResolution("1080[pi]|x1080")).toBe("1080p");
|
||
expect(parseMpResolution("720P")).toBe("720p");
|
||
expect(parseMpResolution("480p")).toBeNull();
|
||
expect(parseMpSource("Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD")).toBe("bluray_disc");
|
||
expect(parseMpSource("Remux")).toBe("remux");
|
||
expect(parseMpSource("Blu-?Ray")).toBe("bluray");
|
||
expect(parseMpSource("UHD|UltraHD")).toBe("uhd");
|
||
expect(parseMpSource("WEB-?DL|WEB-?RIP")).toBe("web");
|
||
expect(parseMpSource("[Hx].?265|HEVC")).toBe("h265");
|
||
expect(parseMpEffect("Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+")).toBe("dv");
|
||
expect(parseMpEffect("Dolby[\\s.]*\\+?Atmos|Atmos")).toBe("atmos");
|
||
expect(parseMpEffect("[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+")).toBe("hdr");
|
||
expect(parseMpEffect("SDR")).toBe("sdr");
|
||
});
|
||
|
||
it("质量预设 → 硬条件 / 软偏好;UHD / Atmos 只能走包含", () => {
|
||
expect(presetConditions({ resolution: "4k", source: "web", effect: "dv" })).toEqual({
|
||
hard: { resolutions: ["2160p"], sources: ["WEB-DL", "WEBRip"], hdr: { anyOf: ["DV"] } },
|
||
soft: { sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] },
|
||
include: [],
|
||
});
|
||
expect(presetConditions({ source: "uhd", effect: "atmos" }).include).toEqual(["UHD|Ultra-?HD", "Atmos"]);
|
||
expect(presetConditions({ effect: "sdr" }).hard).toEqual({ hdr: { requireSdr: true } });
|
||
});
|
||
|
||
it("包含取 AND:多组合成前瞻正则;排除取并集", () => {
|
||
expect(allOfAny([["a", "b"]])).toEqual(["a", "b"]);
|
||
const [re] = allOfAny([["中字", "CHS"], ["UHD"]]);
|
||
const rx = new RegExp(re!, "i");
|
||
expect(rx.test("Show 2160p UHD 中字")).toBe(true);
|
||
expect(rx.test("Show 2160p 中字")).toBe(false);
|
||
expect(chainHard([{ hard: { include: ["x"] } }, { hard: { exclude: ["y"] } }, { hard: {} }])).toEqual({
|
||
include: ["x"],
|
||
exclude: ["y"],
|
||
});
|
||
expect(mergeHard({ exclude: ["a"] }, { exclude: ["b"] })).toEqual({ exclude: ["a", "b"] });
|
||
expect(mergeHard({ include: ["a"] }, {})).toEqual({ include: ["a"] });
|
||
});
|
||
|
||
it("保存路径:本机路径经映射换成下载器路径;下载器侧路径原样;都不匹配为 null;无映射同路径", () => {
|
||
const t = {
|
||
inst: { id: "i1" } as never,
|
||
mappings: [{ instanceId: "i1", downloaderPath: "/downloads", hostPath: "/mnt/cache" }],
|
||
};
|
||
expect(subscriptionSavePath(t, "/mnt/cache/tv/")).toBe("/downloads/tv");
|
||
expect(subscriptionSavePath(t, "/downloads/tv")).toBe("/downloads/tv");
|
||
expect(subscriptionSavePath(t, "/elsewhere")).toBeNull();
|
||
expect(subscriptionSavePath({ inst: t.inst, mappings: [] }, "/x/y")).toBe("/x/y");
|
||
});
|
||
|
||
it("日历标签与上映日期", () => {
|
||
expect(calendarLabel(1, [3])).toBe("S01E03");
|
||
expect(calendarLabel(1, [4, 3])).toBe("S01E03–E04");
|
||
expect(calendarLabel(2, [1, 3])).toBe("S02E01、E03");
|
||
expect(calendarLabel(null, [])).toBe("当期");
|
||
expect(releaseDateOf({ releaseDate: { value: "2026-10-02", source: "tmdb" } })).toBe("2026-10-02");
|
||
expect(releaseDateOf({})).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
|
||
let h: Harness;
|
||
let qb: MockQb;
|
||
let lib: TempTree;
|
||
let lib2: TempTree;
|
||
let cache: TempTree;
|
||
const disk = fakeDisk(10_000_000_000);
|
||
const torrents = new Map<string, Uint8Array>();
|
||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||
const db = (): Kysely<DB> => h.db;
|
||
|
||
beforeAll(async () => {
|
||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib-" });
|
||
lib2 = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib2-" });
|
||
cache = await createTempTree({ "Root.Pick.2021.1080p.BluRay.x264-GRP.mkv": SIZE }, { prefix: "umt-c1a-cache-" });
|
||
disk.state.watch = [cache.root];
|
||
disk.setPct(10);
|
||
h = await createHarness("subsc1a", {
|
||
diskUsage: disk.fn,
|
||
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||
});
|
||
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||
}, 60_000);
|
||
afterAll(async () => {
|
||
await h?.close();
|
||
await qb?.close();
|
||
for (const t of [lib, lib2, cache]) await t?.cleanup();
|
||
});
|
||
|
||
async function release(entityId: string, title: string) {
|
||
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
|
||
const id = await seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
|
||
torrents.set(id, t.bytes);
|
||
return id;
|
||
}
|
||
const privateGroups = (subId: string) =>
|
||
db()
|
||
.selectFrom("rule_group")
|
||
.selectAll()
|
||
.where(sql<string>`scope ->> 'subscriptionId'`, "=", subId)
|
||
.orderBy("created_at")
|
||
.execute();
|
||
const slotsOf = (subId: string) =>
|
||
db().selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
|
||
|
||
it("包含 / 排除 → 私有规则组:规则页不列出;修改 revision+1;清空软删;删除订阅软删,重新订阅重建", async () => {
|
||
const e = await seedEntity(db(), { kind: "series", title: "私有组剧", year: 2025 });
|
||
const c = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "series",
|
||
params: { include: "中字|CHS", exclude: ["HDTV"] },
|
||
});
|
||
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
||
const id = c.body.data.id as string;
|
||
let gs = await privateGroups(id);
|
||
expect(gs).toHaveLength(1);
|
||
expect(gs[0]).toMatchObject({ kind: "acquisition", deleted_at: null, revision: 1, ladder_required: false });
|
||
expect(gs[0]!.hard).toEqual({ include: ["中字|CHS"], exclude: ["HDTV"] });
|
||
const list = await h.api("GET", "/rules?limit=200");
|
||
expect(list.body.data.items.some((g: { id: string }) => g.id === gs[0]!.id)).toBe(false);
|
||
|
||
const p = await h.api("PATCH", `/subscriptions/${id}`, {
|
||
params: { include: ["中字"], exclude: ["HDTV", "CAM"] },
|
||
});
|
||
expect(p.status).toBe(200);
|
||
gs = await privateGroups(id);
|
||
expect(gs[0]).toMatchObject({ revision: 2, hard: { include: ["中字"], exclude: ["HDTV", "CAM"] } });
|
||
|
||
await h.api("PATCH", `/subscriptions/${id}`, { params: {} });
|
||
expect((await privateGroups(id))[0]!.deleted_at).not.toBeNull();
|
||
|
||
await h.api("PATCH", `/subscriptions/${id}`, { params: { exclude: ["CAM"] } });
|
||
const live = (await privateGroups(id)).filter((g) => !g.deleted_at);
|
||
expect(live).toHaveLength(1);
|
||
expect(live[0]!.hard).toEqual({ exclude: ["CAM"] });
|
||
|
||
const del = await h.api("POST", "/subscriptions/batch", { ids: [id], action: "delete" });
|
||
expect(del.body.data.results[0].ok).toBe(true);
|
||
expect((await privateGroups(id)).filter((g) => !g.deleted_at)).toHaveLength(0);
|
||
const re = await h.api("POST", `/subscriptions/${id}/resubscribe`);
|
||
expect(re.status, JSON.stringify(re.body)).toBe(200);
|
||
const again = (await privateGroups(id)).filter((g) => !g.deleted_at);
|
||
expect(again).toHaveLength(1);
|
||
expect(again[0]!.hard).toEqual({ exclude: ["CAM"] });
|
||
});
|
||
|
||
it("评估链纳入私有组:包含没命中 / 排除命中都拒绝;命中包含建计划;与收录版本自己的包含取 AND", async () => {
|
||
const e = await seedEntity(db(), { kind: "movie", title: "Filter Film", year: 2024 });
|
||
const c = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "movie",
|
||
params: { include: ["CHS"], exclude: ["HDTV"], resolution: "1080P" },
|
||
});
|
||
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
||
const [slot] = await slotsOf(c.body.data.id);
|
||
expect(slot).toBeTruthy();
|
||
const noInc = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP");
|
||
const r1 = await evaluateAcquisition(h.d, { sourceReleaseId: noInc });
|
||
expect(r1.plans).toHaveLength(0);
|
||
const cand = await db()
|
||
.selectFrom("artifact")
|
||
.select("content")
|
||
.where("title", "=", "candidate_evaluation")
|
||
.where(sql<boolean>`content->>'sourceReleaseId' = ${noInc}`)
|
||
.executeTakeFirstOrThrow();
|
||
expect(JSON.stringify(cand.content)).toContain("include_missed");
|
||
const excl = await release(e, "Filter.Film.2024.1080p.HDTV.CHS.x264-GRP");
|
||
expect((await evaluateAcquisition(h.d, { sourceReleaseId: excl })).plans).toHaveLength(0);
|
||
const ok = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP");
|
||
const r3 = await evaluateAcquisition(h.d, { sourceReleaseId: ok });
|
||
expect(r3.plans, JSON.stringify(r3)).toHaveLength(1);
|
||
// 收录版本自己也要求 "GRP2":与私有组的 CHS 取 AND
|
||
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", r3.plans[0]!).execute();
|
||
await db()
|
||
.updateTable("acquisition_slot")
|
||
.set({
|
||
status: "empty",
|
||
hard_conditions: JSON.stringify({ resolutions: ["1080p", "1080i"], include: ["GRP2"] }),
|
||
})
|
||
.where("id", "=", slot!.id)
|
||
.execute();
|
||
const onlyGrp = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP2");
|
||
expect((await evaluateAcquisition(h.d, { sourceReleaseId: onlyGrp })).plans).toHaveLength(0);
|
||
const both = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP2");
|
||
expect((await evaluateAcquisition(h.d, { sourceReleaseId: both })).plans).toHaveLength(1);
|
||
});
|
||
|
||
it("MP 分辨率 / 来源 / 特效:新建时建默认收录版本承载预设;改预设覆盖同一字段,手改过的字段保留;无法识别 400", async () => {
|
||
const e = await seedEntity(db(), { kind: "series", title: "预设剧", year: 2025 });
|
||
const bad = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "series",
|
||
params: { resolution: "480p" },
|
||
});
|
||
expect(bad.status).toBe(400);
|
||
const c = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "series",
|
||
params: { resolution: "4K|2160p|x2160", quality: "WEB-?DL|WEB-?RIP", effect: "杜比视界" },
|
||
});
|
||
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
||
const id = c.body.data.id as string;
|
||
expect(c.body.data.params).toMatchObject({ resolution: "4k", source: "web", effect: "dv" });
|
||
expect(c.body.data.params.quality).toBeUndefined();
|
||
let [slot] = await slotsOf(id);
|
||
expect(slot).toMatchObject({ name: "默认版本" });
|
||
expect(slot!.hard_conditions).toEqual({
|
||
resolutions: ["2160p"],
|
||
sources: ["WEB-DL", "WEBRip"],
|
||
hdr: { anyOf: ["DV"] },
|
||
});
|
||
expect(slot!.soft_preferences).toEqual({ sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] });
|
||
// 手改来源
|
||
await db()
|
||
.updateTable("acquisition_slot")
|
||
.set({ hard_conditions: JSON.stringify({ ...(slot!.hard_conditions as object), sources: ["WEB-DL"] }) })
|
||
.where("id", "=", slot!.id)
|
||
.execute();
|
||
const p = await h.api("PATCH", `/subscriptions/${id}`, { params: { resolution: "1080p" } });
|
||
expect(p.status).toBe(200);
|
||
[slot] = await slotsOf(id);
|
||
// 分辨率换成新预设;特效预设去掉 → 删;来源手改过 → 保留
|
||
expect(slot!.hard_conditions).toEqual({ resolutions: ["1080p", "1080i"], sources: ["WEB-DL"] });
|
||
expect(slot!.soft_preferences).toEqual({});
|
||
// 预设 UHD / Atmos 进私有组包含,与表单包含取 AND
|
||
await h.api("PATCH", `/subscriptions/${id}`, { params: { source: "uhd", include: ["中字"] } });
|
||
const g = (await privateGroups(id)).find((x) => !x.deleted_at)!;
|
||
expect((g.hard as { include: string[] }).include).toHaveLength(1);
|
||
expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X UHD 中字")).toBe(true);
|
||
expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X 中字")).toBe(false);
|
||
});
|
||
|
||
it("下载提交:订阅指定下载器与保存路径(本机路径经映射)→ 计划评估与 qB savepath;映射不到 → 不建计划", async () => {
|
||
const inst2 = await db()
|
||
.insertInto("downloader_instance")
|
||
.values({ name: "qb-c1a-2", base_url: qb.url, username: "admin", settings: JSON.stringify({ savePath: "/dl" }) })
|
||
.returning("id")
|
||
.executeTakeFirstOrThrow();
|
||
await db()
|
||
.insertInto("path_mapping")
|
||
.values({
|
||
view_kind: "downloader",
|
||
downloader_instance_id: inst2.id,
|
||
remote_prefix: "/dl",
|
||
local_prefix: cache.root,
|
||
})
|
||
.execute();
|
||
const e = await seedEntity(db(), { kind: "movie", title: "Save Path Film", year: 2023 });
|
||
const c = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "movie",
|
||
params: { downloaderInstanceId: inst2.id, downloadSavePath: `${cache.root}/movies`, resolution: "1080p" },
|
||
});
|
||
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
||
const rel = await release(e, "Save.Path.Film.2023.1080p.WEB-DL.x264-GRP");
|
||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel });
|
||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||
const plan = await db()
|
||
.selectFrom("acquisition_plan")
|
||
.selectAll()
|
||
.where("id", "=", ev.plans[0]!)
|
||
.executeTakeFirstOrThrow();
|
||
expect(plan.evaluation).toMatchObject({ instanceId: inst2.id, savePath: "/dl/movies" });
|
||
const s = await submitDownload(h.d, plan.id);
|
||
expect(s.status, JSON.stringify(s)).toBe("submitted");
|
||
const dt = await db()
|
||
.selectFrom("download_task")
|
||
.selectAll()
|
||
.where("id", "=", s.downloadTaskId!)
|
||
.executeTakeFirstOrThrow();
|
||
expect(dt.instance_id).toBe(inst2.id);
|
||
expect(dt.save_path).toBe("/dl/movies");
|
||
|
||
// 映射不到的保存路径:不建计划,理由可见
|
||
const e2 = await seedEntity(db(), { kind: "movie", title: "Unmapped Film", year: 2023 });
|
||
await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e2,
|
||
mediaKind: "movie",
|
||
params: { downloaderInstanceId: inst2.id, downloadSavePath: "/nowhere", resolution: "1080p" },
|
||
});
|
||
const rel2 = await release(e2, "Unmapped.Film.2023.1080p.WEB-DL.x264-GRP");
|
||
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 });
|
||
expect(ev2.plans).toHaveLength(0);
|
||
expect(ev2.skipped).toContainEqual({ sourceReleaseId: rel2, reason: "download_save_path_unmapped" });
|
||
});
|
||
|
||
it("库根:作品还没有路径绑定时,整理按订阅指定的库根;已有绑定仍按绑定", async () => {
|
||
const root2 = await db()
|
||
.insertInto("library_root")
|
||
.values({ name: "c1a-root2", media_kind: "series", local_path: lib2.root, management_mode: "managed" })
|
||
.returning("id")
|
||
.executeTakeFirstOrThrow();
|
||
const e = await seedEntity(db(), { kind: "movie", title: "Root Pick", year: 2021 });
|
||
const dry = () =>
|
||
planImport(h.d, {
|
||
sourcePaths: [cache.path("Root.Pick.2021.1080p.BluRay.x264-GRP.mkv")],
|
||
mediaEntityId: e,
|
||
action: "copy",
|
||
trigger: "manual",
|
||
actorType: "user",
|
||
dryRun: true,
|
||
});
|
||
const before = await dry();
|
||
expect(before.plan?.libraryRootId, JSON.stringify(before)).toBe(base.rootId);
|
||
const c = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "movie",
|
||
params: { libraryRootId: root2.id },
|
||
});
|
||
expect(c.status).toBe(201);
|
||
const after = await dry();
|
||
expect(after.plan?.libraryRootId, JSON.stringify(after)).toBe(root2.id);
|
||
});
|
||
|
||
it("重置:取消未提交的计划与下载已不在的计划,保留仍在下载的;清延后;排立即重评;已完成 409", async () => {
|
||
const e = await seedEntity(db(), { kind: "series", title: "Reset Show", year: 2024 });
|
||
const c = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "series",
|
||
params: { resolution: "1080p" },
|
||
});
|
||
const id = c.body.data.id as string;
|
||
const [slot] = await slotsOf(id);
|
||
const r1 = await release(e, "Reset.Show.S01E01.1080p.WEB-DL.x264-GRP");
|
||
const r2 = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x264-GRP");
|
||
const r3 = await release(e, "Reset.Show.S01E03.1080p.WEB-DL.x264-GRP");
|
||
const [p1] = (await evaluateAcquisition(h.d, { sourceReleaseId: r1 })).plans;
|
||
const [p2] = (await evaluateAcquisition(h.d, { sourceReleaseId: r2 })).plans;
|
||
const [p3] = (await evaluateAcquisition(h.d, { sourceReleaseId: r3 })).plans;
|
||
expect([p1, p2, p3].every(Boolean)).toBe(true);
|
||
// p1 在下载;p2 已提交但下载任务被移除;p3 只预留
|
||
expect((await submitDownload(h.d, p1!)).status).toBe("submitted");
|
||
const s2 = await submitDownload(h.d, p2!);
|
||
await db()
|
||
.updateTable("download_task")
|
||
.set({ removed_at: new Date() })
|
||
.where("id", "=", s2.downloadTaskId!)
|
||
.execute();
|
||
const r2b = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x265-OTHER");
|
||
await evaluateAcquisition(h.d, { sourceReleaseId: r2b });
|
||
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength(
|
||
1,
|
||
);
|
||
|
||
h.engine.enqueued.length = 0;
|
||
const res = await h.api("POST", `/subscriptions/${id}/reset`);
|
||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||
expect(res.body.data).toMatchObject({
|
||
subscriptionId: id,
|
||
keptPlans: [p1],
|
||
clearedDeferrals: 1,
|
||
reevaluated: true,
|
||
});
|
||
expect([...res.body.data.releasedPlans].sort()).toEqual([p2, p3].sort());
|
||
const statuses = await db()
|
||
.selectFrom("acquisition_plan")
|
||
.select(["id", "status", "status_reason"])
|
||
.where("id", "in", [p1!, p2!, p3!])
|
||
.execute();
|
||
expect(statuses.find((x) => x.id === p1)?.status).toBe("submitted");
|
||
expect(statuses.filter((x) => x.status === "cancelled").map((x) => x.status_reason)).toEqual([
|
||
"subscription_reset",
|
||
"subscription_reset",
|
||
]);
|
||
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength(
|
||
0,
|
||
);
|
||
expect((await slotsOf(id))[0]!.next_evaluate_at).not.toBeNull();
|
||
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
|
||
const audit = await db()
|
||
.selectFrom("audit_event")
|
||
.select("action")
|
||
.where("target_id", "=", id)
|
||
.where("action", "=", "subscription.reset")
|
||
.execute();
|
||
expect(audit).toHaveLength(1);
|
||
|
||
await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", id).execute();
|
||
expect((await h.api("POST", `/subscriptions/${id}/reset`)).status).toBe(409);
|
||
});
|
||
|
||
it("日历:集播出日期同日同季聚合,状态取主版本;没有播出日期列 unknown;电影取上映日期;跨度超限 400", async () => {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const addDays = (n: number) => new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10);
|
||
const e = await seedEntity(db(), { kind: "series", title: "日历剧", year: 2026 });
|
||
const season = await db()
|
||
.insertInto("content_unit")
|
||
.values({
|
||
media_entity_id: e,
|
||
kind: "season",
|
||
season_number: 1,
|
||
status: "confirmed",
|
||
episode_total: 4,
|
||
episode_total_source: "tmdb",
|
||
})
|
||
.returning("id")
|
||
.executeTakeFirstOrThrow();
|
||
const ep = async (n: number, air: string | null) =>
|
||
(
|
||
await db()
|
||
.insertInto("content_unit")
|
||
.values({
|
||
media_entity_id: e,
|
||
parent_unit_id: season.id,
|
||
kind: "episode",
|
||
season_number: 1,
|
||
episode_number: n,
|
||
status: "confirmed",
|
||
air_date: air,
|
||
})
|
||
.returning("id")
|
||
.executeTakeFirstOrThrow()
|
||
).id;
|
||
const e1 = await ep(1, addDays(-3));
|
||
await ep(2, addDays(-3));
|
||
await ep(3, addDays(5));
|
||
await ep(4, null);
|
||
const c = await h.api("POST", "/subscriptions", {
|
||
mediaEntityId: e,
|
||
mediaKind: "series",
|
||
seasonUnitId: season.id,
|
||
params: { resolution: "1080p" },
|
||
});
|
||
expect(c.status, JSON.stringify(c.body)).toBe(201);
|
||
const subId = c.body.data.id as string;
|
||
// E01 已入库(单收录版本:作品下资产都算)
|
||
const a = await db()
|
||
.insertInto("media_asset")
|
||
.values({
|
||
media_entity_id: e,
|
||
kind: "video",
|
||
size_bytes: SIZE,
|
||
media_info: JSON.stringify({ _umt: { quality: { resolution: "1080p", source: "WEB-DL" } } }),
|
||
})
|
||
.returning("id")
|
||
.executeTakeFirstOrThrow();
|
||
await db().insertInto("asset_unit_coverage").values({ media_asset_id: a.id, content_unit_id: e1 }).execute();
|
||
const m = await seedEntity(db(), { kind: "movie", title: "日历片", year: 2026 });
|
||
await db()
|
||
.updateTable("media_entity")
|
||
.set({ metadata: JSON.stringify({ releaseDate: { value: addDays(10), source: "tmdb" } }) })
|
||
.where("id", "=", m)
|
||
.execute();
|
||
await h.api("POST", "/subscriptions", { mediaEntityId: m, mediaKind: "movie" });
|
||
const m2 = await seedEntity(db(), { kind: "movie", title: "无日期片", year: 2026 });
|
||
await h.api("POST", "/subscriptions", { mediaEntityId: m2, mediaKind: "movie" });
|
||
|
||
const cal = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}`);
|
||
expect(cal.status, JSON.stringify(cal.body)).toBe(200);
|
||
const mine = cal.body.data.entries.filter((x: { subscriptionId: string }) => x.subscriptionId === subId);
|
||
expect(mine).toEqual([
|
||
expect.objectContaining({
|
||
date: addDays(-3),
|
||
seasonNumber: 1,
|
||
episodes: [1, 2],
|
||
label: "S01E01–E02",
|
||
state: "partial",
|
||
have: 1,
|
||
total: 2,
|
||
}),
|
||
expect.objectContaining({ date: addDays(5), episodes: [3], state: "upcoming", have: 0, total: 1 }),
|
||
]);
|
||
expect(cal.body.data.unknown).toContainEqual(
|
||
expect.objectContaining({ subscriptionId: subId, seasonNumber: 1, episodes: [4] }),
|
||
);
|
||
expect(cal.body.data.entries).toContainEqual(
|
||
expect.objectContaining({ title: "日历片", date: addDays(10), label: "上映", state: "upcoming" }),
|
||
);
|
||
expect(cal.body.data.unknown).toContainEqual(expect.objectContaining({ title: "无日期片", episodes: [] }));
|
||
const movies = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}&mediaKind=movie`);
|
||
expect(movies.body.data.entries.every((x: { mediaKind: string }) => x.mediaKind === "movie")).toBe(true);
|
||
expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(90)}`)).status).toBe(400);
|
||
expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(-1)}`)).status).toBe(400);
|
||
});
|
||
});
|