config-validation.test.ts

  1import { describe, test, expect, beforeEach, afterEach } from "bun:test";
  2import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
  3import { tmpdir } from "node:os";
  4import { join } from "node:path";
  5import { Type } from "@sinclair/typebox";
  6import { Value } from "@sinclair/typebox/value";
  7import { ConfigError } from "../src/util/errors.js";
  8import { loadConfig } from "../src/config/loader.js";
  9import { partialObject } from "../src/config/schema.js";
 10
 11describe("config validation", () => {
 12  let configDir: string;
 13  let configPath: string;
 14  const originalEnv = { ...process.env };
 15
 16  beforeEach(() => {
 17    configDir = mkdtempSync(join(tmpdir(), "rumilo-cfg-test-"));
 18    configPath = join(configDir, "config.toml");
 19    process.env["XDG_CONFIG_HOME"] = join(configDir, "..");
 20    // loadConfig looks for <XDG_CONFIG_HOME>/rumilo/config.toml
 21    // So we need the dir structure to match
 22    const rumiloDir = join(configDir, "..", "rumilo");
 23    require("node:fs").mkdirSync(rumiloDir, { recursive: true });
 24    configPath = join(rumiloDir, "config.toml");
 25  });
 26
 27  afterEach(() => {
 28    process.env = { ...originalEnv };
 29    try {
 30      rmSync(configDir, { recursive: true, force: true });
 31      // Also clean up the rumilo dir we created
 32      const rumiloDir = join(configDir, "..", "rumilo");
 33      rmSync(rumiloDir, { recursive: true, force: true });
 34    } catch {}
 35  });
 36
 37  test("rejects defaults.model with wrong type (number instead of string)", async () => {
 38    writeFileSync(
 39      configPath,
 40      `[defaults]\nmodel = 42\ncleanup = true\n`,
 41    );
 42    await expect(loadConfig()).rejects.toThrow(ConfigError);
 43    await expect(loadConfig()).rejects.toThrow(/defaults\/model/);
 44  });
 45
 46  test("rejects defaults.cleanup with wrong type (string instead of boolean)", async () => {
 47    writeFileSync(
 48      configPath,
 49      `[defaults]\nmodel = "anthropic:claude-sonnet-4-20250514"\ncleanup = "yes"\n`,
 50    );
 51    await expect(loadConfig()).rejects.toThrow(ConfigError);
 52    await expect(loadConfig()).rejects.toThrow(/defaults\/cleanup/);
 53  });
 54
 55  test("rejects repo.default_depth with wrong type (string instead of number)", async () => {
 56    writeFileSync(
 57      configPath,
 58      `[repo]\ndefault_depth = "deep"\n`,
 59    );
 60    await expect(loadConfig()).rejects.toThrow(ConfigError);
 61    await expect(loadConfig()).rejects.toThrow(/repo\/default_depth/);
 62  });
 63
 64  test("rejects repo.default_depth below minimum (0)", async () => {
 65    writeFileSync(
 66      configPath,
 67      `[repo]\ndefault_depth = 0\n`,
 68    );
 69    await expect(loadConfig()).rejects.toThrow(ConfigError);
 70    await expect(loadConfig()).rejects.toThrow(/default_depth/);
 71  });
 72
 73  test("rejects unknown top-level section type (number instead of object)", async () => {
 74    // web should be an object but we pass a string value at top level
 75    writeFileSync(
 76      configPath,
 77      `[defaults]\nmodel = "x"\ncleanup = true\n[web]\nmodel = 123\n`,
 78    );
 79    await expect(loadConfig()).rejects.toThrow(ConfigError);
 80  });
 81
 82  test("accepts valid partial config (only [repo] section)", async () => {
 83    writeFileSync(
 84      configPath,
 85      `[repo]\nmodel = "anthropic:claude-sonnet-4-20250514"\ndefault_depth = 5\n`,
 86    );
 87    const { config } = await loadConfig();
 88    expect(config.repo.model).toBe("anthropic:claude-sonnet-4-20250514");
 89    expect(config.repo.default_depth).toBe(5);
 90    // defaults should come from defaultConfig
 91    expect(config.defaults.model).toBe("anthropic:claude-sonnet-4-20250514");
 92  });
 93
 94  test("accepts valid complete config", async () => {
 95    writeFileSync(
 96      configPath,
 97      [
 98        `[defaults]`,
 99        `model = "openai:gpt-4"`,
100        `cleanup = false`,
101        ``,
102        `[web]`,
103        `model = "openai:gpt-4"`,
104        ``,
105        `[repo]`,
106        `model = "openai:gpt-4"`,
107        `default_depth = 3`,
108        `blob_limit = "10m"`,
109      ].join("\n"),
110    );
111    const { config } = await loadConfig();
112    expect(config.defaults.model).toBe("openai:gpt-4");
113    expect(config.defaults.cleanup).toBe(false);
114    expect(config.repo.default_depth).toBe(3);
115  });
116
117  test("error message includes path and expected type for diagnostics", async () => {
118    writeFileSync(
119      configPath,
120      `[defaults]\nmodel = 42\ncleanup = true\n`,
121    );
122    try {
123      await loadConfig();
124      throw new Error("should have thrown");
125    } catch (e: any) {
126      expect(e).toBeInstanceOf(ConfigError);
127      expect(e.message).toContain("/defaults/model");
128      expect(e.message).toMatch(/string/i);
129    }
130  });
131});
132
133describe("partialObject deep-partial behavior", () => {
134  const NestedSchema = Type.Object({
135    name: Type.String(),
136    inner: Type.Object({
137      host: Type.String(),
138      port: Type.Number(),
139    }),
140  });
141
142  const PartialNested = partialObject(NestedSchema);
143
144  test("accepts empty object (all fields optional at every level)", () => {
145    const result = Value.Check(PartialNested, {});
146    expect(result).toBe(true);
147  });
148
149  test("accepts object with nested section present but inner fields omitted", () => {
150    const result = Value.Check(PartialNested, { inner: {} });
151    expect(result).toBe(true);
152  });
153
154  test("accepts object with partial inner fields of a nested object", () => {
155    const result = Value.Check(PartialNested, { inner: { host: "localhost" } });
156    expect(result).toBe(true);
157  });
158
159  test("accepts fully specified object", () => {
160    const result = Value.Check(PartialNested, {
161      name: "test",
162      inner: { host: "localhost", port: 8080 },
163    });
164    expect(result).toBe(true);
165  });
166
167  test("rejects wrong type inside nested object", () => {
168    const result = Value.Check(PartialNested, { inner: { port: "not-a-number" } });
169    expect(result).toBe(false);
170  });
171
172  test("rejects wrong type at top level", () => {
173    const result = Value.Check(PartialNested, { name: 123 });
174    expect(result).toBe(false);
175  });
176
177  test("does not recurse into Type.Record", () => {
178    const SchemaWithRecord = Type.Object({
179      headers: Type.Record(Type.String(), Type.String()),
180    });
181    const Partial = partialObject(SchemaWithRecord);
182    // Record should remain as-is (not turned into a partial object)
183    // Valid: omitted entirely
184    expect(Value.Check(Partial, {})).toBe(true);
185    // Valid: proper record
186    expect(Value.Check(Partial, { headers: { "x-key": "val" } })).toBe(true);
187    // Invalid: wrong value type in record
188    expect(Value.Check(Partial, { headers: { "x-key": 42 } })).toBe(false);
189  });
190
191  test("handles deeply nested objects (3 levels)", () => {
192    const DeepSchema = Type.Object({
193      level1: Type.Object({
194        level2: Type.Object({
195          value: Type.Number(),
196        }),
197      }),
198    });
199    const PartialDeep = partialObject(DeepSchema);
200    expect(Value.Check(PartialDeep, {})).toBe(true);
201    expect(Value.Check(PartialDeep, { level1: {} })).toBe(true);
202    expect(Value.Check(PartialDeep, { level1: { level2: {} } })).toBe(true);
203    expect(Value.Check(PartialDeep, { level1: { level2: { value: 42 } } })).toBe(true);
204    expect(Value.Check(PartialDeep, { level1: { level2: { value: "nope" } } })).toBe(false);
205  });
206});