471 lines
12 KiB
Go
471 lines
12 KiB
Go
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 TestParseFileConfigReferenceMaps(t *testing.T) {
|
|
cfg := parseAndApplyConfig(t, `
|
|
version: 1
|
|
pipelines:
|
|
example:
|
|
input: fake/input
|
|
references:
|
|
" roster ": " ./shared-roster.yml "
|
|
artifacts:
|
|
events:
|
|
extract: fake/extract
|
|
references:
|
|
" lore ": " ./lore.md "
|
|
`)
|
|
|
|
profile := cfg.Pipelines["example"]
|
|
if !reflect.DeepEqual(profile.References, map[string]string{"roster": "./shared-roster.yml"}) {
|
|
t.Fatalf("pipeline references = %#v, want trimmed map", profile.References)
|
|
}
|
|
gotLaneRefs := profile.Artifacts["events"].References
|
|
if !reflect.DeepEqual(gotLaneRefs, map[string]string{"lore": "./lore.md"}) {
|
|
t.Fatalf("lane references = %#v, want trimmed map", gotLaneRefs)
|
|
}
|
|
}
|
|
|
|
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 TestApplyFileConfigRejectsDuplicateTrimmedReferenceSlots(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
}{
|
|
{
|
|
name: "pipeline",
|
|
raw: `
|
|
version: 1
|
|
pipelines:
|
|
example:
|
|
input: fake/input
|
|
references:
|
|
roster: ./first.yml
|
|
" roster ": ./second.yml
|
|
`,
|
|
want: `pipeline "example" reference slot`,
|
|
},
|
|
{
|
|
name: "lane",
|
|
raw: `
|
|
version: 1
|
|
pipelines:
|
|
example:
|
|
input: fake/input
|
|
artifacts:
|
|
events:
|
|
extract: fake/extract
|
|
references:
|
|
roster: ./first.yml
|
|
" roster ": ./second.yml
|
|
`,
|
|
want: `pipeline "example" lane "events" reference slot`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fileCfg, err := ParseFileConfigYAML([]byte(tc.raw))
|
|
if err != nil {
|
|
t.Fatalf("ParseFileConfigYAML: %v", err)
|
|
}
|
|
cfg := Default()
|
|
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "duplicated") {
|
|
t.Fatalf("expected duplicate reference slot 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
|
|
}
|
|
}
|