Files
UHD_Media_Tool/packages/contracts/test/schemas.test.ts
T
rootandClaude Opus 5.5 a24bda952b feat(server): W2-B sources, identity resolution, metadata and site workbench
- Sites / accounts / feeds: registry view (/site-definitions), site CRUD with
  enable/disable (history stays queryable, marked site_disabled), accounts and
  feed URLs stored as secrets (only secret_ref / redacted urlDisplay exposed),
  feed enable/disable/reset/fetch; one shared rate limiter + adapter per site.
- Tasks: ingestFeed (FeedFetchState/FeedCursor on feed_source, observation
  feed+guid upsert with digest-change updates, source_release site+id
  aggregation across feeds, auth failure -> account invalid + decision, no
  retry, gap -> feed.gap_suspected -> budgeted backfill snapshot),
  resolveObservation (parseRelease -> local recall via title_alias pg_trgm +
  accepted identities -> TMDB -> resolveIdentity; auto binds entity /
  provisional units / identities / aliases, otherwise identity_candidate pool
  with evidence version and bounded review backoff; manual lock respected),
  refreshMetadata (metadata_field + field_override, user locks win, paths
  untouched), snapshotSite (site_snapshot coverage records).
- Events: feed.due, feed.gap_suspected, release.observed (self-consumed),
  release.resolved, decision.created/updated; scheduler in worker start().
- Identity API: entity detail with redirects, manual mapping correction with
  identity_revision, merge with identity_redirect, pool list/assign/dismiss/
  retry, content unit numbering lock, aliases, external identities.
- Decisions: list/summary (by entity and kind)/detail/resolve; identity
  options executed directly.
- Workbench: /sites/{id}/releases (snapshot first, coalesced live search with
  backoff, origin + freshness) and /sites/{id}/releases/{rid} (volatile field
  recheck times, refresh keeps data on failure).
- Capabilities: catalog.lookup, release.search, release.inspect,
  identity.resolve.
- Migrations 0018-0020: site.adapter api, source_release.resolution_locked,
  site_snapshot, identity_candidate, resolution_attempt, identity_redirect,
  metadata_field, field_override.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:12:01 +08:00

