- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀, w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名 - vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行); pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s - CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争) - ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单 Co-Authored-By: Claude Opus 5.5 <[email protected]>
143 lines
7.5 KiB
TypeScript
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 { 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 { classifyEntity } from "../../src/modules/acquisition-organize/services/classification.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();
|
|
});
|
|
});
|