Files
UHD_Media_Tool/apps/server/test/subscriptions/subscriptions-c1a.test.ts
T
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

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 "../acquisition-organize/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);
});
});