231 lines
8.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { z } from "zod";
import * as C from "../src/index.ts";
// 对 contracts 导出的每个 zod schema 做正反例 parse:
// 正例由 sample() 按 schema 结构生成(字符串/数字按候选值逐个试到通过),个别 refine 由 OVERRIDES 修正;
// 反例把一个必填字段替换成非法值(对象)或直接传 Symbol(非对象)。
interface Def {
type: string;
innerType?: z.ZodType;
shape?: Record<string, z.ZodType>;
options?: z.ZodType[];
entries?: Record<string, string>;
values?: unknown[];
element?: z.ZodType;
in?: z.ZodType;
defaultValue?: unknown;
}
const def = (s: z.ZodType) => (s as unknown as { _zod: { def: Def } })._zod.def;
const UUID = "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60";
const STRING_CANDIDATES = [
UUID,
"2026-09-27T08:00:00.000Z",
"2026-09-27",
"https://example.test/x",
"a".repeat(40),
"/media/movies",
"tmdb",
"123",
"CatalogLookupInput",
"catalog.lookup",
"测试 Test",
"123456",
];
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
function pick<T>(schema: z.ZodType, candidates: readonly T[]): T {
const hit = candidates.find((c) => schema.safeParse(c).success);
if (hit === undefined) throw new Error(`没有候选值能通过 ${def(schema).type}`);
return hit;
}
function sample(schema: z.ZodType): unknown {
const d = def(schema);
switch (d.type) {
case "string":
return pick(schema, STRING_CANDIDATES);
case "number":
return pick(schema, NUMBER_CANDIDATES);
case "boolean":
return true;
case "enum":
return Object.values(d.entries!)[0];
case "literal":
return d.values![0];
case "nullable":
case "optional":
case "default":
case "prefault":
case "readonly":
return sample(d.innerType!);
case "array":
return [sample(d.element!)];
case "record":
return { k: "v" };
case "unknown":
return { any: 1 };
case "union":
return sample(d.options![0]!);
case "pipe":
return sample(d.in!);
case "object": {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(d.shape!)) out[k] = sample(v);
return out;
}
default:
throw new Error(`sample 未支持 ${d.type}`);
}
}
/** 需要满足跨字段 refine 的正例修正 */
const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, unknown>> = {
PathMappingCreate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
PathMappingUpdate: (v) => ({ ...v, viewKind: "downloader", mediaServerInstanceId: null }),
ExternalIdentityCreate: (v) => ({ ...v, contentUnitId: null }),
PathBindingCreate: (v) => ({ ...v, bindingKind: "season", seasonNumber: 1 }),
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
CatalogLookupInput: (v) => v,
IdentityResolveInput: (v) => v,
AssetLocationCreate: (v) => v,
ReleaseIdentityAssign: (v) => ({ ...v, external: undefined, candidateIndex: undefined }),
CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }),
GlobalSettings: () => C.DEFAULT_SETTINGS,
EngineStats: (v) => ({ ...v, byStatus: { pending: 1 } }),
TypedEventEnvelope: () => ({
seq: 1,
at: "2026-09-27T08:00:00Z",
type: "system.heartbeat",
payload: { role: "all" },
}),
};
const isSchema = (v: unknown): v is z.ZodType => v instanceof z.ZodType;
const schemas: [string, z.ZodType][] = [];
for (const [k, v] of Object.entries(C) as [string, unknown][]) if (isSchema(v)) schemas.push([k, v]);
describe("所有导出 schema 的正反例", () => {
it("导出了足够多的 schema", () => {
expect(schemas.length).toBeGreaterThan(150);
});
for (const [name, schema] of schemas) {
it(name, () => {
let good = sample(schema);
const fix = OVERRIDES[name];
if (fix && good && typeof good === "object") good = fix(good as Record<string, unknown>);
const ok = schema.safeParse(good);
if (!ok.success) throw new Error(`${name} 正例失败: ${ok.error.message}\n${JSON.stringify(good)}`);
// 反例
const d = def(schema);
if (d.type === "object") {
const shape = d.shape!;
const key = Object.keys(shape).find(
(k) => !shape[k]!.safeParse(undefined).success && def(shape[k]!).type !== "unknown",
);
const bad = key ? { ...(good as object), [key]: Symbol("bad") } : Symbol("bad");
expect(schema.safeParse(bad).success, `${name} 反例应失败(${key ?? "整体"})`).toBe(false);
} else {
expect(schema.safeParse(Symbol("bad")).success, `${name} 反例应失败`).toBe(false);
}
});
}
});
describe("关键合同行为", () => {
it("Slot 状态集合与 REQ §6.1 一致", () => {
expect(C.SlotStatus.options).toEqual([
"empty",
"reserved",
"downloading",
"verifying",
"satisfied",
"upgrade_pending",
"attention",
]);
});
it("CacheSettings 拒绝恢复水位 ≥ 硬阈值", () => {
expect(C.CacheSettings.safeParse({ ...C.DEFAULT_SETTINGS.cache, resumePercent: 95 }).success).toBe(false);
});
it("GlobalSettingsUpdate 部分更新不会注入默认值,mergeSettings 保留现值", () => {
const patch = C.GlobalSettingsUpdate.parse({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
expect(patch).toEqual({ automation: { enabled: false }, pi: { apiKey: "sk-x" } });
const merged = C.mergeSettings(C.DEFAULT_SETTINGS, patch);
expect(merged.automation).toEqual({ enabled: false, readOnlyReconcile: false });
expect(merged.pi).toEqual(C.DEFAULT_SETTINGS.pi);
expect(C.GlobalSettings.safeParse(merged).success).toBe(true);
});
it("OrganizationChangesQuery 接受查询串并给默认值", () => {
expect(C.OrganizationChangesQuery.parse({ after: "42", limit: "10", includeFiles: "false" })).toEqual({
after: "42",
limit: 10,
includeFiles: false,
});
expect(C.OrganizationChangesQuery.safeParse({ after: "-1" }).success).toBe(false);
});
it("ExternalIdentityCreate 要求且只能有一个本地目标", () => {
const base = { provider: "tmdb", entityType: "movie", externalId: "1" };
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID }).success).toBe(true);
expect(C.ExternalIdentityCreate.safeParse({ ...base, mediaEntityId: UUID, contentUnitId: UUID }).success).toBe(
false,
);
});
it("能力目录覆盖首批 14 个能力且 schema 引用都存在", () => {
expect(Object.keys(C.CAPABILITY_CATALOG).sort()).toEqual([...C.CAPABILITY_IDS].sort());
const exported = C as Record<string, unknown>;
for (const cap of Object.values(C.CAPABILITY_CATALOG)) {
expect(C.CapabilityDescriptor.safeParse(cap).success).toBe(true);
expect(isSchema(exported[cap.inputSchema]), cap.inputSchema).toBe(true);
expect(isSchema(exported[cap.outputSchema]), cap.outputSchema).toBe(true);
expect(exported[cap.inputSchema]).toBe(C.CAPABILITY_IO[cap.id as C.KnownCapabilityId].input);
expect(exported[cap.outputSchema]).toBe(C.CAPABILITY_IO[cap.id as C.KnownCapabilityId].output);
}
});
it("capabilitySpec 组装目录元数据与真实 schema;有副作用的类别不可重放", () => {
for (const id of C.CAPABILITY_IDS) {
const spec = C.capabilitySpec(id);
const { inputSchema: _i, outputSchema: _o, ...meta } = C.CAPABILITY_CATALOG[id];
expect(spec).toMatchObject(meta);
expect(spec.inputSchema).toBe(C.CAPABILITY_IO[id].input);
expect(spec.outputSchema).toBe(C.CAPABILITY_IO[id].output);
if (spec.kind !== "read" && spec.kind !== "preview") expect(spec.replay, id).toBe("never");
}
});
it("事件 payload 表覆盖全部事件类型", () => {
expect(Object.keys(C.EVENT_PAYLOADS).sort()).toEqual(Object.values(C.EventType).sort());
expect(
C.parseEventPayload("task.status_changed", {
taskId: UUID,
kind: "ingestFeed",
from: null,
to: "running",
error: null,
}),
).not.toBeNull();
expect(C.parseEventPayload("task.status_changed", { taskId: "x" })).toBeNull();
expect(C.isEventType("nope.x")).toBe(false);
});
it("API 端点路径唯一且 buildPath 填参", () => {
const eps = C.listEndpoints();
const keys = eps.map((e) => `${e.method} ${e.path}`);
expect(new Set(keys).size).toBe(keys.length);
expect(C.buildPath(C.Api.tasks.cancel.path, { id: UUID })).toBe(`/api/v1/tasks/${UUID}/cancel`);
expect(() => C.buildPath(C.Api.tasks.get.path)).toThrow();
// 公开整理记录读写分权
expect(C.Api.organization.changes.scope).toBe("organize.read");
expect(C.Api.organization.create.scope).toBe("organize.execute");
});
});