Files
scriptorium/internal/config/load_test.go

234 lines
9.2 KiB
Go

package config
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func useConfigSearchPaths(t *testing.T, paths ...string) {
t.Helper()
previous := defaultConfigSearchPaths
defaultConfigSearchPaths = paths
t.Cleanup(func() { defaultConfigSearchPaths = previous })
}
func writeApplicationFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoadSelectsOrderedSourceAndKeepsItsSnapshot(t *testing.T) {
dir := t.TempDir()
first := filepath.Join(dir, "first.yml")
second := filepath.Join(dir, "second.yml")
writeApplicationFile(t, first, "prompt_dir: ./first\n")
writeApplicationFile(t, second, "prompt_dir: ./second\n")
useConfigSearchPaths(t, first, second)
result, err := Load(LoadOptions{Path: filepath.Join(dir, "ignored.yml")})
if err != nil {
t.Fatalf("load first implicit source: %v", err)
}
if result.Config.PromptDir != "first" || len(result.Sources()) != 1 || result.Sources()[0].Path != first {
t.Fatalf("unexpected implicit source: config=%+v sources=%+v", result.Config, result.Sources())
}
writeApplicationFile(t, first, "prompt_dir: ./changed\n")
if result.Config.PromptDir != "first" {
t.Fatalf("result did not retain resolved snapshot: %+v", result.Config)
}
if err := os.Remove(first); err != nil {
t.Fatal(err)
}
result, err = Load(LoadOptions{})
if err != nil || result.Config.PromptDir != "second" || result.Sources()[0].Path != second {
t.Fatalf("second implicit source not selected: result=%+v err=%v", result, err)
}
result, err = Load(LoadOptions{Path: first, Explicit: true})
if result != nil || err == nil {
t.Fatalf("missing explicit source was accepted: result=%+v err=%v", result, err)
}
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("typed load error was lost: %v", err)
}
}
func TestLoadRejectsExplicitEmptyAndDistinguishesEmptyFile(t *testing.T) {
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
if _, err := Load(LoadOptions{Explicit: true, Path: ""}); err == nil {
t.Fatal("explicit empty path was accepted")
} else {
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("expected upstream source diagnostic, got %v", err)
}
}
dir := t.TempDir()
empty := filepath.Join(dir, "empty.yml")
writeApplicationFile(t, empty, "")
if _, err := Load(LoadOptions{Path: empty, Explicit: true}); err == nil {
t.Fatal("empty file was treated as defaults-only")
}
writeApplicationFile(t, empty, "{}\n")
result, err := Load(LoadOptions{Path: empty, Explicit: true})
if err != nil || !reflect.DeepEqual(result.Config, documentDefaults()) {
t.Fatalf("empty mapping did not load defaults: result=%+v err=%v", result, err)
}
}
func TestLoadDefaultsOnlyAndOverridesBeforeValidation(t *testing.T) {
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
result, err := Load(LoadOptions{})
if err != nil || !reflect.DeepEqual(result.Config, documentDefaults()) || len(result.Sources()) != 0 {
t.Fatalf("defaults-only load: result=%+v err=%v", result, err)
}
var effective strings.Builder
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "render_format: text") || len(result.Sources()) != 0 {
t.Fatalf("defaults-only inspection changed: effective=%q err=%v sources=%+v", effective.String(), err, result.Sources())
}
result, err = Load(LoadOptions{Overrides: []configkit.Override{{
Path: configkit.Keys("server", "max_request_bytes"), Value: int64(0), Flag: "--max-request-bytes",
}}})
if err != nil || result.Config.Server.MaxRequestBytes != 0 || len(result.Sources()) != 1 || result.Sources()[0].Layer != configkit.LayerCLI {
t.Fatalf("defaults-only override lost: result=%+v err=%v", result, err)
}
path := filepath.Join(t.TempDir(), "invalid.yml")
writeApplicationFile(t, path, "defaults:\n render_format: invalid-choice\n")
result, err = Load(LoadOptions{Path: path, Explicit: true, Overrides: []configkit.Override{{
Path: configkit.Keys("defaults", "render_format"), Value: "json", Flag: "--render-format",
}}})
if err != nil || result.Config.Defaults.RenderFormat != "json" {
t.Fatalf("override did not precede validation: result=%+v err=%v", result, err)
}
if len(result.Sources()) != 2 || result.Sources()[0].Layer != configkit.LayerBase || result.Sources()[1].Layer != configkit.LayerCLI {
t.Fatalf("source order changed: %+v", result.Sources())
}
writeApplicationFile(t, path, "defaults: [\n")
if _, err := Load(LoadOptions{Path: path, Explicit: true, Overrides: []configkit.Override{{
Path: configkit.Keys("defaults", "render_format"), Value: "json", Flag: "--render-format",
}}}); err == nil {
t.Fatal("override bypassed malformed lower-level YAML")
}
}
func TestSettingsCopiesResolvedValuesAndSortsBackends(t *testing.T) {
result, err := loadDocumentBytes(`schema_dir: ""
transient_retries: 0
server:
addr: ""
artifact_root: ""
max_request_bytes: 0
defaults:
render_format: json
backends:
zed:
endpoint: http://zed.invalid/v1
extra_params:
count: 4
alpha:
queue_capacity: 0
`)
if err != nil {
t.Fatal(err)
}
settings := Settings(result.Config)
if settings.SchemaDir != "" || settings.ServerAddr != "" || settings.MaxRequestBytes != 0 || settings.DefaultRenderFormat != "json" {
t.Fatalf("resolved scalar values changed: %+v", settings)
}
if settings.TransientRetries == nil || *settings.TransientRetries != 0 {
t.Fatalf("retry presence lost: %+v", settings.TransientRetries)
}
if len(settings.Backends) != 2 || settings.Backends[0].ID != "alpha" || settings.Backends[1].ID != "zed" {
t.Fatalf("backend order changed: %+v", settings.Backends)
}
if settings.Backends[0].QueueCapacity == nil || *settings.Backends[0].QueueCapacity != 0 || settings.Backends[1].QueueCapacity != nil {
t.Fatalf("queue presence changed: %+v", settings.Backends)
}
if value := settings.Backends[1].ExtraParams["count"]; value != int64(4) {
t.Fatalf("dynamic number changed shape: %T %v", value, value)
}
if got := Settings(documentDefaults()); got.TransientRetries != nil || got.Backends != nil {
t.Fatalf("default nullable values changed: %+v", got)
}
}
func TestLoadDiscoveryErrorQuotesPath(t *testing.T) {
dir := t.TempDir()
loop := filepath.Join(dir, "loop-\n.yml")
if err := os.Symlink(loop, loop); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
useConfigSearchPaths(t, loop)
_, err := Load(LoadOptions{})
if err == nil || !strings.Contains(err.Error(), `\n`) || strings.Contains(err.Error(), "loop-\n.yml") {
t.Fatalf("discovery error did not quote path safely: %v", err)
}
}
func TestLoadComposesExplicitImportsAndSelectedOverlay(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "fragments"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o700); err != nil {
t.Fatal(err)
}
base := filepath.Join(dir, "config.yml")
writeApplicationFile(t, base, "configkit:\n imports:\n - glob: fragments/*.yml\nprompt_dir: ./base-prompts\nserver:\n addr: :8081\n")
writeApplicationFile(t, filepath.Join(dir, "fragments", "a.yml"), "backends:\n alpha:\n endpoint: http://alpha.invalid/v1\n")
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n beta:\n endpoint: http://beta.invalid/v1\n")
writeApplicationFile(t, filepath.Join(dir, "overlays", "local.yml"), "prompt_dir: ./overlay-prompts\nserver:\n addr: :9090\n")
result, err := Load(LoadOptions{Path: base, Explicit: true})
if err != nil || result.Config.PromptDir != "base-prompts" || len(result.Config.Backends) != 2 || len(result.Sources()) != 3 {
t.Fatalf("disjoint imports did not compose: result=%+v err=%v", result, err)
}
result, err = Load(LoadOptions{Path: base, Explicit: true, Overlay: "local", Overrides: []configkit.Override{{
Path: configkit.Keys("server", "addr"), Value: ":7070", Flag: "--addr",
}}})
if err != nil || result.Config.PromptDir != "overlay-prompts" || result.Config.Server.Addr != ":7070" || len(result.Sources()) != 5 {
t.Fatalf("overlay or explicit override changed: result=%+v err=%v", result, err)
}
if _, err := Load(LoadOptions{Path: base, Explicit: true, Overlay: "missing"}); err == nil {
t.Fatal("missing selected overlay accepted")
}
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n alpha:\n endpoint: http://conflict.invalid/v1\n")
if _, err := Load(LoadOptions{Path: base, Explicit: true}); err == nil {
t.Fatal("conflicting import accepted")
} else {
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("conflict lost structured diagnostic: %v", err)
}
}
}
func TestLoadRejectsOverlayWithoutBase(t *testing.T) {
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
if _, err := Load(LoadOptions{Overlay: "local"}); err == nil {
t.Fatal("overlay without base accepted")
} else {
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("no-base overlay lost structured diagnostic: %v", err)
}
}
if _, err := Load(LoadOptions{Overlay: "default"}); err != nil {
t.Fatalf("default must select no overlay: %v", err)
}
}