diff --git a/src/config/schema.ts b/src/config/schema.ts index 4adafc605438b9b77f26298e1c9324b20ab912f0..7cd1744bb1a5bcd35fa63d1118b8e07e466e60e5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,4 +1,4 @@ -import { Type, type Static, type TObject, type TProperties } from "@sinclair/typebox"; +import { Type, Kind, type Static, type TObject, type TProperties } from "@sinclair/typebox"; const CustomModelSchema = Type.Object({ provider: Type.String(), @@ -56,10 +56,12 @@ export const ConfigSchema = Type.Object({ }); /** Deep-partial version of ConfigSchema for validating TOML override files. */ -function partialObject(schema: TObject) { +export function partialObject(schema: TObject) { const partial: Record = {}; for (const [key, value] of Object.entries(schema.properties)) { - partial[key] = Type.Optional(value as any); + const v = value as any; + const inner = v[Kind] === 'Object' && v.properties ? partialObject(v) : v; + partial[key] = Type.Optional(inner as any); } return Type.Object(partial as any); } diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 1da4518d18f8ff7fb4c0c99b26407c77a4d4655f..039bc35de741daf11b1b5f31010b7241aa1a6451 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -2,8 +2,11 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Type } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; import { ConfigError } from "../src/util/errors.js"; import { loadConfig } from "../src/config/loader.js"; +import { partialObject } from "../src/config/schema.js"; describe("config validation", () => { let configDir: string; @@ -126,3 +129,78 @@ describe("config validation", () => { } }); }); + +describe("partialObject deep-partial behavior", () => { + const NestedSchema = Type.Object({ + name: Type.String(), + inner: Type.Object({ + host: Type.String(), + port: Type.Number(), + }), + }); + + const PartialNested = partialObject(NestedSchema); + + test("accepts empty object (all fields optional at every level)", () => { + const result = Value.Check(PartialNested, {}); + expect(result).toBe(true); + }); + + test("accepts object with nested section present but inner fields omitted", () => { + const result = Value.Check(PartialNested, { inner: {} }); + expect(result).toBe(true); + }); + + test("accepts object with partial inner fields of a nested object", () => { + const result = Value.Check(PartialNested, { inner: { host: "localhost" } }); + expect(result).toBe(true); + }); + + test("accepts fully specified object", () => { + const result = Value.Check(PartialNested, { + name: "test", + inner: { host: "localhost", port: 8080 }, + }); + expect(result).toBe(true); + }); + + test("rejects wrong type inside nested object", () => { + const result = Value.Check(PartialNested, { inner: { port: "not-a-number" } }); + expect(result).toBe(false); + }); + + test("rejects wrong type at top level", () => { + const result = Value.Check(PartialNested, { name: 123 }); + expect(result).toBe(false); + }); + + test("does not recurse into Type.Record", () => { + const SchemaWithRecord = Type.Object({ + headers: Type.Record(Type.String(), Type.String()), + }); + const Partial = partialObject(SchemaWithRecord); + // Record should remain as-is (not turned into a partial object) + // Valid: omitted entirely + expect(Value.Check(Partial, {})).toBe(true); + // Valid: proper record + expect(Value.Check(Partial, { headers: { "x-key": "val" } })).toBe(true); + // Invalid: wrong value type in record + expect(Value.Check(Partial, { headers: { "x-key": 42 } })).toBe(false); + }); + + test("handles deeply nested objects (3 levels)", () => { + const DeepSchema = Type.Object({ + level1: Type.Object({ + level2: Type.Object({ + value: Type.Number(), + }), + }), + }); + const PartialDeep = partialObject(DeepSchema); + expect(Value.Check(PartialDeep, {})).toBe(true); + expect(Value.Check(PartialDeep, { level1: {} })).toBe(true); + expect(Value.Check(PartialDeep, { level1: { level2: {} } })).toBe(true); + expect(Value.Check(PartialDeep, { level1: { level2: { value: 42 } } })).toBe(true); + expect(Value.Check(PartialDeep, { level1: { level2: { value: "nope" } } })).toBe(false); + }); +});