369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
import { createTmdbClient } from "@umt/adapters";
|
|
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
|
|
import { normalizeTitle } from "@umt/domain";
|
|
import {
|
|
AUDIENCES_FIXTURE_RELEASES,
|
|
createMockNexusPhpSite,
|
|
createMockTmdb,
|
|
type MockNexusPhpSite,
|
|
type MockTmdb,
|
|
} 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 { searchTerms } from "../../src/modules/search/works.ts";
|
|
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
|
import type { ServerContext } from "../../src/modules/types.ts";
|
|
import { DEV_KEY, memoryLogger } from "../helpers.ts";
|
|
import { fakeEngine, fakeSecrets, PG_URL, pgAvailable, plainSettings } from "../sources-identity/harness.ts";
|
|
|
|
// SEARCH-2 两阶段搜索:第一阶段只查作品(本地 + TMDB 合并去重)与库存对比,不碰站点;
|
|
// 第二阶段按作品显式搜资源(服务端生成查询词,站点按默认 / 全部 / 指定);仅 TMDB 作品的详情端点。
|
|
|
|
const up = await pgAvailable();
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
|
|
type Loose = any;
|
|
|
|
describe("searchTerms", () => {
|
|
it("电影:原名带年份 + 译名;同名只留一个;剧集不带年份", () => {
|
|
expect(searchTerms({ title: "星际穿越", originalTitle: "Interstellar", year: 2014, movie: true })).toEqual([
|
|
"Interstellar 2014",
|
|
"星际穿越",
|
|
]);
|
|
expect(searchTerms({ title: "流浪地球2", originalTitle: "流浪地球2", year: 2023, movie: true })).toEqual([
|
|
"流浪地球2",
|
|
]);
|
|
expect(searchTerms({ title: "熊家餐馆", originalTitle: "The Bear", year: 2022, movie: false })).toEqual([
|
|
"The Bear",
|
|
"熊家餐馆",
|
|
]);
|
|
expect(searchTerms({ title: "Oppenheimer", originalTitle: null, year: 2023, movie: true })).toEqual([
|
|
"Oppenheimer 2023",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
|
let db: Kysely<DB>;
|
|
let admin: K<unknown>;
|
|
let schema: string;
|
|
let a: MockNexusPhpSite;
|
|
let b: MockNexusPhpSite;
|
|
let tmdb: MockTmdb;
|
|
let app: ReturnType<typeof createApp>;
|
|
const sites = { a: "", b: "", c: "" };
|
|
let dune = "";
|
|
const engine = fakeEngine();
|
|
|
|
const req = async (path: string, init: { method?: string; body?: unknown } = {}) => {
|
|
const res = await app.request(`/api/v1${path}`, {
|
|
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
|
headers: {
|
|
authorization: `Bearer ${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 searches = (m: MockNexusPhpSite) => m.requests.filter((r) => r.path.includes("search=")).map((r) => r.path);
|
|
const counts = () => [searches(a).length, searches(b).length];
|
|
|
|
beforeAll(async () => {
|
|
const release = {
|
|
id: "700001",
|
|
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-GRP",
|
|
subtitle: "沙丘2",
|
|
catId: "401",
|
|
catName: "电影",
|
|
sizeText: "70.00 GB",
|
|
seeders: "50",
|
|
leechers: "1",
|
|
snatched: "10",
|
|
uploadedAt: "2026-09-20 10:00:00",
|
|
};
|
|
a = await createMockNexusPhpSite({ releases: [...AUDIENCES_FIXTURE_RELEASES, release] });
|
|
b = await createMockNexusPhpSite({ releases: [{ ...release, id: "800001" }] });
|
|
tmdb = await createMockTmdb();
|
|
|
|
schema = `search2_${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);
|
|
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,
|
|
outbox: createOutboxPort(),
|
|
secrets,
|
|
settings: plainSettings(() => db),
|
|
capabilities: {} as never,
|
|
} as ServerContext;
|
|
const sources = createSourcesIdentityModule({
|
|
tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }),
|
|
});
|
|
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(),
|
|
createSearchModule({ runtime: (c) => sources.runtime(c), siteTimeoutMs: 2000 }),
|
|
],
|
|
ctx,
|
|
});
|
|
|
|
const addSite = async (key: string, name: string, m: MockNexusPhpSite | null, searchDefault: boolean) => {
|
|
const id = (
|
|
await db
|
|
.insertInto("site")
|
|
.values({
|
|
key,
|
|
name,
|
|
adapter: "nexusphp",
|
|
base_url: m ? `${m.baseUrl}/` : "http://never.invalid/",
|
|
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
|
search_default: searchDefault,
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
if (m) {
|
|
const ref = await secrets.create({ purpose: "site_cookie", plaintext: m.cookie });
|
|
await db
|
|
.insertInto("site_account")
|
|
.values({ site_id: id, label: "main", auth_kind: "cookie", secret_ref: ref })
|
|
.execute();
|
|
}
|
|
return id;
|
|
};
|
|
sites.a = await addSite("audiences", "观众", a, true);
|
|
sites.b = await addSite("hdsky", "天空", b, false);
|
|
// 已关闭:任何模式都不查
|
|
sites.c = await addSite("hdhome", "家园", null, true);
|
|
await db.updateTable("site").set({ enabled: false }).where("id", "=", sites.c).execute();
|
|
|
|
// 本地已建档的沙丘2(接受了 TMDB 693134),有一个未满足的版本目标
|
|
dune = (
|
|
await db
|
|
.insertInto("media_entity")
|
|
.values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
await db
|
|
.insertInto("title_alias")
|
|
.values({
|
|
media_entity_id: dune,
|
|
alias: "沙丘2",
|
|
normalized: normalizeTitle("沙丘2"),
|
|
kind: "original",
|
|
source: "tmdb",
|
|
})
|
|
.execute();
|
|
await db
|
|
.insertInto("external_identity")
|
|
.values({
|
|
media_entity_id: dune,
|
|
provider: "tmdb",
|
|
entity_type: "movie",
|
|
external_id: "693134",
|
|
status: "accepted",
|
|
})
|
|
.execute();
|
|
await db
|
|
.insertInto("acquisition_slot")
|
|
.values({ media_entity_id: dune, name: "2160p Remux", status: "empty" })
|
|
.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 a?.close();
|
|
await b?.close();
|
|
await tmdb?.close();
|
|
});
|
|
|
|
it("第一阶段:作品本地 + TMDB 合并去重、本地在前、带库存对比;不请求任何站点", async () => {
|
|
const before = counts();
|
|
for (const scope of ["all", "media"]) {
|
|
const r = await req(`/search?q=${encodeURIComponent("沙丘")}&scope=${scope}`);
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.releases).toBeNull();
|
|
const works = r.body.data.media.works as Loose[];
|
|
// 同一 TMDB 身份只出现一次(本地那条),本地在前
|
|
expect(works.filter((w) => w.tmdb?.externalId === "693134")).toHaveLength(1);
|
|
expect(works[0]).toMatchObject({
|
|
key: `local:${dune}`,
|
|
source: "local",
|
|
localEntityId: dune,
|
|
tmdb: { entityType: "movie", externalId: "693134" },
|
|
inventory: { state: "tracked", slotTotal: 1, slotSatisfied: 0, missing: [{ name: "2160p Remux" }] },
|
|
});
|
|
}
|
|
const opp = await req(`/search?q=Oppenheimer&scope=media`);
|
|
const w = (opp.body.data.media.works as Loose[]).find((x) => x.tmdb?.externalId === "872585");
|
|
expect(w).toMatchObject({
|
|
source: "tmdb",
|
|
kind: "movie",
|
|
localEntityId: null,
|
|
year: 2023,
|
|
inventory: { state: "not_collected", versionCount: 0 },
|
|
});
|
|
expect(w.overview).toBeTruthy();
|
|
expect(counts()).toEqual(before);
|
|
});
|
|
|
|
it("第二阶段:不带 sites 走默认集合(search_default),sites= 只查指定站点,关闭的站点不查", async () => {
|
|
let before = counts();
|
|
const def = await req(`/search/releases?q=Dune`);
|
|
expect(def.status).toBe(200);
|
|
expect(def.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.a]);
|
|
expect(counts()).toEqual([before[0]! + 1, before[1]]);
|
|
// 惰性识别(DEC-1):搜到的未识别资源以 trigger=search 入队识别
|
|
const resolves = engine.enqueued.filter((e) => e.task === "resolveObservation");
|
|
expect(resolves.length).toBeGreaterThan(0);
|
|
expect(resolves.every((e) => (e.input as { trigger?: string }).trigger === "search")).toBe(true);
|
|
|
|
before = counts();
|
|
const only = await req(`/search/releases?q=Dune&sites=${sites.b},${sites.c}`);
|
|
expect(only.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.b]);
|
|
// 结果只来自天空(其定义与 mock 页面结构不完全一致,条数不断言)
|
|
expect(only.body.data.items.every((i: Loose) => i.siteId === sites.b)).toBe(true);
|
|
expect(counts()).toEqual([before[0], before[1]! + 1]);
|
|
|
|
// 两个都选:两站的(站点, 查询词)都已缓存 → 不再请求
|
|
before = counts();
|
|
const both = await req(`/search/releases?q=Dune&sites=${sites.a},${sites.b}`);
|
|
expect(both.body.data.cached).toBe(true);
|
|
expect(both.body.data.sites).toHaveLength(2);
|
|
expect(counts()).toEqual(before);
|
|
});
|
|
|
|
it("settings.search.defaultSiteMode:all = 全部启用站点;custom = 指定站点", async () => {
|
|
await db.deleteFrom("settings").where("key", "=", "search").execute();
|
|
await db
|
|
.insertInto("settings")
|
|
.values({ key: "search", value: JSON.stringify({ defaultSiteMode: "all", siteIds: [] }) })
|
|
.execute();
|
|
const all = await req(`/search/releases?q=Oppenheimer`);
|
|
expect(all.body.data.sites.map((s: Loose) => s.siteId).sort()).toEqual([sites.a, sites.b].sort());
|
|
|
|
await db
|
|
.updateTable("settings")
|
|
.set({ value: JSON.stringify({ defaultSiteMode: "custom", siteIds: [sites.b] }) })
|
|
.where("key", "=", "search")
|
|
.execute();
|
|
const custom = await req(`/search/releases?q=Interstellar`);
|
|
expect(custom.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.b]);
|
|
await db.deleteFrom("settings").where("key", "=", "search").execute();
|
|
});
|
|
|
|
it("按作品搜资源:服务端用原名 + 年份 / 译名生成查询词,每站每词一次", async () => {
|
|
const before = counts();
|
|
const r = await req(`/search/releases?tmdb=movie:693134&sites=${sites.a}`);
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]);
|
|
expect(r.body.data.q).toBe("Dune: Part Two 2024");
|
|
const sent = searches(a).slice(before[0]);
|
|
expect(sent).toHaveLength(2);
|
|
expect(r.body.data.sites[0]).toMatchObject({ siteId: sites.a, status: "ok" });
|
|
|
|
const byEntity = await req(`/search/releases?entityId=${dune}&sites=${sites.a}`);
|
|
expect(byEntity.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]);
|
|
expect(byEntity.body.data.cached).toBe(true);
|
|
|
|
expect((await req(`/search/releases?sites=${sites.a}`)).status).toBe(422);
|
|
expect((await req(`/search/releases?tmdb=movie:999999999`)).status).toBe(404);
|
|
});
|
|
|
|
it("GET /media/tmdb/{type}/{id}:TMDB 信息 + 季列表 + 库存对比 + 是否已建档", async () => {
|
|
const before = counts();
|
|
const movie = await req(`/media/tmdb/movie/872585`);
|
|
expect(movie.status).toBe(200);
|
|
expect(movie.body.data).toMatchObject({
|
|
provider: "tmdb",
|
|
entityType: "movie",
|
|
externalId: "872585",
|
|
kind: "movie",
|
|
year: 2023,
|
|
originalTitle: "Oppenheimer",
|
|
localEntityId: null,
|
|
inventory: { state: "not_collected" },
|
|
mediaServers: null,
|
|
seasons: [],
|
|
queries: ["Oppenheimer 2023", "奥本海默"],
|
|
externalIds: { imdb: "tt15398776" },
|
|
});
|
|
|
|
const local = await req(`/media/tmdb/movie/693134?include=mediaServers`);
|
|
expect(local.body.data).toMatchObject({
|
|
localEntityId: dune,
|
|
inventory: { state: "tracked", missing: [{ name: "2160p Remux" }] },
|
|
mediaServers: { mediaEntityId: dune, instances: [] },
|
|
});
|
|
|
|
const tv = await req(`/media/tmdb/tv/136315`);
|
|
expect(tv.body.data.kind).toBe("series");
|
|
expect(tv.body.data.seasons.map((s: Loose) => [s.seasonNumber, s.episodeCount, s.localHave])).toEqual([
|
|
[1, 8, null],
|
|
[2, 10, null],
|
|
[3, 10, null],
|
|
]);
|
|
expect(tv.body.data.queries).toEqual(["The Bear", "熊家餐馆"]);
|
|
|
|
expect((await req(`/media/tmdb/movie/999999999`)).status).toBe(404);
|
|
expect((await req(`/media/tmdb/person/1`)).status).toBe(422);
|
|
// 详情页不查站点
|
|
expect(counts()).toEqual(before);
|
|
});
|
|
|
|
it("GET /inventory/media-servers 可按 TMDB 条目查(未建档时 mediaEntityId = null)", async () => {
|
|
const r = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=872585`);
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toMatchObject({ mediaEntityId: null, instances: [], partial: false });
|
|
const linked = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=693134`);
|
|
expect(linked.body.data.mediaEntityId).toBe(dune);
|
|
expect((await req(`/inventory/media-servers`)).status).toBe(422);
|
|
});
|
|
|
|
it("站点「默认参与搜索」可在 PATCH /sites/{id} 修改,列表返回 searchDefault", async () => {
|
|
const r = await req(`/sites/${sites.b}`, { method: "PATCH", body: { searchDefault: true } });
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.searchDefault).toBe(true);
|
|
const list = await req(`/sites`);
|
|
const byId = Object.fromEntries(list.body.data.items.map((s: Loose) => [s.id, s.searchDefault]));
|
|
expect(byId[sites.a]).toBe(true);
|
|
expect(byId[sites.b]).toBe(true);
|
|
await req(`/sites/${sites.b}`, { method: "PATCH", body: { searchDefault: false } });
|
|
});
|
|
});
|