config-validation.test.ts

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