package config import ( "os" "path/filepath" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics" ) func TestParseMinimalValidConfig(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 1 `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } if fileCfg.Version != SupportedFileConfigVersion { t.Fatalf("unexpected version: %d", fileCfg.Version) } } func TestLoadFileConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(path, []byte("version: 1\n"), 0o644); err != nil { t.Fatalf("write config: %v", err) } fileCfg, err := LoadFileConfig(path) if err != nil { t.Fatalf("LoadFileConfig: %v", err) } if fileCfg.Version != SupportedFileConfigVersion { t.Fatalf("unexpected version: %d", fileCfg.Version) } } func TestParseFileConfigRejectsUnknownYAMLFields(t *testing.T) { _, err := ParseFileConfigYAML([]byte(` version: 1 unexpected: true `)) if err == nil || !strings.Contains(err.Error(), "field unexpected not found") { t.Fatalf("expected unknown field error, got %v", err) } } func TestParseFileConfigRejectsUnknownModuleBindingFields(t *testing.T) { _, err := ParseFileConfigYAML([]byte(` version: 1 pipelines: example: input: module: fake/input unexpected: true artifacts: events: extract: fake/extract `)) if err == nil || !strings.Contains(err.Error(), "field unexpected not found") { t.Fatalf("expected unknown binding field error, got %v", err) } } func TestParseFileConfigRejectsMissingAndUnsupportedVersion(t *testing.T) { tests := []struct { name string data string want string }{ {name: "missing", data: `llm_profiles: {}`, want: "version is required"}, {name: "unsupported", data: `version: 2`, want: "unsupported config version"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := ParseFileConfigYAML([]byte(tc.data)) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("expected error containing %q, got %v", tc.want, err) } }) } } func TestParseFileConfigModuleBindingForms(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 1 pipelines: example: input: fake/input chunk: module: generic options: size: 10 flags: - alpha nested: enabled: true artifacts: events: extract: module: fake/extract llm_profile: fast options: temperature: 0 merge: appendorder normalize: module: noop output: json `) profile := cfg.Pipelines["example"] if profile.Input.Module != "fake/input" { t.Fatalf("unexpected input binding: %+v", profile.Input) } if profile.Chunk.Module != "generic" { t.Fatalf("unexpected chunk binding: %+v", profile.Chunk) } if profile.Chunk.Options["size"] != 10 { t.Fatalf("expected chunk options to preserve scalar, got %#v", profile.Chunk.Options) } if !reflect.DeepEqual(profile.Chunk.Options["flags"], []any{"alpha"}) { t.Fatalf("expected list option, got %#v", profile.Chunk.Options["flags"]) } nested, ok := profile.Chunk.Options["nested"].(map[string]any) if !ok || nested["enabled"] != true { t.Fatalf("expected nested map option, got %#v", profile.Chunk.Options["nested"]) } lane := profile.Artifacts["events"] if lane.Extract.Module != "fake/extract" || lane.Extract.LLMProfile != "fast" { t.Fatalf("unexpected extract binding: %+v", lane.Extract) } if lane.Extract.Options["temperature"] != 0 { t.Fatalf("expected object options, got %#v", lane.Extract.Options) } if lane.Merge.Module != "appendorder" || lane.Normalize.Module != "noop" { t.Fatalf("unexpected lane defaults: %+v", lane) } if profile.Output.Module != "json" { t.Fatalf("unexpected output binding: %+v", profile.Output) } } func TestParseFileConfigValidatorMixedBindingForms(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 1 pipelines: example: input: fake/input artifacts: events: extract: fake/extract validators: - fake/validator - module: fake/llm-validator llm_profile: careful options: threshold: 0.7 `) validators := cfg.Pipelines["example"].Artifacts["events"].Validators if len(validators) != 2 { t.Fatalf("expected two validators, got %d", len(validators)) } if validators[0].Module != "fake/validator" { t.Fatalf("unexpected shorthand validator: %+v", validators[0]) } if validators[1].Module != "fake/llm-validator" || validators[1].LLMProfile != "careful" { t.Fatalf("unexpected object validator: %+v", validators[1]) } if validators[1].Options["threshold"] != 0.7 { t.Fatalf("unexpected validator options: %#v", validators[1].Options) } } func TestParseFileConfigDurationParsing(t *testing.T) { tests := []struct { name string raw string want int }{ {name: "integer seconds", raw: "600", want: 600}, {name: "duration string", raw: "10m", want: 600}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 1 llm_profiles: default: timeout: `+tc.raw+` `) if got := cfg.LLMProfiles["default"].TimeoutSeconds; got != tc.want { t.Fatalf("TimeoutSeconds = %d, want %d", got, tc.want) } }) } } func TestParseFileConfigRejectsSubsecondDuration(t *testing.T) { _, err := ParseFileConfigYAML([]byte(` version: 1 llm_profiles: default: timeout: 1500ms `)) if err == nil || !strings.Contains(err.Error(), "whole seconds") { t.Fatalf("expected whole-seconds duration error, got %v", err) } } func TestApplyFileConfigResolvesAPIKeyEnv(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 1 llm_profiles: default: api_key_env: NOTARIUS_TEST_API_KEY `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() if err := cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"})); err != nil { t.Fatalf("ApplyFileConfig: %v", err) } profile := cfg.LLMProfiles["default"] if profile.APIKeyEnv != "NOTARIUS_TEST_API_KEY" || profile.APIKey != "secret" { t.Fatalf("unexpected resolved API key: %+v", profile) } } func TestApplyFileConfigRejectsDuplicateTrimmedLLMProfileIDs(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 1 llm_profiles: default: model: first " default ": model: second `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), "llm profile id") || !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate LLM profile ID error, got %v", err) } } func TestApplyFileConfigRejectsDuplicateTrimmedPipelineIDs(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 1 pipelines: example: input: fake/input " example ": input: fake/other-input `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), "pipeline id") || !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate pipeline ID error, got %v", err) } } func TestApplyFileConfigRejectsDuplicateTrimmedArtifactLaneIDs(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 1 pipelines: example: input: fake/input artifacts: events: extract: fake/extract " events ": extract: fake/other-extract `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), `pipeline "example" artifact lane id`) || !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate artifact lane ID error, got %v", err) } } func TestApplyFileConfigAllowsRetryOnlyLLMProfile(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 1 llm_profiles: retry-only: max_retries: 3 `) profile := cfg.LLMProfiles["retry-only"] if profile.MaxRetries != 3 { t.Fatalf("unexpected max retries: %d", profile.MaxRetries) } if profile.TimeoutSeconds != 0 { t.Fatalf("expected unset timeout, got %d", profile.TimeoutSeconds) } if profile.MaxConcurrency != 0 { t.Fatalf("expected unset max concurrency, got %d", profile.MaxConcurrency) } } func TestApplyFileConfigRejectsInvalidAPIKeyEnv(t *testing.T) { tests := []struct { name string env string want string }{ {name: "invalid name", env: "NOTARIUS-KEY", want: "environment variable name"}, {name: "not set", env: "NOTARIUS_TEST_API_KEY", want: "is not set"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fileCfg, err := ParseFileConfigYAML([]byte(` version: 1 llm_profiles: default: api_key_env: ` + tc.env + ` `)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("expected error containing %q, got %v", tc.want, err) } }) } } func TestApplyFileConfigOperationalSections(t *testing.T) { cfg := parseAndApplyConfig(t, ` version: 1 concurrency: total_llm: 4 diagnostics: work_dir: /tmp/notarius-test retention: always `) if cfg.Concurrency.TotalLLM != 4 { t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM) } if cfg.Diagnostics.WorkDir != "/tmp/notarius-test" { t.Fatalf("unexpected work dir: %q", cfg.Diagnostics.WorkDir) } if cfg.Diagnostics.Retention != diagnostics.RetentionAlways { t.Fatalf("unexpected retention: %q", cfg.Diagnostics.Retention) } } func parseAndApplyConfig(t *testing.T, raw string) Config { t.Helper() fileCfg, err := ParseFileConfigYAML([]byte(raw)) if err != nil { t.Fatalf("ParseFileConfigYAML: %v", err) } cfg := Default() if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil { t.Fatalf("ApplyFileConfig: %v", err) } return cfg } func emptyLookup(string) (string, bool) { return "", false } func mapLookup(values map[string]string) func(string) (string, bool) { return func(key string) (string, bool) { value, ok := values[key] return value, ok } }