test(subscriptions): overview, coverage matrix, auto-fill and form compatibility
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
410d7fd636
commit
124d6428b1
2 files changed
+785
No files matched your search
@@ -0,0 +1,120 @@
|
||||
import { checkFillBudget, coverageRow, resolveAutoFill, subscriptionGroup } from "@umt/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// W9-E 纯函数:总览分组顺序、矩阵一行的格状态、自动补全取值、补全预算。
|
||||
|
||||
describe("subscriptionGroup", () => {
|
||||
const base = {
|
||||
status: "active" as const,
|
||||
mediaKind: "series" as const,
|
||||
mode: "continuous" as const,
|
||||
totalsKnown: true,
|
||||
anyProgress: false,
|
||||
allSlotsComplete: false,
|
||||
};
|
||||
it("按 暂停 → 待定 → 已完成 → 追更中 → 待搜索 判定;cancelled 不进总览", () => {
|
||||
expect(subscriptionGroup({ ...base, status: "paused", totalsKnown: false }).group).toBe("paused");
|
||||
expect(subscriptionGroup({ ...base, status: "pending" }).group).toBe("pending");
|
||||
expect(subscriptionGroup({ ...base, totalsKnown: false, anyProgress: true }).group).toBe("pending");
|
||||
// 电影不看总集数
|
||||
expect(subscriptionGroup({ ...base, mediaKind: "movie", totalsKnown: false }).group).toBe("waiting");
|
||||
expect(subscriptionGroup({ ...base, status: "completed" })).toEqual({
|
||||
group: "completed",
|
||||
awaitingNewSeason: false,
|
||||
});
|
||||
expect(subscriptionGroup({ ...base, anyProgress: true, allSlotsComplete: true })).toEqual({
|
||||
group: "completed",
|
||||
awaitingNewSeason: true,
|
||||
});
|
||||
expect(subscriptionGroup({ ...base, mode: "once", allSlotsComplete: true }).awaitingNewSeason).toBe(false);
|
||||
expect(subscriptionGroup({ ...base, anyProgress: true }).group).toBe("following");
|
||||
expect(subscriptionGroup(base).group).toBe("waiting");
|
||||
expect(subscriptionGroup({ ...base, status: "cancelled" }).group).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("coverageRow", () => {
|
||||
const cols = [1, 2, 3, 4, 5, 6].map((e) => ({
|
||||
unitKey: `s1e${e}`,
|
||||
episode: e,
|
||||
airDate: e === 6 ? "2099-01-01" : null,
|
||||
totalKnown: true,
|
||||
}));
|
||||
it("已入库 > 在途(含整季通配)> 范围外/未播出 > 缺失;可升级与延后标记", () => {
|
||||
const r = coverageRow({
|
||||
columns: [...cols, { unitKey: "s2e1", episode: 1, totalKnown: false }],
|
||||
covered: new Map([
|
||||
["s1e1", { assetId: "a1", quality: { resolution: "2160p", source: "REMUX", label: "2160p REMUX" }, pri: 100 }],
|
||||
["s1e2", { assetId: "a1", pri: 97 }],
|
||||
]),
|
||||
inflight: [
|
||||
{ id: "p1", stage: "downloading", unitKeys: ["s1e3"] },
|
||||
{ id: "p2", stage: "reserved", unitKeys: ["s2*"] },
|
||||
],
|
||||
deferredKeys: ["s1e4"],
|
||||
range: { end: 4 },
|
||||
today: "2026-09-28",
|
||||
upgrade: { enabled: true, targetPri: 100 },
|
||||
});
|
||||
const by = Object.fromEntries(r.cells.map((c) => [c.unitKey, c]));
|
||||
expect(by.s1e1).toMatchObject({ state: "have", assetId: "a1", pri: 100, upgradable: false });
|
||||
expect(by.s1e1!.quality?.label).toBe("2160p REMUX");
|
||||
expect(by.s1e2).toMatchObject({ state: "have", upgradable: true });
|
||||
expect(by.s1e3).toMatchObject({ state: "inflight", planId: "p1", planStage: "downloading" });
|
||||
expect(by.s1e4).toMatchObject({ state: "missing", deferred: true, reason: null });
|
||||
expect(by.s1e5).toMatchObject({ state: "skipped", reason: "out_of_range" });
|
||||
expect(by.s1e6).toMatchObject({ state: "skipped", reason: "out_of_range" });
|
||||
expect(by.s2e1).toMatchObject({ state: "inflight", planId: "p2" });
|
||||
expect(r.summary).toEqual({ have: 2, inflight: 2, missing: 1, skipped: 2 });
|
||||
expect(r.minPri).toBe(97);
|
||||
});
|
||||
it("未播出跳过;总集数未知的缺失标 suspected", () => {
|
||||
const r = coverageRow({
|
||||
columns: [
|
||||
{ unitKey: "s1e1", episode: 1, airDate: "2099-01-01", totalKnown: true },
|
||||
{ unitKey: "s1e2", episode: 2, totalKnown: false },
|
||||
],
|
||||
covered: new Map(),
|
||||
inflight: [],
|
||||
today: "2026-09-28",
|
||||
});
|
||||
expect(r.cells.map((c) => [c.state, c.reason])).toEqual([
|
||||
["skipped", "not_aired"],
|
||||
["missing", "suspected"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("自动补全开关与补全预算", () => {
|
||||
it("订阅显式值优先;inherit 取规则链上最后一个设了 autoFill 的组;都没设为开", () => {
|
||||
expect(resolveAutoFill("off", [{ id: "g", autoFill: true }])).toMatchObject({
|
||||
enabled: false,
|
||||
source: "subscription",
|
||||
});
|
||||
expect(
|
||||
resolveAutoFill("inherit", [
|
||||
{ id: "a", autoFill: true },
|
||||
{ id: "b", autoFill: false },
|
||||
{ id: "c", autoFill: null },
|
||||
]),
|
||||
).toEqual({ enabled: false, mode: "inherit", source: "rule_group", ruleGroupId: "b" });
|
||||
expect(resolveAutoFill(undefined, [{ id: "a", autoFill: null }])).toEqual({
|
||||
enabled: true,
|
||||
mode: "inherit",
|
||||
source: "default",
|
||||
});
|
||||
});
|
||||
it("单元数与字节按日累计;未设维度不限;体积未知只看单元数", () => {
|
||||
expect(checkFillBudget(undefined, { unitsToday: 99, bytesToday: 1e12 }, { units: 5, bytes: 1e9 }).allow).toBe(true);
|
||||
const d = checkFillBudget(
|
||||
{ unitsPerDay: 3, bytesPerDay: 100 },
|
||||
{ unitsToday: 2, bytesToday: 50 },
|
||||
{ units: 2, bytes: 60 },
|
||||
);
|
||||
expect(d.allow).toBe(false);
|
||||
expect(d.violations.map((v) => v.kind)).toEqual(["units", "bytes"]);
|
||||
expect(
|
||||
checkFillBudget({ bytesPerDay: 100 }, { unitsToday: 0, bytesToday: 99 }, { units: 1, bytes: null }).allow,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user