Files
UHD_Media_Tool/apps/server/test/subscriptions/subscriptions-c1a.test.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

514 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});