Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei # Conflicts: # packages/adapters/src/tmdb/index.ts
271 lines
11 KiB
TypeScript
271 lines
11 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",
|
|
`lib:${UUID}`,
|
|
"30 3 * * *",
|
|
"ALL",
|
|
"zh-hans",
|
|
"movie:157336",
|
|
"zh-CN",
|
|
// 发现页筛选:原始语言、地区、年份
|
|
"zh",
|
|
"US",
|
|
"2024",
|
|
];
|
|
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 }),
|
|
// 枚举键的 record 在 zod v4 里要求键齐全
|
|
SubscriptionOverviewPage: (v) => ({
|
|
...v,
|
|
counts: Object.fromEntries(C.SubscriptionGroup.options.map((g) => [g, 0])),
|
|
}),
|
|
PathBindingCreate: (v) => ({ ...v, bindingKind: "season", seasonNumber: 1 }),
|
|
OrganizeRequest: (v) => ({ ...v, sourcePaths: undefined }),
|
|
FilesListQuery: (v) => ({ ...v, filter: "video,unidentified" }),
|
|
EngineRunQuery: (v) => ({ ...v, status: "running,pending", triggeredBy: "user,api_key" }),
|
|
LogFilter: (v) => ({ ...v, type: "task,auth", level: "error,warn" }),
|
|
LogQuery: (v) => ({ ...v, type: "task,auth", level: "error,warn" }),
|
|
LogStreamQuery: (v) => ({ ...v, type: "auto", level: "running" }),
|
|
CatalogLookupInput: (v) => v,
|
|
IdentityResolveInput: (v) => v,
|
|
AssetLocationCreate: (v) => v,
|
|
ReleaseIdentityAssign: (v) => ({ ...v, external: undefined, candidateIndex: undefined }),
|
|
MetadataEnrichInput: (v) => ({ ...v, title: undefined }),
|
|
CacheSettings: (v) => ({ ...v, hardThresholdPercent: 90, resumePercent: 85, cleanupTargetPercent: 80 }),
|
|
GlobalSettings: () => C.DEFAULT_SETTINGS,
|
|
// 方案至少 2 个(id 不同)
|
|
AgentProposeOptionsInput: (v) => {
|
|
const o = (v.options as Record<string, unknown>[])[0]!;
|
|
return { ...v, options: [o, { ...o, id: "b" }] };
|
|
},
|
|
EngineStats: (v) => ({ ...v, byStatus: { pending: 1 } }),
|
|
// 发现页查询:筛选条件只配特定列表(跨字段校验),正例只给列表
|
|
DiscoverTmdbQuery: () => ({ list: "discover", type: "movie", genre: "28", year: "2024" }),
|
|
DiscoverDoubanQuery: () => ({ list: "explore", type: "tv", genre: "悬疑", year: "2024" }),
|
|
DiscoverBangumiQuery: () => ({ list: "rank", year: "2024" }),
|
|
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: { contextBudget: 64_000 } });
|
|
expect(patch).toEqual({ automation: { enabled: false }, pi: { contextBudget: 64_000 } });
|
|
const merged = C.mergeSettings(C.DEFAULT_SETTINGS, patch);
|
|
expect(merged.automation).toEqual({
|
|
enabled: false,
|
|
readOnlyReconcile: false,
|
|
workerLanes: C.DEFAULT_WORKER_LANES,
|
|
});
|
|
// 分道并发部分更新:只改一道,其余保留
|
|
const lanes = C.mergeSettings(merged, C.GlobalSettingsUpdate.parse({ automation: { workerLanes: { file: 3 } } }));
|
|
expect(lanes.automation.workerLanes).toEqual({ file: 3, network: 4, maintenance: 1 });
|
|
expect(C.GlobalSettings.safeParse(lanes).success).toBe(true);
|
|
expect(merged.pi).toEqual({ ...C.DEFAULT_SETTINGS.pi, contextBudget: 64_000 });
|
|
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);
|
|
// 例外:site.health_check 只读首页并写回健康列,幂等可重跑;metadata.enrich 只存补全建议(7 天标题缓存),重跑无副作用
|
|
if (spec.kind !== "read" && spec.kind !== "preview" && id !== "site.health_check" && id !== "metadata.enrich")
|
|
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");
|
|
});
|
|
});
|