Files
UHD_Media_Tool/apps/server/test/acquisition-organize/classification.test.ts
T
rootandClaude Opus 5.5 c9e8120fc6 test: server 测试共用建库与假引擎,sse-pg 归入串行组
- 新增 apps/server/test/support/{pg,engine}.ts:测试库地址、可用性探测、schema 咨询锁、
  建 schema + 加锁迁移 + 删除、可选去重的假引擎;6 份 harness 改用它(acquisition-organize 与
  file-manager 原先迁移不加锁,并行下有冲突隐患,一并修正)
- sse-pg 起子进程,并行下子进程启动超时,归入 serial
- 修正 b61335e 挪动 / 改名测试后遗留的 import 排序与格式(当时 lint 检查经管道被吞掉退出码)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 17:40:43 +08:00

143 lines
7.5 KiB
TypeScript

import { OpenAPIHono } from "@hono/zod-openapi";
import type { TmdbClient } from "@umt/adapters";
import { DEFAULT_CLASSIFICATION_YAML } from "@umt/domain";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { AuthEnv } from "../../src/http/auth.ts";
import { classifyEntity } from "../../src/modules/acquisition-organize/services/classification.ts";
import { createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
import { registerSettingsRoutes } from "../../src/modules/auth-settings/settings-routes.ts";
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("TMDB classification API and accepted identities (real PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness("classification");
const settingsApp = new OpenAPIHono<AuthEnv>();
registerSettingsRoutes(settingsApp, h.ctx);
h.app.route("/api/v1", settingsApp);
}, 60_000);
afterEach(() => vi.restoreAllMocks());
afterAll(async () => await h?.close());
it("legacy stored organize settings receive default YAML without losing preferences", async () => {
await h.setSettings("organize", { minSizeBytes: 456789, conflictDefault: "skip" });
const reader = createSettingsReader({ db: h.db, logger: h.ctx.logger });
expect(await reader.get("organize")).toMatchObject({
minSizeBytes: 456789,
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
});
it("ordered categories and raw preview do not write settings", async () => {
const view = await h.api("GET", "/classification");
expect(view.status).toBe(200);
expect(view.body.data.yaml).toBe(DEFAULT_CLASSIFICATION_YAML);
expect(view.body.data.categories.movie).toEqual(["动画电影", "演唱会", "华语电影", "亚洲电影", "欧美电影"]);
const preview = await h.api("POST", "/classification/preview", {
yaml: "movie:\n 日本片:\n production_countries: JP\ntv: {}\n",
mediaType: "movie",
tmdb: { production_countries: [{ iso_3166_1: "JP" }] },
});
expect(preview.status).toBe(200);
expect(preview.body.data).toEqual({ category: "日本片", matchedFields: ["production_countries"], fallback: false });
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(DEFAULT_CLASSIFICATION_YAML);
});
it("PUT preserves organize preferences, audits and emits settings change; invalid writes reject", async () => {
await h.setSettings("organize", { ...(await h.ctx.settings.get("organize")), minSizeBytes: 765432 });
const publish = vi.spyOn(h.ctx.events, "publish");
const yaml = "movie:\n 新分类:\n original_language: zh\n 其它: null\ntv: {}\n";
const saved = await h.api("PUT", "/classification", { yaml });
expect(saved.status).toBe(200);
expect(saved.body.data.categories.movie).toEqual(["新分类", "其它"]);
expect(await h.ctx.settings.get("organize")).toMatchObject({ classificationYaml: yaml, minSizeBytes: 765432 });
expect(publish).toHaveBeenCalledWith("settings.changed", { keys: ["organize"] });
expect(
(await h.db.selectFrom("audit_event").selectAll().where("action", "=", "settings.update").execute()).length,
).toBeGreaterThan(0);
for (const invalid of ["movie:\n ../escape: null\ntv: {}", "movie:\n 同名: null\n 同名: null\ntv: {}"]) {
expect((await h.api("PUT", "/classification", { yaml: invalid })).status).toBe(422);
expect((await h.api("PATCH", "/settings", { organize: { classificationYaml: invalid } })).status).toBe(422);
}
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(yaml);
await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML });
});
it("PUT merges current row after waiting rather than overwriting a newer source edit", async () => {
const source = {
id: "33333333-3333-4333-8333-333333333333",
name: "Concurrent source",
enabled: true,
source: { root: "lib:11111111-1111-4111-8111-111111111111", path: "" },
target: { root: "lib:22222222-2222-4222-8222-222222222222", path: "" },
mediaType: "all",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
};
const originalAll = h.ctx.settings.all.bind(h.ctx.settings);
vi.spyOn(h.ctx.settings, "all").mockImplementationOnce(async () => {
const stale = await originalAll();
await h.setSettings("organize", { ...stale.organize, minSizeBytes: 654321, sources: [source] });
return stale;
});
expect((await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML })).status).toBe(200);
const stored = await h.db
.selectFrom("settings")
.select("value")
.where("key", "=", "organize")
.executeTakeFirstOrThrow();
expect(stored.value).toMatchObject({
minSizeBytes: 654321,
sources: [source],
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
});
it("requires accepted movie/tv identity, uses raw detail, and fails without fabricating fallback", async () => {
await h.setSettings("organize", {
...(await h.ctx.settings.get("organize")),
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
const id = await seedEntity(h.db, { kind: "movie", title: "测试片", year: 2020 });
const entity = await h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
const factory = vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
expect((await classifyEntity(h.d, entity)).category).toBeNull();
expect(factory).not.toHaveBeenCalled();
const identity = await h.db
.insertInto("external_identity")
.values({ media_entity_id: id, provider: "tmdb", entity_type: "movie", external_id: "123", status: "proposed" })
.returning("id")
.executeTakeFirstOrThrow();
expect((await classifyEntity(h.d, entity)).category).toBeNull();
await h.db.updateTable("external_identity").set({ status: "accepted" }).where("id", "=", identity.id).execute();
expect(await classifyEntity(h.d, entity)).toEqual({
category: "动画电影",
matchedFields: ["genre_ids"],
fallback: false,
});
expect(getJson).toHaveBeenCalledWith("/movie/123");
getJson.mockRejectedValue(new Error("offline"));
expect(await classifyEntity(h.d, entity)).toEqual({ category: null, matchedFields: [], fallback: false });
getJson.mockResolvedValue({});
expect((await classifyEntity(h.d, entity)).category).toBeNull();
getJson.mockResolvedValue({ genres: [{ id: 10764 }], origin_country: ["CN"] });
const tvId = await seedEntity(h.db, { kind: "series", title: "测试剧", year: 2020 });
const tv = await h.db.selectFrom("media_entity").selectAll().where("id", "=", tvId).executeTakeFirstOrThrow();
await h.db
.insertInto("external_identity")
.values({ media_entity_id: tvId, provider: "tmdb", entity_type: "tv", external_id: "456", status: "accepted" })
.execute();
expect((await classifyEntity(h.d, tv)).category).toBe("综艺");
expect(getJson).toHaveBeenCalledWith("/tv/456");
factory.mockResolvedValue(null);
expect((await classifyEntity(h.d, entity)).category).toBeNull();
});
});