test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行
- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀, w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名 - vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行); pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s - CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争) - ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b343dddbf6
commit
b61335eaf6
75 files changed
+159
-115
No files matched your search
@@ -0,0 +1,254 @@
|
||||
import { makeTorrent } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
// 规则 API:订阅/Slot/策略版本(发布=持续授权、回滚)/偏好标签抑制;新作品收录走已发布策略;outbox 路由。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe("outbox 路由", () => {
|
||||
const routes = createAcquisitionOrganizeModule().outboxRoutes!({} as never);
|
||||
it("release.resolved → evaluateAcquisition;只有下载完成才建 verifyAndImport", () => {
|
||||
const ev = (type: string, payload: unknown) => ({ id: "1", type, payload, at: new Date() });
|
||||
expect(
|
||||
routes["release.resolved"]!(ev("release.resolved", { sourceReleaseId: "a", contentUnitIds: ["u"] })),
|
||||
).toEqual({
|
||||
task: "evaluateAcquisition",
|
||||
input: { sourceReleaseId: "a", contentUnitIds: ["u"] },
|
||||
});
|
||||
expect(
|
||||
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "completed" })),
|
||||
).toEqual({
|
||||
task: "verifyAndImport",
|
||||
input: { downloadTaskId: "d" },
|
||||
});
|
||||
expect(
|
||||
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "paused" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("规则 API(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
const disk = fakeDisk(1_000_000_000);
|
||||
|
||||
beforeAll(async () => {
|
||||
disk.setPct(10);
|
||||
h = await createHarness("rules", { diskUsage: disk.fn });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("订阅与 Slot:缺省季范围、同名冲突、revision 乐观锁", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "series", title: "追更剧", year: 2025 });
|
||||
const sub = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series" });
|
||||
expect(sub.status).toBe(201);
|
||||
expect(sub.body.data).toMatchObject({
|
||||
mode: "continuous",
|
||||
seasonScope: "following_seasons",
|
||||
origin: "user",
|
||||
// 剧集没有已接受的 TMDB 身份 → 总集数无来源:pending(照常搜索/评估,不能完成)
|
||||
status: "pending",
|
||||
statusReason: "metadata_pending",
|
||||
});
|
||||
const once = await h.api("PATCH", `/subscriptions/${sub.body.data.id}`, {
|
||||
seasonScope: "current_season",
|
||||
params: { stopAfter: "2030-01-01T00:00:00Z" },
|
||||
});
|
||||
expect(once.body.data.seasonScope).toBe("current_season");
|
||||
expect((await h.api("POST", "/subscriptions", { mediaKind: "movie" })).status).toBe(422);
|
||||
|
||||
const s = await h.api("POST", "/acquisition-slots", {
|
||||
mediaEntityId: e,
|
||||
subscriptionId: sub.body.data.id,
|
||||
name: "1080p",
|
||||
hardConditions: { resolutions: ["1080p"] },
|
||||
});
|
||||
expect(s.status).toBe(201);
|
||||
expect(s.body.data).toMatchObject({ status: "empty", revision: 1 });
|
||||
expect((await h.api("POST", "/acquisition-slots", { mediaEntityId: e, name: "1080p" })).status).toBe(409);
|
||||
expect(
|
||||
(
|
||||
await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
|
||||
expectedRevision: 9,
|
||||
softPreferences: { hdr: ["DV"] },
|
||||
})
|
||||
).status,
|
||||
).toBe(409);
|
||||
const ok = await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
|
||||
expectedRevision: 1,
|
||||
softPreferences: { hdr: ["DV"] },
|
||||
});
|
||||
expect(ok.body.data).toMatchObject({ revision: 2, softPreferences: { hdr: ["DV"] } });
|
||||
const list = await h.api("GET", `/acquisition-slots?mediaEntityId=${e}`);
|
||||
expect(list.body.data.items).toHaveLength(1);
|
||||
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
|
||||
});
|
||||
|
||||
it("策略版本:草案 → 发布(持续授权)→ 新版本发布(旧授权吊销)→ 回滚(新版本号,内容同目标)", async () => {
|
||||
const bad = await h.api("POST", "/policy-revisions", {
|
||||
policyKey: "acq",
|
||||
kind: "acquisition",
|
||||
title: "x",
|
||||
rules: { newTitle: { enabled: "yes" } },
|
||||
});
|
||||
expect(bad.status).toBe(422);
|
||||
const rules = {
|
||||
defaultSlots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }],
|
||||
newTitle: {
|
||||
enabled: true,
|
||||
windowDays: 60,
|
||||
mediaKinds: ["movie"],
|
||||
slots: [{ name: "新片", hardConditions: { resolutions: ["1080p", "2160p"] } }],
|
||||
},
|
||||
};
|
||||
const r1 = await h.api("POST", "/policy-revisions", {
|
||||
policyKey: "acq",
|
||||
kind: "acquisition",
|
||||
title: "默认收录",
|
||||
rules,
|
||||
});
|
||||
expect(r1.body.data).toMatchObject({ revision: 1, status: "draft" });
|
||||
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/preview`)).body.data.preview).toMatchObject({
|
||||
valid: true,
|
||||
templates: 2,
|
||||
});
|
||||
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 2 })).status).toBe(
|
||||
409,
|
||||
);
|
||||
const p1 = await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 1 });
|
||||
expect(p1.body.data.status).toBe("published");
|
||||
expect((await h.api("PATCH", `/policy-revisions/${r1.body.data.id}`, { title: "改" })).status).toBe(409);
|
||||
const g1 = await h.db
|
||||
.selectFrom("automation_grant")
|
||||
.selectAll()
|
||||
.where("policy_revision_id", "=", r1.body.data.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(g1).toMatchObject({ status: "active", action: "policy.acquisition" });
|
||||
|
||||
const r2 = await h.api("POST", "/policy-revisions", {
|
||||
policyKey: "acq",
|
||||
kind: "acquisition",
|
||||
title: "只要 4K",
|
||||
rules: { defaultSlots: [{ name: "4k", hardConditions: { resolutions: ["2160p"] } }] },
|
||||
});
|
||||
expect(r2.body.data.revision).toBe(2);
|
||||
await h.api("POST", `/policy-revisions/${r2.body.data.id}/publish`, { expectedRevision: 2 });
|
||||
expect((await h.api("GET", `/policy-revisions/${r1.body.data.id}`)).body.data.status).toBe("superseded");
|
||||
expect(
|
||||
(await h.db.selectFrom("automation_grant").select("status").where("id", "=", g1.id).executeTakeFirstOrThrow())
|
||||
.status,
|
||||
).toBe("revoked");
|
||||
|
||||
const rb = await h.api("POST", `/policy-revisions/${r1.body.data.id}/rollback`);
|
||||
expect(rb.body.data).toMatchObject({ revision: 3, status: "published", basedOnRevisionId: r1.body.data.id, rules });
|
||||
expect((await h.api("GET", `/policy-revisions/${r2.body.data.id}`)).body.data.status).toBe("rolled_back");
|
||||
const active = await h.db
|
||||
.selectFrom("automation_grant")
|
||||
.select("policy_revision_id")
|
||||
.where("status", "=", "active")
|
||||
.execute();
|
||||
expect(active.map((a) => a.policy_revision_id)).toEqual([rb.body.data.id]);
|
||||
|
||||
// 订阅没有 Slot 时套用已发布策略的缺省 Slot
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "套模板", year: 2025 });
|
||||
await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" });
|
||||
const slots = await h.db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", e).execute();
|
||||
expect(slots.map((s) => s.name)).toEqual(["1080p"]);
|
||||
});
|
||||
|
||||
it("新作品收录:库里没有、首映在窗口内 → 按已发布策略建订阅与 Slot 并预留计划;旧片不算新片", async () => {
|
||||
const base = await seedBase(h.db, {
|
||||
libraryPath: "/tmp/nonexistent-lib",
|
||||
cachePath: "/tmp",
|
||||
qbUrl: "http://127.0.0.1:9",
|
||||
});
|
||||
const recent = new Date(Date.now() - 5 * 86_400_000).toISOString().slice(0, 10);
|
||||
const fresh = (
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({
|
||||
kind: "movie",
|
||||
title: "新片",
|
||||
year: 2026,
|
||||
status: "active",
|
||||
metadata: JSON.stringify({ releaseDate: recent }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const t = makeTorrent({ name: "Fresh.2026.2160p.WEB-DL.mkv", length: 1000, pieceSeed: 3 });
|
||||
const rel = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: fresh,
|
||||
title: "Fresh.2026.2160p.WEB-DL.H265-NEW",
|
||||
size: 1000,
|
||||
hash: t.hash,
|
||||
});
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel });
|
||||
expect(ev.plans).toHaveLength(1);
|
||||
const sub = await h.db
|
||||
.selectFrom("subscription")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", fresh)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(sub).toMatchObject({ origin: "policy", mode: "once", priority: "new_work" });
|
||||
const plan = await h.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.selectAll()
|
||||
.where("id", "=", ev.plans[0]!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(plan).toMatchObject({ status: "reserved", priority: "new_work", purpose: "fill" });
|
||||
expect((plan.evaluation as { mode: string }).mode).toBe("new_title");
|
||||
|
||||
const old = await seedEntity(h.db, { kind: "movie", title: "老片", year: 1999 });
|
||||
const t2 = makeTorrent({ name: "Old.1999.1080p.mkv", length: 1000, pieceSeed: 4 });
|
||||
const rel2 = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: old,
|
||||
title: "Old.1999.1080p.BluRay-OLD",
|
||||
size: 1000,
|
||||
hash: t2.hash,
|
||||
});
|
||||
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 });
|
||||
expect(ev2.plans).toHaveLength(0);
|
||||
expect(ev2.skipped[0]?.reason).toBe("new_title:not_new_release");
|
||||
|
||||
// 总开关关闭:不建计划
|
||||
await h.setSettings("automation", { enabled: false });
|
||||
const t3 = makeTorrent({ name: "Fresh2.mkv", length: 1000, pieceSeed: 5 });
|
||||
const rel3 = await seedRelease(h.db, {
|
||||
siteId: base.siteId,
|
||||
entityId: fresh,
|
||||
title: "Fresh.2026.1080p.WEB-DL.H264-NEW",
|
||||
size: 1000,
|
||||
hash: t3.hash,
|
||||
});
|
||||
const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: rel3 });
|
||||
expect(ev3.plans).toHaveLength(0);
|
||||
await h.setSettings("automation", { enabled: true });
|
||||
});
|
||||
|
||||
it("偏好标签:删除/停用写抑制记录;Agent 不能据旧证据加回;用户重新添加解除抑制", async () => {
|
||||
const tag = { label: "不收恐怖片", kind: "hard", scope: { genres: ["horror"] }, rule: { exclude: true } };
|
||||
const c = await h.api("POST", "/preference-tags", tag);
|
||||
expect(c.status).toBe(201);
|
||||
expect((await h.api("POST", "/preference-tags", tag)).status).toBe(409);
|
||||
expect((await h.api("DELETE", `/preference-tags/${c.body.data.id}`)).status).toBe(200);
|
||||
const sup = await h.api("GET", "/preference-suppressions?active=true");
|
||||
expect(sup.body.data.items).toHaveLength(1);
|
||||
expect(sup.body.data.items[0].fingerprint).toBe(c.body.data.fingerprint);
|
||||
expect((await h.api("POST", "/preference-tags", { ...tag, origin: "agent_inferred" })).status).toBe(409);
|
||||
const again = await h.api("POST", "/preference-tags", { ...tag, origin: "manual" });
|
||||
expect(again.status).toBe(201);
|
||||
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(0);
|
||||
const off = await h.api("PATCH", `/preference-tags/${again.body.data.id}`, { status: "disabled" });
|
||||
expect(off.body.data).toMatchObject({ status: "disabled", revision: 2 });
|
||||
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(1);
|
||||
const list = await h.api("GET", "/preference-tags");
|
||||
expect(list.body.data.items.map((t: { id: string }) => t.id)).toEqual([again.body.data.id]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user