From 124d6428b16b7fa21c4d5deee4a7361e38e8e2bc Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 15:54:29 +0800 Subject: [PATCH] test(subscriptions): overview, coverage matrix, auto-fill and form compatibility Co-Authored-By: Claude Opus 5.5 --- .../subscriptions/overview-domain.test.ts | 120 ++++ .../subscriptions/subscriptions-v2.test.ts | 665 ++++++++++++++++++ 2 files changed, 785 insertions(+) create mode 100644 apps/server/test/subscriptions/overview-domain.test.ts create mode 100644 apps/server/test/subscriptions/subscriptions-v2.test.ts diff --git a/apps/server/test/subscriptions/overview-domain.test.ts b/apps/server/test/subscriptions/overview-domain.test.ts new file mode 100644 index 00000000..f84b3019 --- /dev/null +++ b/apps/server/test/subscriptions/overview-domain.test.ts @@ -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); + }); +}); diff --git a/apps/server/test/subscriptions/subscriptions-v2.test.ts b/apps/server/test/subscriptions/subscriptions-v2.test.ts new file mode 100644 index 00000000..aa28b637 --- /dev/null +++ b/apps/server/test/subscriptions/subscriptions-v2.test.ts @@ -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>; + const db = (): Kysely => 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>; + }; + + 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"); + }); +});