Files
openclaw/src/config/validation.test.ts
solodmd d21ddd3f7a test(config): add unit tests for mapZodIssueToConfigIssue (#97736)
* test(config): add unit tests for mapZodIssueToConfigIssue

* fix: remove unused ts-expect-error directive
2026-06-29 07:55:11 -07:00

437 lines
15 KiB
TypeScript

// Unit tests for mapZodIssueToConfigIssue (config validation issue mapper).
import { describe, expect, it } from "vitest";
import { testing } from "./validation.js";
// ---------------------------------------------------------------------------
// Basic path and message mapping
// ---------------------------------------------------------------------------
describe("mapZodIssueToConfigIssue", () => {
describe("basic path and message", () => {
it("maps a simple path and message", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: ["channels", "telegram", "botToken"],
message: "invalid bot token",
});
expect(result).toEqual({
path: "channels.telegram.botToken",
message: "invalid bot token",
});
});
it("defaults message to 'Invalid input' when message is missing", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: ["foo"],
});
expect(result.path).toBe("foo");
expect(result.message).toBe("Invalid input");
});
it("defaults message to 'Invalid input' when message is not a string", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: ["foo"],
message: 42,
});
expect(result.message).toBe("Invalid input");
});
it("handles empty path array", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: [],
message: "root error",
});
expect(result.path).toBe("");
});
it("handles non-array path", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: "scalar",
message: "bad path",
});
expect(result.path).toBe("");
});
it("includes numeric array-index segments in path", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: ["agents", "list", 0, "name"],
message: "missing name",
});
expect(result.path).toBe("agents.list.0.name");
});
});
// ---------------------------------------------------------------------------
// Numeric bound hints (too_big / too_small)
// ---------------------------------------------------------------------------
describe("numeric bound hints", () => {
it("appends (maximum: N) for too_big inclusive with number origin", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_big",
path: ["timeoutMs"],
message: "Value is too big",
origin: "number",
maximum: 100,
inclusive: true,
});
expect(result.message).toMatch(/\(maximum: 100\)/);
});
it("appends (must be less than N) for too_big exclusive with number origin", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_big",
path: ["timeoutMs"],
message: "Value is too big",
origin: "number",
maximum: 100,
inclusive: false,
});
expect(result.message).toMatch(/\(must be less than 100\)/);
});
it("appends (minimum: N) for too_small inclusive with number origin", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_small",
path: ["maxTokens"],
message: "Value is too small",
origin: "number",
minimum: 1,
inclusive: true,
});
expect(result.message).toMatch(/\(minimum: 1\)/);
});
it("appends (must be greater than N) for too_small exclusive with number origin", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_small",
path: ["maxTokens"],
message: "Value is too small",
origin: "number",
minimum: 1,
inclusive: false,
});
expect(result.message).toMatch(/\(must be greater than 1\)/);
});
it("does not add bound hint when maximum is missing for too_big", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_big",
path: ["timeoutMs"],
message: "Value is too big",
origin: "number",
inclusive: true,
});
expect(result.message).toBe("Value is too big");
});
it("does not add bound hint when minimum is missing for too_small", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_small",
path: ["maxTokens"],
message: "Value is too small",
origin: "number",
inclusive: true,
});
expect(result.message).toBe("Value is too small");
});
it("does not add bound hint when origin is not 'number'", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_big",
path: ["strLength"],
message: "Value is too big",
origin: "string",
maximum: 10,
inclusive: true,
});
expect(result.message).toBe("Value is too big");
});
it("does not add bound hint for too_big with invalid inclusive default", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "too_big",
path: ["count"],
message: "Value is too big",
origin: "number",
maximum: 5,
// inclusive defaults to false when missing
});
expect(result.message).toMatch(/\(must be less than 5\)/);
});
});
// ---------------------------------------------------------------------------
// Allowed values collection
// ---------------------------------------------------------------------------
describe("allowed values", () => {
it("collects allowed values from invalid_value issue", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_value",
path: ["update", "channel"],
message: "Invalid enum value",
values: ["stable", "beta", "dev"],
});
expect(result).toMatchObject({
path: "update.channel",
message: expect.stringContaining("Invalid enum value"),
allowedValues: ["stable", "beta", "dev"],
});
});
it("collects boolean allowed values from invalid_type with expected boolean", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_type",
path: ["enabled"],
message: "Expected boolean, received string",
expected: "boolean",
});
expect(result).toMatchObject({
path: "enabled",
allowedValues: ["true", "false"],
});
});
it("does not collect allowed values for invalid_type with non-boolean expected", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_type",
path: ["port"],
message: "Expected number, received string",
expected: "number",
});
expect(result.allowedValues).toBeUndefined();
});
it("collects allowed values from custom issue with 'expected one of' message", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: ["mode"],
message: 'expected one of "fast"|"balanced"|"thorough"',
});
expect(result).toMatchObject({
path: "mode",
allowedValues: ["fast", "balanced", "thorough"],
});
});
it("does not collect allowed values from custom issue without expected one of pattern", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: ["plugins", "entries", "my-plugin"],
message: "plugin not found",
});
expect(result.allowedValues).toBeUndefined();
});
it("collects allowed values from invalid_union with complete nested branches", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["dmPolicy"],
message: "Invalid input",
errors: [
[{ code: "invalid_value", path: [], values: ["pairing", "allowlist"] }],
[{ code: "invalid_value", path: [], values: ["open", "disabled"] }],
],
});
expect(result).toMatchObject({
path: "dmPolicy",
allowedValues: expect.arrayContaining(["pairing", "allowlist", "open", "disabled"]),
});
expect(result.allowedValues).toHaveLength(4);
});
it("returns no allowed values for invalid_union with incomplete branch", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["dmPolicy"],
message: "Invalid input",
errors: [
[{ code: "invalid_value", path: [], values: ["pairing"] }],
// empty branch → incomplete
[],
],
});
expect(result.allowedValues).toBeUndefined();
expect(result.allowedValuesHiddenCount).toBeUndefined();
});
it("returns no allowed values for invalid_union with empty errors", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["dmPolicy"],
message: "Invalid input",
errors: [],
});
expect(result.allowedValues).toBeUndefined();
});
it("returns no allowed values for invalid_union with non-array errors", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["dmPolicy"],
message: "Invalid input",
errors: "not-an-array",
});
expect(result.allowedValues).toBeUndefined();
});
it("returns no allowed values for invalid_value with non-array values", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_value",
path: ["color"],
message: "Invalid value",
values: "not-an-array",
});
expect(result.allowedValues).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// AllowedValuesHiddenCount when > MAX_ALLOWED_VALUES_HINT (12)
// ---------------------------------------------------------------------------
describe("allowed values hidden count", () => {
it("includes hiddenCount when allowed values exceed 12", () => {
const values = Array.from({ length: 15 }, (_, i) => `value-${i}`);
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_value",
path: ["many"],
message: "too many options",
values,
});
expect(result.allowedValues).toHaveLength(12);
expect(result.allowedValuesHiddenCount).toBe(3);
});
it("has hiddenCount 0 when allowed values are 12 or fewer", () => {
const values = Array.from({ length: 5 }, (_, i) => `v${i}`);
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_value",
path: ["few"],
message: "some options",
values,
});
expect(result.allowedValues).toHaveLength(5);
expect(result.allowedValuesHiddenCount).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Bindings-specific union extraction
// ---------------------------------------------------------------------------
describe("bindings-specific union extraction", () => {
it("extracts a matching branch issue for bindings path with route mismatch", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["bindings", 0],
message: "Invalid input",
errors: [
// Route type mismatch — triggers the extraction, will be filtered
[{ code: "invalid_value", path: ["type"], values: ["route"] }],
// Valid branch — this one gets extracted
[{ code: "invalid_type", path: ["url"], message: "Expected string" }],
],
});
expect(result).toEqual({
path: "bindings.0.url",
message: "Expected string",
});
});
it("filters out route-type-mismatch branches", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["bindings", 0],
message: "Invalid input",
errors: [
// Route type mismatch — should be skipped
[{ code: "invalid_value", path: ["type"], values: ["route"] }],
// Valid branch
[{ code: "invalid_type", path: ["channel"], message: "Expected string" }],
],
});
expect(result).toEqual({
path: "bindings.0.channel",
message: "Expected string",
});
});
it("returns null (no extraction) when multiple branches match", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["bindings", 0],
message: "Invalid input",
errors: [
[{ code: "invalid_type", path: ["url"], message: "Bad url" }],
[{ code: "invalid_type", path: ["channel"], message: "Bad channel" }],
],
});
// Ambiguous — falls back to the original union message
expect(result.path).toBe("bindings.0");
expect(result.message).toBe("Invalid input");
});
it("returns null for non-bindings invalid_union", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["logging", "level"],
message: "Invalid input",
errors: [[{ code: "invalid_value", path: [], values: ["debug", "info"] }]],
});
// Non-bindings path with allowed values → shows allowed values
expect(result.allowedValues).toBeDefined();
});
it("returns null for bindings with no array errors", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "invalid_union",
path: ["bindings", 0],
message: "Invalid input",
});
expect(result.path).toBe("bindings.0");
expect(result.message).toBe("Invalid input");
});
});
// ---------------------------------------------------------------------------
// Combined scenarios
// ---------------------------------------------------------------------------
describe("combined scenarios", () => {
it("includes both numeric bound hint and allowed values when both apply", () => {
const result = testing.mapZodIssueToConfigIssue({
code: "custom",
path: ["maxTokens"],
message: 'expected one of "100"|"200"|"500"|"1000"',
origin: "number",
maximum: 1000,
inclusive: true,
});
// Message already contains allowed values, so no "(allowed: ...)" suffix
expect(result.message).toMatch(/expected one of/);
// But the allowed values metadata is still attached
expect(result.allowedValues).toContain("100");
expect(result.allowedValues).toContain("200");
});
it("handles malformed input gracefully", () => {
const result = testing.mapZodIssueToConfigIssue(null);
expect(result.path).toBe("");
expect(result.message).toBe("Invalid input");
});
it("handles undefined input gracefully", () => {
const result = testing.mapZodIssueToConfigIssue(undefined);
expect(result.path).toBe("");
expect(result.message).toBe("Invalid input");
});
});
});