- 新增 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]>
666 lines
30 KiB
TypeScript
666 lines
30 KiB
TypeScript
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");
|
||
});
|
||
});
|