import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db"; import { normalizeTitle } from "@umt/domain"; import { AUDIENCES_FIXTURE_RELEASES, createMockMteamApi, createMockNexusPhpSite, type MockMteamApi, type MockNexusPhpSite, MTEAM_FIXTURE_TORRENTS, } from "@umt/testkit"; import { Kysely as K, PostgresDialect } from "kysely"; import pg from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createOutboxPort } from "../../src/context.ts"; import { createApp } from "../../src/http/app.ts"; import { EventBus } from "../../src/http/sse.ts"; import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; import { createAgentModule } from "../../src/modules/agent/index.ts"; import { createSearchModule } from "../../src/modules/search/index.ts"; import { type BaseRelease, filterReleases, quickFilter } from "../../src/modules/search/releases.ts"; import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts"; import type { ServerContext } from "../../src/modules/types.ts"; import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts"; import { fakeEngine, fakeSecrets, PG_URL, pgAvailable, plainSettings } from "../sources-identity/harness.ts"; // W9-G 全局搜索:两个 mock 站点(NexusPHP 观众 + M-Team API)+ 一个挂起的站点(超时 → partial)、 // 并发相同查询合并、30s 缓存命中、库存叠加字段、筛选排序游标、从搜索结果下载转调 /acquisition-plans。 const up = await pgAvailable(); const READ_KEY = "w9g-read-key"; const SLOW_HOST = "slow-site.invalid"; const sample: BaseRelease = { key: "k", sourceReleaseId: null, siteId: "00000000-0000-4000-8000-000000000000", siteName: "s", siteReleaseId: "1", title: "t", subtitle: null, category: { id: null, name: null }, sizeBytes: null, seeders: null, leechers: null, snatched: null, promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null }, free: false, hr: "unknown", uploadedAt: null, detailUrl: "http://x/", observedAt: "2026-09-28T00:00:00.000Z", parsed: { titleCn: null, titleEn: null, year: null, season: null, episodes: null, resolution: null, source: null, videoCodec: null, hdr: [], releaseGroup: null, }, }; // biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON type Loose = any; describe.skipIf(!up)("全局搜索", () => { let db: Kysely; let admin: K; let schema: string; let nexus: MockNexusPhpSite; let mteam: MockMteamApi; let app: ReturnType; let slowCalls = 0; const sites: Record<"nexus" | "mteam" | "slow", string> = { nexus: "", mteam: "", slow: "" }; const req = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => { const res = await app.request(`/api/v1${path}`, { method: init.method ?? (init.body !== undefined ? "POST" : "GET"), headers: { authorization: `Bearer ${init.key ?? DEV_KEY}`, ...(init.body !== undefined ? { "content-type": "application/json" } : {}), }, ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), }); return { status: res.status, body: (await res.json()) as Loose }; }; const nexusSearches = () => nexus.requests.filter((r) => r.path.includes("search=")).length; const mteamSearches = () => mteam.requests.filter((r) => r.path.includes("/api/torrent/search")).length; beforeAll(async () => { nexus = await createMockNexusPhpSite({ releases: [ ...AUDIENCES_FIXTURE_RELEASES, { id: "900001", title: "Dune.Part.Two.2024.1080p.WEB-DL.H264.AAC-GRP", subtitle: "沙丘2", catId: "401", catName: "电影", sizeText: "8.00 GB", seeders: "300", leechers: "5", snatched: "900", uploadedAt: "2026-09-20 10:00:00", hr: true, }, ], }); mteam = await createMockMteamApi({ torrents: [ ...MTEAM_FIXTURE_TORRENTS, { id: "990001", name: "Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.H.265-FLUX", smallDescr: "沙丘2", category: "419", size: "25000000000", createdDate: "2026-09-22 08:00:00", status: { seeders: "40", leechers: "1", timesCompleted: "100", discount: "NORMAL" }, }, ], }); schema = `w9g_search_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`; admin = new K({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }), }); await sql.raw(`create schema ${schema}`).execute(admin); await import("@umt/db"); db = new K({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }), }), }) as unknown as Kysely; await migrateToLatest(db, { migrationTableSchema: schema }); const { logger } = memoryLogger(); const secrets = fakeSecrets(() => db); const ctx = { role: "all", db, config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never, logger, events: new EventBus(5), engine: fakeEngine(), outbox: createOutboxPort(), secrets, settings: plainSettings(() => db), capabilities: createCapabilityRegistry({ logger }), } as ServerContext; // 挂起的站点:请求一直不返回,直到被中止(单站超时) const fetchImpl: typeof fetch = (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.hostname !== SLOW_HOST) return fetch(input, init); slowCalls++; return new Promise((_, reject) => { const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); }); }; const sources = createSourcesIdentityModule({ fetch: fetchImpl, tmdb: () => null }); const search = createSearchModule({ runtime: (c) => sources.runtime(c), siteTimeoutMs: 600 }); app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger, bus: new EventBus(5), role: "all", version: "0.0.0-test", modules: [sources, createAcquisitionOrganizeModule(), createAgentModule(), search], ctx, apiKeyStore: memoryStore({ [READ_KEY]: { id: "reader", scopes: ["read"] } }), }); const addSite = async ( key: string, name: string, adapter: "nexusphp" | "mteam_api", baseUrl: string, config = {}, ) => ( await db .insertInto("site") .values({ key, name, adapter, base_url: baseUrl, rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), config: JSON.stringify(config), }) .returning("id") .executeTakeFirstOrThrow() ).id; const addAccount = async (siteId: string, kind: "cookie" | "api_key", plaintext: string) => { const ref = await secrets.create({ purpose: kind === "cookie" ? "site_cookie" : "site_api_key", plaintext }); await db .insertInto("site_account") .values({ site_id: siteId, label: "main", auth_kind: kind, secret_ref: ref }) .execute(); }; sites.nexus = await addSite("audiences", "观众", "nexusphp", `${nexus.baseUrl}/`); await addAccount(sites.nexus, "cookie", nexus.cookie); sites.mteam = await addSite("mteam", "馒头", "mteam_api", "https://kp.m-team.cc/", { apiBaseUrl: mteam.baseUrl }); await addAccount(sites.mteam, "api_key", mteam.apiKey); sites.slow = await addSite("hdsky", "天空", "nexusphp", `http://${SLOW_HOST}/`); await addAccount(sites.slow, "cookie", "uid=1; pass=x"); const downloader = await db .insertInto("downloader_instance") .values({ name: "search-download", base_url: "http://downloader.invalid", cache_volume_key: "search-cache", settings: JSON.stringify({ savePath: "/downloads" }), }) .returning("id") .executeTakeFirstOrThrow(); await db .insertInto("path_mapping") .values({ downloader_instance_id: downloader.id, view_kind: "downloader", remote_prefix: "/downloads", local_prefix: "/tmp/umt-search-cache", media_server_instance_id: null, library_root_id: null, verified_at: null, }) .execute(); // 已关闭的站点不参与搜索 await db .insertInto("site") .values({ key: "hdhome", name: "家园", adapter: "nexusphp", base_url: "http://off.invalid/", enabled: false }) .execute(); }, 60_000); afterAll(async () => { await db?.destroy(); if (admin) { await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin); await admin.destroy(); } await nexus?.close(); await mteam?.close(); }); it("两站成功 + 一站超时 → partial;结果写入资源表;每组带 tookMs", async () => { const r = await req("/search?q=Dune&scope=releases&limit=50"); expect(r.status).toBe(200); const d = r.body.data; expect(d.media).toBeNull(); expect(d.actions).toBeNull(); const rel = d.releases; expect(rel.partial).toBe(true); expect(rel.failedSites).toEqual([ expect.objectContaining({ siteId: sites.slow, status: "timeout", count: 0, error: expect.stringContaining("秒"), }), ]); const byId = Object.fromEntries(rel.sites.map((s: Loose) => [s.siteId, s])); expect(Object.keys(byId).sort()).toEqual([sites.nexus, sites.mteam, sites.slow].sort()); expect(byId[sites.nexus]).toMatchObject({ status: "ok", count: 2 }); expect(byId[sites.mteam]).toMatchObject({ status: "ok", count: 1 }); expect(slowCalls).toBeGreaterThan(0); expect(rel.total).toBe(3); expect(rel.cached).toBe(false); expect(typeof rel.tookMs).toBe("number"); expect(typeof d.tookMs).toBe("number"); // 默认排序:做种数降序 expect(rel.items.map((i: Loose) => i.seeders)).toEqual([1204, 300, 40]); const remux = rel.items[0]; expect(remux).toMatchObject({ siteId: sites.nexus, siteName: "观众", siteReleaseId: "812345", free: true, parsed: { resolution: "2160p", source: "REMUX" }, }); expect(remux.sourceReleaseId).toMatch(/^[0-9a-f-]{36}$/); expect(rel.items[1]).toMatchObject({ free: false, parsed: { resolution: "1080p", source: "WEB-DL" } }); const rows = await db.selectFrom("source_release").select(["id", "site_id"]).execute(); expect(rows.length).toBe(3); }); it("30s 内相同查询命中缓存,不再请求站点", async () => { const before = [nexusSearches(), mteamSearches(), slowCalls]; const r = await req("/search?q=%20dune%20&scope=releases"); expect(r.body.data.releases).toMatchObject({ cached: true, total: 3, partial: true }); const page = await req("/search/releases?q=Dune"); expect(page.body.data).toMatchObject({ cached: true, total: 3 }); expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before); // refresh=true 跳过缓存 const fresh = await req("/search/releases?q=Dune&refresh=true"); expect(fresh.body.data.cached).toBe(false); expect(nexusSearches()).toBe(before[0]! + 1); }); it("并发的相同查询合并为一次站点请求", async () => { const before = mteamSearches(); const [a, b] = await Promise.all([ req("/search/releases?q=Oppenheimer"), req("/search?q=oppenheimer&scope=releases"), ]); expect(a.status).toBe(200); expect(b.status).toBe(200); expect(mteamSearches() - before).toBe(1); const flags = [a.body.data.coalesced, b.body.data.releases.coalesced]; expect(flags.filter(Boolean).length).toBe(1); expect(a.body.data.items[0]).toMatchObject({ siteId: sites.mteam, siteReleaseId: "912234" }); }); it("库存叠加:已识别资源 → resolved + 订阅/收录版本缺口;未识别但标题命中唯一作品别名 → alias", async () => { const entity = ( await db .insertInto("media_entity") .values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" }) .returning("id") .executeTakeFirstOrThrow() ).id; for (const alias of ["沙丘2", "Dune Part Two"]) { await db .insertInto("title_alias") .values({ media_entity_id: entity, alias, normalized: normalizeTitle(alias), kind: "original", source: "tmdb" }) .execute(); } await db .insertInto("subscription") .values({ media_entity_id: entity, media_kind: "movie", mode: "once", status: "active" }) .execute(); const slot = ( await db .insertInto("acquisition_slot") .values({ media_entity_id: entity, name: "2160p Remux", status: "empty" }) .returning("id") .executeTakeFirstOrThrow() ).id; // 观众 REMUX 已识别到作品 await db .updateTable("source_release") .set({ media_entity_id: entity, resolution_status: "resolved" }) .where("site_id", "=", sites.nexus) .where("site_release_id", "=", "812345") .execute(); const r = await req("/search/releases?q=Dune"); const items = r.body.data.items as Loose[]; const remux = items.find((i) => i.siteReleaseId === "812345"); expect(remux).toMatchObject({ mediaEntityId: entity, entityMatch: "resolved", entity: { id: entity, title: "沙丘2", year: 2024, kind: "movie" }, inventory: { entityId: entity, state: "gap", subscribed: true, gaps: [{ slotId: slot, name: "2160p Remux", status: "empty" }], versions: [], downloading: [], candidateReasons: [], }, }); const mt = items.find((i) => i.siteReleaseId === "990001"); expect(mt).toMatchObject({ mediaEntityId: entity, entityMatch: "alias", inventory: { state: "gap" } }); // 本地作品组:别名精确命中 + 收录版本状态摘要 const all = await req("/search?q=沙丘2&scope=media"); const local = all.body.data.media.local.items; expect(local[0]).toMatchObject({ entity: { id: entity, title: "沙丘2" }, matchedBy: "exact", score: 1, subscribed: true, versionCount: 0, slotCounts: { empty: 1, satisfied: 0 }, }); expect(all.body.data.media.tmdb).toMatchObject({ configured: false, items: [] }); expect(typeof all.body.data.media.local.tookMs).toBe("number"); const prefix = await req("/search?q=Dune%20Pa&scope=media"); expect(prefix.body.data.media.local.items[0]).toMatchObject({ entity: { id: entity }, matchedBy: "prefix" }); }); it("筛选 / 排序 / 游标 / 分面", async () => { const q = (s: string) => req(`/search/releases?q=Dune&${s}`).then((r) => r.body.data); let d = await q("resolution=2160p"); expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["812345", "990001"]); expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 }); expect(d.facets.resolutions).toEqual([ { value: "2160p", label: "2160p", count: 2 }, { value: "1080p", label: "1080p", count: 1 }, ]); expect(d.facets.sites.find((f: Loose) => f.value === sites.nexus)).toMatchObject({ label: "观众", count: 2 }); d = await q("source=web-dl,REMUX&free=false"); expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["900001", "990001"]); d = await q(`sites=${sites.mteam}`); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]); d = await q(`minSize=${10 * 1024 ** 3}&maxSize=${50 * 1024 ** 3}`); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]); d = await q("sort=size&order=asc"); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001", "990001", "812345"]); d = await q("sort=uploadedAt"); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["812345", "990001", "900001"]); d = await q("sort=seeders&order=asc"); expect(d.items.map((i: Loose) => i.seeders)).toEqual([40, 300, 1204]); const p1 = await q("limit=2"); expect(p1.items.length).toBe(2); expect(p1.nextCursor).toBeTruthy(); const p2 = await q(`limit=2&cursor=${p1.nextCursor}`); expect(p2.items.map((i: Loose) => i.seeders)).toEqual([40]); expect(p2.nextCursor).toBeNull(); }); it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => { const q = (qf: string, extra = "") => req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data); const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count])); const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort(); let d = await q(""); expect(d.total).toBe(3); expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 }); expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 }); expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 }); expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 }); expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众"); expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 }); expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2); expect(d.facets.season).toEqual([]); expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3); // 旧分面仍在 expect(d.facets.resolutions.length).toBe(2); d = await q("resolution:2160p"); expect(ids(d)).toEqual(["812345", "990001"]); expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 }); // 本维度其它选项仍按其它条件计数 expect(counts(d.facets.resolution)).toMatchObject({ "2160p": 2, "1080p": 1 }); expect(counts(d.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1 }); d = await q("resolution:2160p;releaseType:webdl"); expect(ids(d)).toEqual(["990001"]); d = await q(`resolution:2160p,1080p;site:${sites.nexus}`); expect(ids(d)).toEqual(["812345", "900001"]); d = await q("releaseGroup:flux,grp"); expect(ids(d)).toEqual(["900001", "990001"]); d = await q("hdr:DV"); expect(ids(d)).toEqual(["812345"]); d = await q("videoCodec:AV1"); expect(d.items).toEqual([]); expect(d.total).toBe(0); // 与旧参数叠加(AND) d = await q("resolution:2160p", "&source=REMUX"); expect(ids(d)).toEqual(["812345"]); // 分页:同一筛选下逐页取完 = 一次取完,且排序一致 const whole = await q("videoCodec:H.265,H.264", "&sort=size&limit=50"); const seen: string[] = []; let cursor: string | null = null; do { const p: Loose = await q("videoCodec:H.265,H.264", `&sort=size&limit=1${cursor ? `&cursor=${cursor}` : ""}`); expect(p.total).toBe(3); expect(p.facets.videoCodec).toEqual(whole.facets.videoCodec); seen.push(...p.items.map((i: Loose) => i.siteReleaseId)); cursor = p.nextCursor; } while (cursor); expect(seen).toEqual(whole.items.map((i: Loose) => i.siteReleaseId)); }); it("站点资源列表的分面基于本地数据,不请求站点", async () => { const before = [nexusSearches(), mteamSearches()]; const list = (qf: string) => req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data); let d = await list(""); expect(d.total).toBe(2); expect(d.unfilteredTotal).toBe(2); expect(d.facets.site).toEqual([{ value: sites.nexus, label: "观众", count: 2 }]); expect(d.facets.resolution.find((f: Loose) => f.value === "2160p").count).toBe(1); expect(d.facets.releaseType.find((f: Loose) => f.value === "remux").count).toBe(1); // H&R 分面来自资源表 raw._site.hr,每条资源恰好落在一档 expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(2); d = await list("resolution:1080p"); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]); expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 }); expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([ ["FraMeSToR", 0], ["GRP", 1], ]); // 分页 const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data); expect(p1.items.length).toBe(1); const p2 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1&cursor=${p1.nextCursor}`).then( (r) => r.body.data, ); expect(p2.items.length).toBe(1); expect(p2.items[0].id).not.toBe(p1.items[0].id); expect(p2.nextCursor).toBeNull(); expect([nexusSearches(), mteamSearches()]).toEqual(before); }); it("SEARCH-4 新维度:音频 / 年份 / 分类 / 做种 / 大小,搜索结果与站点资源列表同一口径", async () => { const q = (qf: string) => req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data); const list = (qf: string) => req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data); const pick = (f: Loose) => Object.fromEntries( ["audioCodec", "subtitle", "dub", "feature", "year", "category", "size", "seeders"].map((k) => [ k, Object.fromEntries(f[k].map((o: Loose) => [o.value, o.count])), ]), ); const d = await q(""); expect(pick(d.facets)).toMatchObject({ audioCodec: { Atmos: 1, TrueHD: 1, DDP: 1, AAC: 1 }, subtitle: { zh: 1, unknown: 2 }, dub: { cmn: 1, en: 1, unknown: 2 }, feature: {}, year: { "2024": 3 }, category: { 电影: 3 }, size: { "5-10": 1, "20-50": 1, "50+": 1 }, seeders: { "10-49": 1, "50+": 2 }, }); // 组合:年份 × 做种 × 音频(同维度 OR) const hit = await q("year:2024;seeders:50+;audioCodec:AAC,DDP"); expect(hit.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]); expect(hit.facets.audioCodec.find((o: Loose) => o.value === "DDP").count).toBe(0); expect(hit.facets.seeders.find((o: Loose) => o.value === "10-49").count).toBe(1); // 站点资源列表:从资源表的解析结果 / 分类 / 大小 / 做种算,不请求站点 const before = nexusSearches(); const l = await list(""); expect(pick(l.facets)).toMatchObject({ audioCodec: { Atmos: 1, TrueHD: 1, AAC: 1 }, dub: { cmn: 1, unknown: 1 }, category: { 电影: 2 }, size: { "5-10": 1, "50+": 1 }, seeders: { "50+": 2 }, }); expect((await list("dub:cmn;category:电影")).total).toBe(1); expect((await list("size:5-10;audioCodec:AAC")).items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]); expect(nexusSearches()).toBe(before); }); it("H&R 筛选:true 只要明确 H&R;false 只排除明确 H&R(unknown 保留)", () => { const base = (id: string, hr: boolean | "unknown") => ({ ...sample, key: id, siteReleaseId: id, hr }); const items = [base("a", true), base("b", false), base("c", "unknown")]; const f = (hr: "true" | "false") => filterReleases(items, { q: "x", hr, sort: "default", order: "desc", limit: 50 }).map((i) => i.siteReleaseId); expect(f("true")).toEqual(["a"]); expect(f("false")).toEqual(["b", "c"]); // 快速筛选里的 H&R 分面(POLISH-1):有 / 无 / 未知,多选为 OR const qfq = (qf: string) => quickFilter(items, { q: "x", qf, sort: "default", order: "desc", limit: 50 }, 0); const all = qfq(""); expect(all.facets.hr).toEqual([ { value: "yes", label: "有", count: 1 }, { value: "no", label: "无", count: 1 }, { value: "unknown", label: "未知", count: 1 }, ]); expect(qfq("hr:no,unknown").items.map((i) => i.siteReleaseId)).toEqual(["b", "c"]); }); it("页面与操作跳转;scope=all 只查作品与操作(不查站点)", async () => { const r = await req("/search?q=设置&scope=actions"); expect(r.body.data.actions.items[0]).toMatchObject({ id: "page.settings", kind: "page", href: "/console/settings", }); const sub = await req("/search?q=订阅&scope=actions"); expect(sub.body.data.actions.items.map((a: Loose) => a.id)).toContain("action.new-subscription"); const before = [nexusSearches(), mteamSearches(), slowCalls]; const all = await req("/search?q=Dune%20Part%20Two&refresh=true"); expect(all.body.data.media).not.toBeNull(); expect(all.body.data.releases).toBeNull(); expect(all.body.data.actions).not.toBeNull(); expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before); }); it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => { // 未识别资源先返回一次结构化确认,不阻断后续手动下载 const unresolved = await req(`/search/releases/${sites.nexus}/900001/plan`, { body: {} }); expect(unresolved.status, JSON.stringify(unresolved.body)).toBe(200); expect(unresolved.body.data).toMatchObject({ submitted: false, requiresConfirmation: true, rejected: [{ code: "unrecognized" }], }); // 已识别资源无需订阅或收录版本,独立计划提交 const other = ( await db .insertInto("media_entity") .values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" }) .returning("id") .executeTakeFirstOrThrow() ).id; await db .updateTable("source_release") .set({ media_entity_id: other }) .where("site_id", "=", sites.mteam) .where("site_release_id", "=", "912234") .execute(); const ok = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: { note: "from search" } }); expect(ok.status).toBe(200); expect(ok.body.data).toMatchObject({ submitted: true, slotId: null, plan: { slotId: null }, rejected: [] }); expect(await db.selectFrom("subscription").selectAll().where("media_entity_id", "=", other).execute()).toHaveLength( 0, ); expect( await db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", other).execute(), ).toHaveLength(0); // 不在搜索结果里 const missing = await req(`/search/releases/${sites.mteam}/nope/plan`, { body: {} }); expect(missing.status).toBe(404); // read scope 不能下载;但可以搜索 const ro = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: {}, key: READ_KEY }); expect(ro.status).toBe(403); expect((await req("/search?q=Dune&scope=releases", { key: READ_KEY })).status).toBe(200); }); });