test(subscriptions): overview, coverage matrix, auto-fill and form compatibility

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:54:29 +08:00
1 parent 410d7fd636
commit 124d6428b1
2 files changed
+785

No files matched your search

@@ -0,0 +1,665 @@
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 {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.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");
});
});