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(); 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(); }); });