Files
UHD_Media_Tool/apps/server/test/subscriptions/subscriptions-v2.test.ts
T
rootandClaude Opus 5.5 c9e8120fc6 test: server 测试共用建库与假引擎,sse-pg 归入串行组
- 新增 apps/server/test/support/{pg,engine}.ts:测试库地址、可用性探测、schema 咨询锁、
  建 schema + 加锁迁移 + 删除、可选去重的假引擎;6 份 harness 改用它(acquisition-organize 与
  file-manager 原先迁移不加锁,并行下有冲突隐患,一并修正)
- sse-pg 起子进程,并行下子进程启动超时,归入 serial
- 修正 b61335e 挪动 / 改名测试后遗留的 import 排序与格式(当时 lint 检查经管道被吞掉退出码)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 17:40:43 +08:00

666 lines
30 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 } from "@umt/db";
import { makeTorrent } from "@umt/testkit";
import type { Kysely } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
} from "../acquisition-organize/harness.ts";
// W9-E 订阅重做后端:总览分组/排序/计数、覆盖矩阵(单集/季包/多版本/在途/临时单元/电影版本)、
// 多版本自动补全(开关、规则组继承、补全预算、手动补全)、表单字段与 MP/旧字段兼容、暂停/恢复/重新订阅/批量/排序。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
let h: Harness;
const disk = fakeDisk(10_000_000_000);
let base: Awaited<ReturnType<typeof seedBase>>;
const db = (): Kysely<DB> => h.db;
beforeAll(async () => {
disk.setPct(10);
h = await createHarness("subsv2", { diskUsage: disk.fn });
base = await seedBase(h.db, {
libraryPath: "/tmp/umt-nonexistent-lib",
cachePath: "/tmp",
qbUrl: "http://127.0.0.1:9",
});
}, 60_000);
afterAll(async () => {
await h?.close();
});
// ── 种子 ──
async function sub(
entityId: string | null,
o: {
kind?: "movie" | "series";
status?: "active" | "paused" | "pending" | "completed" | "cancelled";
params?: object;
mode?: "once" | "continuous";
lastProgressAt?: Date;
} = {},
) {
return (
await db()
.insertInto("subscription")
.values({
media_entity_id: entityId,
title_query: entityId ? null : "未建档",
media_kind: o.kind ?? "series",
mode: o.mode ?? "continuous",
status: o.status ?? "active",
params: JSON.stringify(o.params ?? {}),
last_progress_at: o.lastProgressAt ?? null,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function slot(
entityId: string,
subId: string,
name: string,
o: { position?: number; hard?: object; upgrade?: object; status?: string; currentAssetId?: string } = {},
) {
return (
await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: subId,
name,
position: o.position ?? 0,
hard_conditions: JSON.stringify(o.hard ?? {}),
upgrade_threshold: JSON.stringify(o.upgrade ?? {}),
...(o.status ? { status: o.status as never } : {}),
current_asset_id: o.currentAssetId ?? null,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function season(entityId: string, n: number, total: number | null) {
return (
await db()
.insertInto("content_unit")
.values({
media_entity_id: entityId,
kind: "season",
season_number: n,
status: "confirmed",
episode_total: total,
episode_total_source: total ? "tmdb" : null,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function episode(
entityId: string,
s: number,
e: number,
o: { airDate?: string; status?: "provisional" | "confirmed" } = {},
) {
return (
await db()
.insertInto("content_unit")
.values({
media_entity_id: entityId,
kind: "episode",
season_number: s,
episode_number: e,
status: o.status ?? "confirmed",
air_date: o.airDate ?? null,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function asset(entityId: string, o: { edition?: string; quality?: object } = {}) {
return (
await db()
.insertInto("media_asset")
.values({
media_entity_id: entityId,
kind: "video",
size_bytes: SIZE,
content_edition: o.edition ?? null,
media_info: JSON.stringify({ _umt: { quality: o.quality ?? { resolution: "1080p", source: "WEB-DL" } } }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function cover(assetId: string, unitIds: string[]) {
for (const u of unitIds)
await db().insertInto("asset_unit_coverage").values({ media_asset_id: assetId, content_unit_id: u }).execute();
}
async function ledger(slotId: string, unitId: string, assetId: string, pri: number, quality: object) {
await db()
.insertInto("slot_unit")
.values({
slot_id: slotId,
content_unit_id: unitId,
media_asset_id: assetId,
pri,
quality: JSON.stringify(quality),
})
.execute();
}
async function plan(slotId: string, entityId: string, status: "reserved" | "submitted", unitKeys: string[]) {
const t = makeTorrent({ name: `x${Math.random()}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const rel = await seedRelease(db(), {
siteId: base.siteId,
entityId,
title: `Seed.S01.1080p-${t.hash.slice(0, 6)}`,
size: SIZE,
hash: t.hash,
});
return (
await db()
.insertInto("acquisition_plan")
.values({
slot_id: slotId,
source_release_id: rel,
purpose: "fill",
status,
priority: "tracking",
evaluation: "{}",
payload_hash: t.hash,
idempotency_key: `seed:${t.hash}`,
unit_keys: unitKeys,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function rel(entityId: string, title: string) {
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
return seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
}
const cov = async (id: string) => {
const r = await h.api("GET", `/subscriptions/${id}/coverage?refresh=true`);
expect(r.status, JSON.stringify(r.body)).toBe(200);
return r.body.data;
};
const cellsOf = (
c: { seasons: { columns: { unitKey: string }[] }[]; rows: { slotId: string | null; cells: { state: string }[] }[] },
slotId: string | null,
) => {
const keys = c.seasons.flatMap((s) => s.columns.map((x) => x.unitKey));
const row = c.rows.find((r) => r.slotId === slotId)!;
return Object.fromEntries(keys.map((k, i) => [k, row.cells[i]])) as Record<string, Record<string, unknown>>;
};
it("覆盖矩阵:单集 / 季包 / 多版本 / 在途(含整季通配)/ 临时单元 / 未播出;缓存 30s", async () => {
const e = await seedEntity(db(), { kind: "series", title: "矩阵剧", year: 2025 });
await season(e, 1, 6);
const u = [
await episode(e, 1, 1),
await episode(e, 1, 2),
await episode(e, 1, 3),
await episode(e, 1, 4, { status: "provisional" }),
await episode(e, 1, 5, { airDate: "2099-01-01" }),
];
const s = await sub(e);
const p = await slot(e, s, "4K", { position: 0, upgrade: { enabled: true, basis: "priority", targetPri: 100 } });
const q = await slot(e, s, "1080p", { position: 1 });
// 主版本:季包资产覆盖 E1–E2(分集账本记 pri);E3 在途(单集计划)
const pack = await asset(e, { quality: { resolution: "2160p", source: "REMUX" } });
await cover(pack, [u[0]!, u[1]!]);
await ledger(p, u[0]!, pack, 100, { resolution: "2160p", source: "REMUX" });
await ledger(p, u[1]!, pack, 97, { resolution: "2160p", source: "REMUX" });
const p3 = await plan(p, e, "reserved", ["s1e3"]);
// 次版本:E1 单集已入库;整季通配在途(季包提交、集号未知)
const single = await asset(e);
await cover(single, [u[0]!]);
await ledger(q, u[0]!, single, 0, { resolution: "1080p", source: "WEB-DL" });
const qp = await plan(q, e, "submitted", ["s1*"]);
const c = await cov(s);
expect(c.mediaKind).toBe("series");
expect(c.seasons).toHaveLength(1);
expect(c.seasons[0]).toMatchObject({ seasonNumber: 1, total: 6, totalKnown: true, source: "tmdb" });
const cols = c.seasons[0].columns;
expect(cols.map((x: { unitKey: string }) => x.unitKey)).toEqual(["s1e1", "s1e2", "s1e3", "s1e4", "s1e5", "s1e6"]);
expect(cols.map((x: { temporary: boolean }) => x.temporary)).toEqual([false, false, false, true, false, true]);
expect(c.rows.map((r: { slotId: string | null; primary: boolean }) => [r.slotId, r.primary])).toEqual([
[null, false],
[p, true],
[q, false],
]);
const P = cellsOf(c, p);
expect(P.s1e1).toMatchObject({ state: "have", assetId: pack, pri: 100, upgradable: false });
expect((P.s1e1!.quality as { label: string }).label).toBe("2160p REMUX");
expect(P.s1e2).toMatchObject({ state: "have", pri: 97, upgradable: true });
expect(P.s1e3).toMatchObject({ state: "inflight", planId: p3, planStage: "reserved" });
expect(P.s1e4).toMatchObject({ state: "missing" });
expect(P.s1e5).toMatchObject({ state: "skipped", reason: "not_aired" });
expect(P.s1e6).toMatchObject({ state: "missing" });
const Q = cellsOf(c, q);
expect(Q.s1e1).toMatchObject({ state: "have", assetId: single });
for (const k of ["s1e2", "s1e3", "s1e4", "s1e5", "s1e6"])
expect(Q[k]).toMatchObject({ state: "inflight", planId: qp });
const any = cellsOf(c, null);
expect(any.s1e2).toMatchObject({ state: "have" });
expect(c.rows[1].summary).toEqual({ have: 2, inflight: 1, missing: 2, skipped: 1 });
// 缓存:不带 refresh 30s 内返回同一份
const a = await h.api("GET", `/subscriptions/${s}/coverage`);
await db().deleteFrom("acquisition_plan").where("id", "=", p3).execute();
const b = await h.api("GET", `/subscriptions/${s}/coverage`);
expect(b.body.data.computedAt).toBe(a.body.data.computedAt);
expect(cellsOf(await cov(s), p).s1e3).toMatchObject({ state: "missing" });
expect((await h.api("GET", "/subscriptions/00000000-0000-0000-0000-000000000000/coverage")).status).toBe(404);
});
it("覆盖矩阵:集范围裁剪;总集数未知列到已见最高集并标 suspected;电影按内容版本 × 收录版本", async () => {
const e = await seedEntity(db(), { kind: "series", title: "范围剧", year: 2025 });
await season(e, 1, null);
const e3 = await episode(e, 1, 3);
const s = await sub(e, { params: { episodeRange: { start: 2 } } });
const p = await slot(e, s, "main");
const a = await asset(e);
await cover(a, [e3]);
await ledger(p, e3, a, 0, {});
const c = await cov(s);
expect(c.seasons[0]).toMatchObject({ total: null, totalKnown: false });
const P = cellsOf(c, p);
expect(Object.keys(P)).toEqual(["s1e1", "s1e2", "s1e3"]);
expect(P.s1e1).toMatchObject({ state: "skipped", reason: "out_of_range" });
expect(P.s1e2).toMatchObject({ state: "missing", reason: "suspected" });
expect(P.s1e3).toMatchObject({ state: "have" });
const m = await seedEntity(db(), { kind: "movie", title: "矩阵电影", year: 2024 });
const ms = await sub(m, { kind: "movie", mode: "once" });
const ma = await asset(m, { quality: { resolution: "2160p", source: "REMUX" } });
const mp = await slot(m, ms, "4K", { position: 0, currentAssetId: ma, status: "satisfied" });
const mq = await slot(m, ms, "1080p", { position: 1 });
await db()
.updateTable("acquisition_slot")
.set({ completion: JSON.stringify({ kind: "movie", cuts: ["default", "Extended"] }) })
.where("id", "=", mq)
.execute();
const mc = await cov(ms);
expect(mc.seasons[0].columns.map((x: { edition: string }) => x.edition).sort()).toEqual(["Extended", "default"]);
expect(cellsOf(mc, mp)).toMatchObject({
"edition:default": { state: "have", assetId: ma },
"edition:Extended": { state: "skipped", reason: "not_required" },
});
expect(cellsOf(mc, mq)).toMatchObject({
"edition:default": { state: "missing" },
"edition:Extended": { state: "missing" },
});
});
it("总览:分组由服务端判定并给计数;卡片字段;按上次更新/名称/缺集排序;筛选与分页", async () => {
const tag = `总览${Math.random().toString(36).slice(2, 6)}`;
// 追更中:总集数已知,有入库
const e1 = await seedEntity(db(), { kind: "series", title: `${tag}甲`, year: 2025 });
await season(e1, 1, 3);
const e1u = await episode(e1, 1, 1);
const s1 = await sub(e1, { lastProgressAt: new Date("2026-01-03T00:00:00Z"), params: { siteIds: [base.siteId] } });
const s1p = await slot(e1, s1, "main");
const a1 = await asset(e1);
await cover(a1, [e1u]);
await ledger(s1p, e1u, a1, 0, {});
// 待搜索:总集数已知,没有入库/在途
const e2 = await seedEntity(db(), { kind: "series", title: `${tag}乙`, year: 2024 });
await season(e2, 1, 8);
const s2 = await sub(e2, { lastProgressAt: new Date("2026-01-05T00:00:00Z"), params: { searchIntervalHours: 0 } });
await slot(e2, s2, "main");
// 待定:总集数未知
const e3 = await seedEntity(db(), { kind: "series", title: `${tag}丙`, year: 2023 });
const s3 = await sub(e3, { lastProgressAt: new Date("2026-01-01T00:00:00Z") });
// 暂停
const e4 = await seedEntity(db(), { kind: "series", title: `${tag}丁`, year: 2022 });
const s4 = await sub(e4, { status: "paused" });
// 已完成(电影:唯一收录版本已入库)
const e5 = await seedEntity(db(), { kind: "movie", title: `${tag}戊`, year: 2021 });
const a5 = await asset(e5);
const s5 = await sub(e5, { kind: "movie", mode: "once" });
await slot(e5, s5, "main", { currentAssetId: a5, status: "satisfied" });
// 取消:不进总览
const e6 = await seedEntity(db(), { kind: "series", title: `${tag}己`, year: 2020 });
const s6 = await sub(e6, { status: "cancelled" });
const r = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}`);
expect(r.status, JSON.stringify(r.body)).toBe(200);
const d = r.body.data;
expect(d.counts).toEqual({ following: 1, waiting: 1, completed: 1, paused: 1, pending: 1 });
expect(d.total).toBe(5);
const byId = Object.fromEntries(d.items.map((x: { id: string }) => [x.id, x]));
expect(byId[s6]).toBeUndefined();
expect(byId[s1]).toMatchObject({
group: "following",
mediaEntityTitle: `${tag}甲`,
year: 2025,
progress: { have: 1, total: 3, inflight: 0 },
missing: 2,
sites: [{ id: base.siteId, name: "测试站" }],
defaultRuleGroups: true,
autoFill: { enabled: true, mode: "inherit", source: "default" },
searchEnabled: true,
lastUpdatedAt: "2026-01-03T00:00:00.000Z",
});
expect(byId[s1].ruleGroups.map((g: { name: string }) => g.name)).toEqual(["订阅默认"]);
expect(byId[s1].slots).toEqual([
expect.objectContaining({ slotId: s1p, primary: true, have: 1, missing: 2, total: 3, autoFill: true }),
]);
expect(byId[s2]).toMatchObject({ group: "waiting", searchEnabled: false, progress: { have: 0, total: 8 } });
expect(byId[s3]).toMatchObject({ group: "pending", progress: { total: null } });
expect(byId[s4].group).toBe("paused");
expect(byId[s5]).toMatchObject({ group: "completed", awaitingNewSeason: false, progress: { have: 1, total: 1 } });
// 默认按上次更新倒序(没写 last_progress_at 的取 created_at,晚于 2026-01)
const order = d.items.map((x: { id: string }) => x.id);
expect(order.indexOf(s2)).toBeLessThan(order.indexOf(s1));
expect(order.indexOf(s1)).toBeLessThan(order.indexOf(s3));
const byName = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&sort=name`);
expect(byName.body.data.items.map((x: { mediaEntityTitle: string }) => x.mediaEntityTitle.slice(-1))).toEqual(
["丙", "丁", "戊", "甲", "乙"].sort((a, b) => a.localeCompare(b, "zh")),
);
const miss = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&sort=missing`);
expect(miss.body.data.items[0].id).toBe(s2);
const g = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&group=following`);
expect(g.body.data.items.map((x: { id: string }) => x.id)).toEqual([s1]);
expect(g.body.data.counts.waiting).toBe(1);
const p1 = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&limit=2`);
expect(p1.body.data.items).toHaveLength(2);
const p2 = await h.api(
"GET",
`/subscriptions?q=${encodeURIComponent(tag)}&limit=2&cursor=${p1.body.data.nextCursor}`,
);
expect(p2.body.data.items.map((x: { id: string }) => x.id)).toEqual(order.slice(2, 4));
const cancelled = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&status=cancelled`);
expect(cancelled.body.data.items.map((x: { id: string; group: null }) => [x.id, x.group])).toEqual([[s6, null]]);
});
it("自动补全:主版本已满足的单元补其他版本(优先级 upgrade);主版本也缺的照常追更;关闭只记延后", async () => {
const e = await seedEntity(db(), { kind: "series", title: "Fill Show", year: 2025 });
await season(e, 1, 10);
const u1 = await episode(e, 1, 1);
const s = await sub(e);
const p = await slot(e, s, "4K", { position: 0, hard: { resolutions: ["2160p"] } });
const q = await slot(e, s, "1080p", { position: 1, hard: { resolutions: ["1080p"] } });
const a = await asset(e, { quality: { resolution: "2160p", source: "REMUX" } });
await cover(a, [u1]);
await ledger(p, u1, a, 0, { resolution: "2160p" });
const r1 = await rel(e, "Fill.Show.S01E01.1080p.WEB-DL.x264-GRP");
const ev1 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 });
expect(ev1.plans, JSON.stringify(ev1)).toHaveLength(1);
const fillPlan = await db()
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", ev1.plans[0]!)
.executeTakeFirstOrThrow();
expect(fillPlan).toMatchObject({ slot_id: q, priority: "upgrade", unit_keys: ["s1e1"] });
expect((fillPlan.evaluation as { autoFill?: boolean; primarySlotId?: string }).autoFill).toBe(true);
const r2 = await rel(e, "Fill.Show.S01E02.1080p.WEB-DL.x264-GRP");
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: r2 });
const chase = await db()
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", ev2.plans[0]!)
.executeTakeFirstOrThrow();
expect(chase).toMatchObject({ slot_id: q, priority: "tracking", unit_keys: ["s1e2"] });
expect((chase.evaluation as { autoFill?: boolean }).autoFill).toBeUndefined();
// 关闭:非主版本不建计划,候选记延后(未开自动补全),不进 slot_deferral
await db()
.updateTable("subscription")
.set({ params: JSON.stringify({ autoFill: "off" }) })
.where("id", "=", s)
.execute();
const r3 = await rel(e, "Fill.Show.S01E03.1080p.WEB-DL.x264-GRP");
const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: r3 });
expect(ev3.plans).toHaveLength(0);
expect(ev3.deferred).toEqual([{ sourceReleaseId: r3, slotId: q, reason: "auto_fill_off" }]);
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", q).execute()).toHaveLength(0);
const ov = await h.api("GET", "/subscriptions?q=Fill%20Show");
expect(ov.body.data.items[0].autoFill).toMatchObject({ enabled: false, mode: "off", source: "subscription" });
expect(ov.body.data.items[0].slots.map((x: { autoFill: boolean }) => x.autoFill)).toEqual([true, false]);
// 手动补全:不受开关限制,可限定单元
const f = await h.api("POST", `/subscriptions/${s}/slots/${q}/fill`, { unitKeys: ["s1e3"] });
expect(f.status).toBe(202);
const job = h.engine.enqueued.findLast((x) => x.task === "evaluateAcquisition")!;
expect(job.input).toEqual({ acquisitionSlotId: q, forceFill: { unitKeys: ["s1e3"] } });
const forced = await evaluateAcquisition(h.d, job.input as never);
const fp = await db().selectFrom("acquisition_plan").selectAll().where("id", "in", forced.plans).execute();
expect(fp.map((x) => [x.slot_id, x.unit_keys])).toEqual([[q, ["s1e3"]]]);
expect(
(await h.api("POST", `/subscriptions/${s}/slots/00000000-0000-0000-0000-000000000000/fill`, {})).status,
).toBe(404);
});
it("自动补全:继承规则组 autoFill;补全预算超出记延后(slot_deferral),电影同口径", async () => {
const g = await h.api("POST", "/rules", {
name: `不补全${Math.random().toString(36).slice(2, 6)}`,
ruleString: "1080P | 4K",
});
expect(g.status, JSON.stringify(g.body)).toBe(201);
await db().updateTable("rule_group").set({ auto_fill: false }).where("id", "=", g.body.data.id).execute();
const m = await seedEntity(db(), { kind: "movie", title: "Fill Movie", year: 2024 });
const ma = await asset(m, { quality: { resolution: "2160p", source: "REMUX" } });
const ms = await sub(m, { kind: "movie", mode: "once", params: { ruleGroupIds: [g.body.data.id] } });
await slot(m, ms, "4K", { position: 0, hard: { resolutions: ["2160p"] }, currentAssetId: ma, status: "satisfied" });
const mq = await slot(m, ms, "1080p", { position: 1, hard: { resolutions: ["1080p"] } });
const r1 = await rel(m, "Fill.Movie.2024.1080p.WEB-DL.x264-GRP");
const ev1 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 });
expect(ev1.deferred).toEqual([{ sourceReleaseId: r1, slotId: mq, reason: "auto_fill_off" }]);
const ov = await h.api("GET", "/subscriptions?q=Fill%20Movie");
expect(ov.body.data.items[0].autoFill).toMatchObject({ enabled: false, mode: "inherit", source: "rule_group" });
// 订阅显式打开 + 预算 0 单元/天 → 延后(记 slot_deferral,次日重评)
await db()
.updateTable("subscription")
.set({
params: JSON.stringify({ ruleGroupIds: [g.body.data.id], autoFill: "on", fillBudget: { unitsPerDay: 0 } }),
})
.where("id", "=", ms)
.execute();
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 });
expect(ev2.plans).toHaveLength(0);
expect(ev2.deferred).toEqual([{ sourceReleaseId: r1, slotId: mq, reason: "fill_budget" }]);
expect(await db().selectFrom("slot_deferral").select("reason").where("slot_id", "=", mq).execute()).toEqual([
{ reason: "fill_budget" },
]);
// 预算放开 → 补全计划(低优先级)
await db()
.updateTable("subscription")
.set({ params: JSON.stringify({ autoFill: "on", fillBudget: { unitsPerDay: 1, bytesPerDay: SIZE } }) })
.where("id", "=", ms)
.execute();
const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 });
expect(ev3.plans).toHaveLength(1);
const pl = await db()
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", ev3.plans[0]!)
.executeTakeFirstOrThrow();
expect(pl).toMatchObject({ slot_id: mq, priority: "upgrade" });
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", mq).execute()).toHaveLength(0);
});
it("表单:MP 字段与旧键兼容(规范化后只存规范键);校验 400;同作品同季 409;best_version → 升级策略", async () => {
const e = await seedEntity(db(), { kind: "series", title: "表单剧", year: 2025 });
const site = await db()
.selectFrom("site")
.select(["id", "key"])
.where("id", "=", base.siteId)
.executeTakeFirstOrThrow();
const inst = await db()
.selectFrom("downloader_instance")
.select(["id", "name"])
.where("id", "=", base.instanceId)
.executeTakeFirstOrThrow();
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: {
sites: [site.key],
downloader: inst.name,
save_path: "/downloads/tv",
start_episode: 2,
total_episode: 12,
manual_total_episode: 1,
include: "中字|CHS",
exclude: "(?i)HDTV",
keyword: "Form Show",
filter_groups: ["订阅默认"],
autoFillVersions: { enabled: false, budget: { perDay: 3, bytes: 1_000_000 } },
libraryRootId: base.rootId,
custom: "kept",
},
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const params = c.body.data.params;
const defGroup = await db()
.selectFrom("rule_group")
.select("id")
.where("name", "=", "订阅默认")
.executeTakeFirstOrThrow();
expect(params).toEqual({
siteIds: [site.id],
downloaderInstanceId: inst.id,
downloadSavePath: "/downloads/tv",
episodeRange: { start: 2 },
include: ["中字|CHS"],
exclude: ["HDTV"],
keywords: ["Form Show"],
ruleGroupIds: [defGroup.id],
autoFill: "off",
fillBudget: { unitsPerDay: 3, bytesPerDay: 1_000_000 },
libraryRootId: base.rootId,
custom: "kept",
});
const s1 = await db()
.selectFrom("content_unit")
.select(["episode_total", "episode_total_source", "episode_total_locked"])
.where("media_entity_id", "=", e)
.where("kind", "=", "season")
.executeTakeFirstOrThrow();
expect(s1).toEqual({ episode_total: 12, episode_total_source: "manual", episode_total_locked: true });
expect(
(
await db()
.selectFrom("subscription")
.select("last_progress_at")
.where("id", "=", c.body.data.id)
.executeTakeFirstOrThrow()
).last_progress_at,
).not.toBeNull();
// 同作品同季 → 409
const dup = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series" });
expect(dup.status).toBe(409);
expect(dup.body.error.message).toContain(c.body.data.id);
// 校验
const e2 = await seedEntity(db(), { kind: "series", title: "表单剧二", year: 2025 });
for (const bad of [
{ include: "(a+)+" },
{ exclude: "(\\d)\\1" },
{ sites: ["nosuchsite"] },
{ filter_groups: ["不存在的组"] },
{ episodeRange: { start: 5, end: 2 } },
{ fillBudget: { unitsPerDay: -1 } },
]) {
const r = await h.api("POST", "/subscriptions", { mediaEntityId: e2, mediaKind: "series", params: bad });
expect(r.status, JSON.stringify(bad)).toBe(400);
}
// PATCH:autoFill 布尔 → on/off;best_version → 收录版本升级策略
const sid = c.body.data.id;
const sl = await slot(e, sid, "main");
const u = await h.api("PATCH", `/subscriptions/${sid}`, {
params: { autoFill: true, best_version: 1, best_version_full: 1 },
});
expect(u.status, JSON.stringify(u.body)).toBe(200);
expect(u.body.data.params).toEqual({ autoFill: "on" });
const up = await db()
.selectFrom("acquisition_slot")
.select(["upgrade_threshold", "revision"])
.where("id", "=", sl)
.executeTakeFirstOrThrow();
expect(up.upgrade_threshold).toEqual({ enabled: true, mode: "full_pack" });
expect(up.revision).toBe(2);
await h.api("PATCH", `/subscriptions/${sid}`, { params: { best_version: 0 } });
expect(
(
await db()
.selectFrom("acquisition_slot")
.select("upgrade_threshold")
.where("id", "=", sl)
.executeTakeFirstOrThrow()
).upgrade_threshold,
).toEqual({ enabled: false, mode: "full_pack" });
});
it("暂停 / 恢复 / 重新订阅 / 批量 / 收录版本排序", async () => {
const e = await seedEntity(db(), { kind: "movie", title: "动作片", year: 2024 });
const s = await sub(e, { kind: "movie", mode: "once" });
const a = await slot(e, s, "A", { position: 0 });
const b = await slot(e, s, "B", { position: 1 });
const p = await h.api("POST", `/subscriptions/${s}/pause`);
expect(p.body.data).toMatchObject({ status: "paused", statusReason: "user_paused", nextSearchAt: null });
expect((await h.api("POST", `/subscriptions/${s}/resubscribe`)).status).toBe(409);
const r = await h.api("POST", `/subscriptions/${s}/resume`);
expect(r.body.data).toMatchObject({ status: "active", statusReason: "resumed" });
expect(r.body.data.nextSearchAt).not.toBeNull();
expect((await h.api("POST", `/subscriptions/${s}/resume`)).body.data.status).toBe("active");
await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", s).execute();
expect((await h.api("POST", `/subscriptions/${s}/pause`)).status).toBe(409);
const re = await h.api("POST", `/subscriptions/${s}/resubscribe`);
expect(re.body.data).toMatchObject({ status: "active", statusReason: "resubscribed" });
expect(
h.engine.enqueued.some(
(x) => x.task === "searchSubscription" && (x.input as { subscriptionId: string }).subscriptionId === s,
),
).toBe(true);
const o = await h.api("PUT", `/subscriptions/${s}/slot-order`, { slotIds: [b, a] });
expect(o.status, JSON.stringify(o.body)).toBe(200);
expect(o.body.data.map((x: { id: string; position: number }) => [x.id, x.position])).toEqual([
[b, 0],
[a, 1],
]);
expect((await h.api("PUT", `/subscriptions/${s}/slot-order`, { slotIds: [b] })).status).toBe(400);
const c = await cov(s);
expect(c.rows.find((x: { primary: boolean }) => x.primary).slotId).toBe(b);
const s2 = await sub(await seedEntity(db(), { kind: "movie", title: "批量片", year: 2024 }), { kind: "movie" });
const bp = await h.api("POST", "/subscriptions/batch", { ids: [s, s2], action: "pause" });
expect(bp.body.data.results).toEqual([
{ id: s, ok: true, error: null },
{ id: s2, ok: true, error: null },
]);
const bd = await h.api("POST", "/subscriptions/batch", {
ids: [s2, "00000000-0000-0000-0000-000000000000"],
action: "delete",
});
expect(bd.body.data.results.map((x: { ok: boolean }) => x.ok)).toEqual([true, false]);
expect(
(await db().selectFrom("subscription").select("status").where("id", "=", s2).executeTakeFirstOrThrow()).status,
).toBe("cancelled");
const au = await db().selectFrom("audit_event").select("action").where("target_id", "=", s2).execute();
expect(au.map((x) => x.action)).toContain("subscription.deleted");
});
});