package config import ( "errors" "os" "path/filepath" "testing" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" ) func TestLoadConfigMissingImplicitPathUsesBuiltInDefaults(t *testing.T) { tmp := t.TempDir() missing := filepath.Join(tmp, "missing.yml") got, err := LoadConfig(missing, false) if err != nil { t.Fatalf("expected no error, got %v", err) } want := BuiltInDefaults() if got != want { t.Fatalf("unexpected settings: got=%+v want=%+v", got, want) } } func TestBuiltInDefaultsIncludeHTTPSizeLimits(t *testing.T) { got := BuiltInDefaults() if got.MaxRequestBytes != defaults.HTTPMaxRequestBytesDefault { t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes) } if got.MaxArtifactBytes != defaults.HTTPMaxArtifactBytesDefault { t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes) } if got.MaxResponseBytes != defaults.HTTPMaxResponseBytesDefault { t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes) } } func TestLoadConfigMissingExplicitPathReturnsError(t *testing.T) { tmp := t.TempDir() missing := filepath.Join(tmp, "missing.yml") _, err := LoadConfig(missing, true) if err == nil { t.Fatal("expected error for missing explicit config") } if !errors.Is(err, ErrConfigNotFound) { t.Fatalf("expected ErrConfigNotFound, got %v", err) } } func TestLoadConfigInvalidYAMLReturnsError(t *testing.T) { path := writeConfigFile(t, "config.yml", "prompt_dir: [") _, err := LoadConfig(path, true) if err == nil { t.Fatal("expected invalid YAML error") } if !errors.Is(err, ErrInvalidConfigYAML) { t.Fatalf("expected ErrInvalidConfigYAML, got %v", err) } } func TestLoadConfigInvalidYAMLImplicitPathReturnsError(t *testing.T) { path := writeConfigFile(t, "config.yml", "prompt_dir: [") _, err := LoadConfig(path, false) if err == nil { t.Fatal("expected invalid YAML error") } if !errors.Is(err, ErrInvalidConfigYAML) { t.Fatalf("expected ErrInvalidConfigYAML, got %v", err) } } func TestLoadConfigUnknownFieldReturnsError(t *testing.T) { path := writeConfigFile(t, "config.yml", "unknown_field: true\n") _, err := LoadConfig(path, true) if err == nil { t.Fatal("expected unknown field error") } if !errors.Is(err, ErrInvalidConfigYAML) { t.Fatalf("expected ErrInvalidConfigYAML, got %v", err) } } func TestLoadConfigAPIKeyFieldIsRejectedAsUnknown(t *testing.T) { path := writeConfigFile(t, "config.yml", "api_key: secret\n") _, err := LoadConfig(path, true) if err == nil { t.Fatal("expected unknown field error for api_key") } if !errors.Is(err, ErrInvalidConfigYAML) { t.Fatalf("expected ErrInvalidConfigYAML, got %v", err) } } func TestLoadConfigValidConfigSetsDirectoriesAndServerAddr(t *testing.T) { path := writeConfigFile(t, "config.yml", ` prompt_dir: ./prompts profile_dir: ./profiles schema_dir: ./schemas server: addr: 127.0.0.1:9090 artifact_root: ./artifacts max_request_bytes: 1024 max_artifact_bytes: 2048 max_response_bytes: 4096 defaults: render_format: json `) got, err := LoadConfig(path, true) if err != nil { t.Fatalf("expected no error, got %v", err) } if got.PromptDir != filepath.Clean("./prompts") { t.Fatalf("unexpected prompt_dir: %q", got.PromptDir) } if got.ProfileDir != filepath.Clean("./profiles") { t.Fatalf("unexpected profile_dir: %q", got.ProfileDir) } if got.SchemaDir != filepath.Clean("./schemas") { t.Fatalf("unexpected schema_dir: %q", got.SchemaDir) } if got.ServerAddr != "127.0.0.1:9090" { t.Fatalf("unexpected server.addr: %q", got.ServerAddr) } if got.ArtifactRoot != filepath.Clean("./artifacts") { t.Fatalf("unexpected server.artifact_root: %q", got.ArtifactRoot) } if got.MaxRequestBytes != 1024 { t.Fatalf("unexpected server.max_request_bytes: %d", got.MaxRequestBytes) } if got.MaxArtifactBytes != 2048 { t.Fatalf("unexpected server.max_artifact_bytes: %d", got.MaxArtifactBytes) } if got.MaxResponseBytes != 4096 { t.Fatalf("unexpected server.max_response_bytes: %d", got.MaxResponseBytes) } if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON { t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat) } } func TestLoadConfigAcceptsZeroHTTPSizeLimits(t *testing.T) { path := writeConfigFile(t, "config.yml", ` server: max_request_bytes: 0 max_artifact_bytes: 0 max_response_bytes: 0 `) got, err := LoadConfig(path, true) if err != nil { t.Fatalf("expected no error, got %v", err) } if got.MaxRequestBytes != 0 || got.MaxArtifactBytes != 0 || got.MaxResponseBytes != 0 { t.Fatalf("expected zero limits to be preserved, got request=%d artifact=%d response=%d", got.MaxRequestBytes, got.MaxArtifactBytes, got.MaxResponseBytes) } } func TestLoadConfigRejectsNegativeHTTPSizeLimits(t *testing.T) { tests := []struct { name string body string }{ {name: "request", body: "server:\n max_request_bytes: -1\n"}, {name: "artifact", body: "server:\n max_artifact_bytes: -1\n"}, {name: "response", body: "server:\n max_response_bytes: -1\n"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { path := writeConfigFile(t, "config.yml", tc.body) _, err := LoadConfig(path, true) if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("expected ErrInvalidConfig, got %v", err) } }) } } func TestLoadConfigEmptyFileResolvesToBuiltInDefaults(t *testing.T) { path := writeConfigFile(t, "config.yml", "") got, err := LoadConfig(path, true) if err != nil { t.Fatalf("expected no error, got %v", err) } want := BuiltInDefaults() if got != want { t.Fatalf("unexpected settings: got=%+v want=%+v", got, want) } } func TestLoadConfigImplicitSearchPrefersUsrLocalEtcOverEtc(t *testing.T) { tmp := t.TempDir() localPath := filepath.Join(tmp, "usr-local.yml") etcPath := filepath.Join(tmp, "etc.yml") if err := os.WriteFile(localPath, []byte("prompt_dir: ./from-usr-local\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(etcPath, []byte("prompt_dir: ./from-etc\n"), 0o644); err != nil { t.Fatal(err) } orig := defaultConfigSearchPaths defaultConfigSearchPaths = []string{localPath, etcPath} t.Cleanup(func() { defaultConfigSearchPaths = orig }) got, err := LoadConfig("", false) if err != nil { t.Fatalf("expected no error, got %v", err) } if got.PromptDir != filepath.Clean("./from-usr-local") { t.Fatalf("expected usr-local config to win, got prompt_dir=%q", got.PromptDir) } } func TestLoadConfigImplicitSearchFallsBackToEtcWhenUsrLocalMissing(t *testing.T) { tmp := t.TempDir() missingLocal := filepath.Join(tmp, "missing-local.yml") etcPath := filepath.Join(tmp, "etc.yml") if err := os.WriteFile(etcPath, []byte("prompt_dir: ./from-etc\n"), 0o644); err != nil { t.Fatal(err) } orig := defaultConfigSearchPaths defaultConfigSearchPaths = []string{missingLocal, etcPath} t.Cleanup(func() { defaultConfigSearchPaths = orig }) got, err := LoadConfig("", false) if err != nil { t.Fatalf("expected no error, got %v", err) } if got.PromptDir != filepath.Clean("./from-etc") { t.Fatalf("expected etc fallback config, got prompt_dir=%q", got.PromptDir) } } func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) { base := AppSettings{ PromptDir: "/from/config/prompts", ProfileDir: "/from/config/profiles", SchemaDir: "/from/config/schemas", ServerAddr: ":1234", ArtifactRoot: "/from/config/artifacts", MaxRequestBytes: 111, MaxArtifactBytes: 222, MaxResponseBytes: 333, DefaultRenderFormat: renderformat.PreparedRunFormatJSON, } maxRequestBytes := int64(0) maxArtifactBytes := int64(444) maxResponseBytes := int64(555) got, err := ApplyCLIOverrides(base, CLIOverrides{ PromptDir: "./prompts-cli", ProfileDir: "./profiles-cli", SchemaDir: "./schemas-cli", ServerAddr: ":8081", ArtifactRoot: "./artifacts-cli", MaxRequestBytes: &maxRequestBytes, MaxArtifactBytes: &maxArtifactBytes, MaxResponseBytes: &maxResponseBytes, RenderFormat: "text", }) if err != nil { t.Fatalf("expected no error, got %v", err) } if got.PromptDir != filepath.Clean("./prompts-cli") { t.Fatalf("unexpected prompt dir: %q", got.PromptDir) } if got.ProfileDir != filepath.Clean("./profiles-cli") { t.Fatalf("unexpected profile dir: %q", got.ProfileDir) } if got.SchemaDir != filepath.Clean("./schemas-cli") { t.Fatalf("unexpected schema dir: %q", got.SchemaDir) } if got.ServerAddr != ":8081" { t.Fatalf("unexpected server addr: %q", got.ServerAddr) } if got.ArtifactRoot != filepath.Clean("./artifacts-cli") { t.Fatalf("unexpected artifact root: %q", got.ArtifactRoot) } if got.MaxRequestBytes != 0 { t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes) } if got.MaxArtifactBytes != 444 { t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes) } if got.MaxResponseBytes != 555 { t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes) } if got.DefaultRenderFormat != renderformat.PreparedRunFormatText { t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat) } } func TestApplyCLIOverridesRejectsNegativeHTTPSizeLimits(t *testing.T) { negative := int64(-1) tests := []struct { name string overrides CLIOverrides }{ {name: "request", overrides: CLIOverrides{MaxRequestBytes: &negative}}, {name: "artifact", overrides: CLIOverrides{MaxArtifactBytes: &negative}}, {name: "response", overrides: CLIOverrides{MaxResponseBytes: &negative}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := ApplyCLIOverrides(BuiltInDefaults(), tc.overrides) if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("expected ErrInvalidConfig, got %v", err) } }) } } func TestApplyCLIOverridesInvalidRenderFormatReturnsError(t *testing.T) { _, err := ApplyCLIOverrides(BuiltInDefaults(), CLIOverrides{RenderFormat: "yaml"}) if err == nil { t.Fatal("expected invalid render format error") } if !errors.Is(err, ErrInvalidConfig) { t.Fatalf("expected ErrInvalidConfig, got %v", err) } } func writeConfigFile(t *testing.T, name, content string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write config file: %v", err) } return path }