feat(search): global search aggregation backend (W9-G)
- GET /search?q=&scope=all|media|releases|actions&limit=: local works (title_alias
exact/prefix/trgm + name prefix, slot status summary), TMDB candidates (cached,
localEntityId), cross-site releases, page/action jump list; tookMs per group
- cross-site: enabled sites via shared per-site limiter, 15s AbortSignal timeout,
site concurrency cap 4, same-query coalescing, 30s cache; partial + failedSites;
results upserted to source_release; inventory overlay via GET /inventory
- GET /search/releases: site/resolution/source/size/free/H&R filters, sort, cursor,
facets; shares the cache
- POST /search/releases/{siteId}/{siteReleaseId}/plan: forwards in-process to
POST /acquisition-plans (same auth, validation, audit, rejections)
- contracts/search.ts, runbook, tests (partial/timeout, coalesce, cache, overlay,
filters/sort, plan)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e0c0c4f51d
commit
a0946bf480
12 files changed
+1869
-1
No files matched your search
@@ -43,7 +43,7 @@ const pingModule: ServerModule = {
|
||||
};
|
||||
|
||||
describe("ServerModule 骨架", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务(末尾),名字唯一", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索(末尾),名字唯一", () => {
|
||||
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
|
||||
"auth-settings",
|
||||
"sources-identity",
|
||||
@@ -52,6 +52,7 @@ describe("ServerModule 骨架", () => {
|
||||
"file-manager",
|
||||
"pi",
|
||||
"schedules",
|
||||
"search",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
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 { createAgentModule } from "../../src/modules/agent/index.ts";
|
||||
import { createSearchModule } from "../../src/modules/search/index.ts";
|
||||
import { type BaseRelease, filterReleases } 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<DB>;
|
||||
let admin: K<unknown>;
|
||||
let schema: string;
|
||||
let nexus: MockNexusPhpSite;
|
||||
let mteam: MockMteamApi;
|
||||
let app: ReturnType<typeof createApp>;
|
||||
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<unknown>({
|
||||
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<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
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: {} as never,
|
||||
} 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<Response>((_, 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");
|
||||
// 已关闭的站点不参与搜索
|
||||
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("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"]);
|
||||
});
|
||||
|
||||
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 all = await req("/search?q=Dune");
|
||||
expect(all.body.data.media).not.toBeNull();
|
||||
expect(all.body.data.releases).not.toBeNull();
|
||||
expect(all.body.data.actions).not.toBeNull();
|
||||
});
|
||||
|
||||
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
|
||||
// 未识别的资源 → 与 POST /acquisition-plans 相同的 412
|
||||
const unresolved = await req(`/search/releases/${sites.nexus}/900001/plan`, { body: {} });
|
||||
expect(unresolved.status).toBe(412);
|
||||
expect(unresolved.body.error.code).toBe("precondition_failed");
|
||||
|
||||
// 已识别但作品没有版本目标 → submitted=false + no_slot
|
||||
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: false, plan: null, rejected: [{ code: "no_slot" }] });
|
||||
|
||||
// 不在搜索结果里
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user