Files
UHD_Media_Tool/apps/server/test/sources-identity/subscription-search.test.ts
T
rootandClaude Opus 5.5 ed083fd138 feat(subscriptions): 按 MP 收敛订阅默认值与表单
- 定时主动搜索默认关(DEFAULT_SEARCH_INTERVAL_HOURS = 0),新建 / 手动照常各搜一次,之后靠 RSS 与站点快照
- 多版本自动补全系统缺省改为关
- 表单:基础页 = 作品与范围、下载、洗版;收录版本、优先级、自动补全折叠到进阶页「多版本」
- 对照文档 §7.3 记录落实范围

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 22:34:48 +08:00

150 lines
6.4 KiB
TypeScript

import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { applySeasonTotals } from "../../src/modules/sources-identity/service/metadata-totals.ts";
import { searchSubscription } from "../../src/modules/sources-identity/tasks.ts";
import { createHarness, type Harness, json, pgAvailable } from "./harness.ts";
// W5-C 订阅主动搜索(mock NexusPHP 站点:适配器 search + 限流 + 快照)与季总集数账本(refreshMetadata 的写入部分)。
const up = await pgAvailable();
describe.skipIf(!up)("订阅主动搜索与季总集数", () => {
let h: Harness;
let mock: MockNexusPhpSite;
let siteId: string;
beforeAll(async () => {
mock = await createMockNexusPhpSite();
h = await createHarness("subsearch");
const ok = await json(
await h.req("/sites", {
body: {
key: "audiences",
name: "观众",
adapter: "nexusphp",
baseUrl: `${mock.baseUrl}/`,
rateLimit: { minIntervalMs: 0, concurrency: 4 },
},
}),
);
expect(ok.status).toBe(200);
siteId = ok.body.data.id;
await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: mock.cookie } });
});
afterAll(async () => {
await h?.close();
await mock?.close();
});
const entity = async (title: string, kind: "movie" | "series" = "movie") =>
(
await h.db
.insertInto("media_entity")
.values({ kind, title, original_title: title, year: 2021, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
const subscribe = async (entityId: string, extra: Record<string, unknown> = {}) =>
(
await h.db
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: "movie", mode: "once", ...extra })
.returning("id")
.executeTakeFirstOrThrow()
).id;
it("按标题在已开启站点搜索:结果 upsert 为 source_release(新结果发 release.observed),排下次搜索并入队订阅重评", async () => {
const e = await entity("Dune");
const subId = await subscribe(e, { params: JSON.stringify({ searchIntervalHours: 24 }) });
const searches = () => mock.requests.filter((r) => r.path.includes("search=")).length;
const before = searches();
const r = await searchSubscription(h.rt, subId);
expect(r).toMatchObject({ status: "ok", keywords: ["Dune"], sites: [siteId], searches: 1, errors: [] });
expect(searches() - before).toBe(1);
const rels = await h.db
.selectFrom("source_release")
.select(["id", "title"])
.where("site_id", "=", siteId)
.execute();
expect(rels.some((x) => /Dune/i.test(x.title))).toBe(true);
expect((await h.outbox("release.observed")).length).toBeGreaterThanOrEqual(1);
const snap = await h.db
.selectFrom("site_snapshot")
.selectAll()
.where("query", "=", "Dune")
.executeTakeFirstOrThrow();
expect(snap.kind).toBe("live_search");
const sub = await h.db.selectFrom("subscription").selectAll().where("id", "=", subId).executeTakeFirstOrThrow();
expect(sub.last_searched_at).not.toBeNull();
expect(sub.next_search_at!.getTime() - sub.last_searched_at!.getTime()).toBe(24 * 3600_000);
expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "evaluateAcquisition", input: { subscriptionId: subId } });
});
it("站点范围外不搜;暂停订阅只允许手动;searchIntervalHours=0 关闭周期", async () => {
const e = await entity("Bear");
const scoped = await subscribe(e, {
params: JSON.stringify({ siteIds: ["00000000-0000-4000-8000-000000000000"] }),
});
expect(await searchSubscription(h.rt, scoped)).toMatchObject({ status: "ok", sites: [], searches: 0 });
const paused = await subscribe(e, { status: "paused", params: JSON.stringify({ searchIntervalHours: 0 }) });
expect(await searchSubscription(h.rt, paused)).toMatchObject({ status: "skipped", reason: "subscription_paused" });
const manual = await searchSubscription(h.rt, paused, { manual: true });
expect(manual).toMatchObject({ status: "ok", searches: 1, nextSearchAt: null });
});
it("季总集数:TMDB 集数写季单元(S00 不计、未公布保持未知);回退接受但被已覆盖最高集兜底;锁定不改;pending 订阅转 active", async () => {
const e = await entity("Series T", "series");
const sub = await subscribe(e, { media_kind: "series", status: "pending", status_reason: "metadata_pending" });
const now = new Date();
const r1 = await h.db.transaction().execute((trx) =>
applySeasonTotals(
trx,
e,
[
{ seasonNumber: 0, episodeCount: 3, name: "特别篇", airDate: null },
{ seasonNumber: 1, episodeCount: 8, name: "第 1 季", airDate: null },
{ seasonNumber: 2, episodeCount: 0, name: "第 2 季", airDate: null },
],
now,
),
);
expect(r1.seasons).toEqual([
{ season: 1, total: 8, known: true, changed: true },
{ season: 2, total: null, known: false, changed: false },
]);
expect(r1.activated).toBe(1);
expect(
(await h.db.selectFrom("subscription").select("status").where("id", "=", sub).executeTakeFirstOrThrow()).status,
).toBe("active");
const r2 = await h.db
.transaction()
.execute((trx) =>
applySeasonTotals(trx, e, [{ seasonNumber: 1, episodeCount: 12, name: null, airDate: null }], now),
);
expect(r2.seasons[0]).toMatchObject({ total: 12, changed: true });
await h.db
.updateTable("content_unit")
.set({ episode_total_locked: true })
.where("media_entity_id", "=", e)
.where("season_number", "=", 1)
.execute();
const r3 = await h.db
.transaction()
.execute((trx) =>
applySeasonTotals(trx, e, [{ seasonNumber: 1, episodeCount: 20, name: null, airDate: null }], now),
);
expect(r3.seasons[0]).toMatchObject({ total: 12, changed: false });
const units = await h.db
.selectFrom("content_unit")
.select(["kind", "season_number", "episode_total", "episode_total_source"])
.where("media_entity_id", "=", e)
.orderBy("season_number")
.execute();
// 只建季单元,不预建集单元
expect(units).toEqual([
{ kind: "season", season_number: 1, episode_total: 12, episode_total_source: "tmdb" },
{ kind: "season", season_number: 2, episode_total: null, episode_total_source: null },
]);
});
});