812 lines
28 KiB
Go
812 lines
28 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestNotariusOmittedAndDisabledBehavior(t *testing.T) {
|
|
dir := t.TempDir()
|
|
omittedPath := filepath.Join(dir, "omitted.yml")
|
|
if err := os.WriteFile(omittedPath, []byte(testPipelineBaseYAML), 0o644); err != nil {
|
|
t.Fatalf("write omitted pipeline: %v", err)
|
|
}
|
|
omitted, err := LoadPipeline(omittedPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadPipeline(omitted) error = %v", err)
|
|
}
|
|
if omitted.Notarius != nil {
|
|
t.Fatalf("Notarius = %#v, want nil when omitted", omitted.Notarius)
|
|
}
|
|
|
|
disabledPath := filepath.Join(dir, "disabled.yml")
|
|
disabledYAML := testPipelineBaseYAML + `
|
|
notarius:
|
|
enabled: false
|
|
config_path: relative/notarius.yml
|
|
`
|
|
if err := os.WriteFile(disabledPath, []byte(disabledYAML), 0o644); err != nil {
|
|
t.Fatalf("write disabled pipeline: %v", err)
|
|
}
|
|
disabled, err := LoadPipeline(disabledPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadPipeline(disabled) error = %v", err)
|
|
}
|
|
if disabled.Notarius == nil {
|
|
t.Fatal("Notarius = nil, want configured disabled section")
|
|
}
|
|
if disabled.Notarius.Enabled {
|
|
t.Fatal("Notarius.Enabled = true, want false")
|
|
}
|
|
if disabled.Notarius.Binary != DefaultNotariusBinary || disabled.Notarius.Timeout != DefaultNotariusTimeout {
|
|
t.Fatalf("disabled defaults = %#v", disabled.Notarius)
|
|
}
|
|
if disabled.Notarius.Resume || disabled.Notarius.ChunkCache != "" {
|
|
t.Fatalf("disabled cache-control defaults = %#v", disabled.Notarius)
|
|
}
|
|
if disabled.Notarius.ConfigPath != "relative/notarius.yml" || disabled.Notarius.WorkingDirectory != "" {
|
|
t.Fatalf("disabled paths were resolved unexpectedly: %#v", disabled.Notarius)
|
|
}
|
|
}
|
|
|
|
func TestNotariusCredentialReferenceLoading(t *testing.T) {
|
|
const envName = "NARRATIO_TEST_NOTARIUS_KEY"
|
|
const canary = "notarius-credential-canary"
|
|
t.Setenv(envName, canary)
|
|
|
|
tests := []struct {
|
|
name string
|
|
credential string
|
|
wantNil bool
|
|
}{
|
|
{name: "omitted", wantNil: true},
|
|
{name: "null", credential: " llm_api_key: null\n", wantNil: true},
|
|
{name: "custom reference", credential: " llm_api_key: " + envName + "\n"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
|
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n" + test.credential
|
|
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := LoadPipeline(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if test.wantNil {
|
|
if cfg.Notarius.LLMAPIKey != nil {
|
|
t.Fatal("credential reference should be nil")
|
|
}
|
|
return
|
|
}
|
|
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != envName ||
|
|
!cfg.Notarius.LLMAPIKey.IsResolved() || cfg.Notarius.LLMAPIKey.Value() != canary {
|
|
t.Fatal("credential reference was not loaded and resolved")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotariusCredentialReferenceFailures(t *testing.T) {
|
|
const missing = "NARRATIO_TEST_NOTARIUS_MISSING"
|
|
if err := os.Unsetenv(missing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tests := []struct {
|
|
name, reference, want string
|
|
}{
|
|
{name: "missing", reference: missing, want: "secret.unset"},
|
|
{name: "empty", reference: `""`, want: "secret.invalid_name"},
|
|
{name: "invalid", reference: "123-NOT-AN-ENV-NAME", want: "secret.invalid_name"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
|
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n llm_api_key: " + test.reference + "\n"
|
|
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := LoadPipeline(path)
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("LoadPipeline() error = %v, want classification %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml")
|
|
if err := os.MkdirAll(filepath.Dir(pipelinePath), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
pipelineYAML := testPipelineBaseYAML + `
|
|
notarius:
|
|
enabled: true
|
|
config_path: notarius/config.yml
|
|
pipeline_id: dnd-session
|
|
outputs:
|
|
npc_registry:
|
|
lane_id: npc-registry
|
|
media_type: application/json
|
|
schema_id: notarius.dnd.npc_registry
|
|
schema_version: v1
|
|
`
|
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatalf("write pipeline: %v", err)
|
|
}
|
|
|
|
cfg, err := LoadPipeline(pipelinePath)
|
|
if err != nil {
|
|
t.Fatalf("LoadPipeline() error = %v", err)
|
|
}
|
|
wantConfigPath := filepath.Join(filepath.Dir(pipelinePath), "notarius", "config.yml")
|
|
if cfg.Notarius.Binary != "notarius" || cfg.Notarius.Timeout != "3h" {
|
|
t.Fatalf("defaults = %#v", cfg.Notarius)
|
|
}
|
|
if cfg.Notarius.ConfigPath != wantConfigPath {
|
|
t.Fatalf("config_path = %q, want %q", cfg.Notarius.ConfigPath, wantConfigPath)
|
|
}
|
|
if cfg.Notarius.WorkingDirectory != filepath.Dir(wantConfigPath) {
|
|
t.Fatalf("working_directory = %q, want %q", cfg.Notarius.WorkingDirectory, filepath.Dir(wantConfigPath))
|
|
}
|
|
|
|
explicitPath := filepath.Join(dir, "explicit.yml")
|
|
explicitYAML := strings.Replace(pipelineYAML, " pipeline_id: dnd-session\n", " pipeline_id: dnd-session\n working_directory: runtime\n", 1)
|
|
if err := os.WriteFile(explicitPath, []byte(explicitYAML), 0o644); err != nil {
|
|
t.Fatalf("write explicit pipeline: %v", err)
|
|
}
|
|
explicit, err := LoadPipeline(explicitPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadPipeline(explicit working directory) error = %v", err)
|
|
}
|
|
if explicit.Notarius.WorkingDirectory != filepath.Join(dir, "runtime") {
|
|
t.Fatalf("explicit working_directory = %q, want %q", explicit.Notarius.WorkingDirectory, filepath.Join(dir, "runtime"))
|
|
}
|
|
|
|
for _, value := range []string{`""`, `" "`} {
|
|
t.Run("explicit blank "+value, func(t *testing.T) {
|
|
blankPath := filepath.Join(t.TempDir(), "pipeline.yml")
|
|
blankYAML := strings.Replace(pipelineYAML, " pipeline_id: dnd-session\n", " pipeline_id: dnd-session\n working_directory: "+value+"\n", 1)
|
|
if err := os.WriteFile(blankPath, []byte(blankYAML), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blank, err := LoadPipeline(blankPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadPipeline(explicit blank working directory) error = %v", err)
|
|
}
|
|
if blank.Notarius.WorkingDirectory != "" {
|
|
t.Fatalf("explicit blank working_directory = %q, want empty", blank.Notarius.WorkingDirectory)
|
|
}
|
|
if err := ValidatePipelineConfig(blank); err == nil || !strings.Contains(err.Error(), "working_directory is required") {
|
|
t.Fatalf("ValidatePipelineConfig() error = %v, want required working directory", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotariusCacheControlNormalizationAndValidation(t *testing.T) {
|
|
for _, mode := range []string{"", "auto", "bypass", "refresh"} {
|
|
t.Run("mode "+mode, func(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
cfg.Resume = true
|
|
if mode != "" {
|
|
cfg.ChunkCache = " " + mode + " "
|
|
}
|
|
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
|
|
t.Fatalf("NormalizePipelineConfig() error = %v", err)
|
|
}
|
|
if cfg.ChunkCache != mode {
|
|
t.Fatalf("ChunkCache = %q, want %q", cfg.ChunkCache, mode)
|
|
}
|
|
if !cfg.Resume {
|
|
t.Fatal("Resume = false, want true")
|
|
}
|
|
if err := validateNotarius(cfg, nil); err != nil {
|
|
t.Fatalf("validateNotarius() error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
for _, mode := range []string{"AUTO", "invalid"} {
|
|
t.Run("invalid "+mode, func(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
cfg.ChunkCache = mode
|
|
err := validateNotarius(cfg, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "pipeline.notarius.chunk_cache") {
|
|
t.Fatalf("validateNotarius() error = %v, want chunk_cache error", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
disabled := &NotariusConfig{Enabled: false, Resume: true, ChunkCache: " INVALID "}
|
|
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: disabled}); err != nil {
|
|
t.Fatalf("NormalizePipelineConfig(disabled) error = %v", err)
|
|
}
|
|
if disabled.ChunkCache != " INVALID " {
|
|
t.Fatalf("disabled ChunkCache = %q, want unchanged", disabled.ChunkCache)
|
|
}
|
|
if err := validateNotarius(disabled, nil); err != nil {
|
|
t.Fatalf("validateNotarius(disabled) error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNotariusCacheControlsSafeSerialization(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
cfg.Resume = true
|
|
cfg.ChunkCache = "refresh"
|
|
|
|
yamlData, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jsonData, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(yamlData), "resume: true") || !strings.Contains(string(yamlData), "chunk_cache: refresh") {
|
|
t.Fatalf("YAML omitted cache controls: %s", yamlData)
|
|
}
|
|
if !strings.Contains(string(jsonData), `"resume":true`) || !strings.Contains(string(jsonData), `"chunk_cache":"refresh"`) {
|
|
t.Fatalf("JSON omitted cache controls: %s", jsonData)
|
|
}
|
|
|
|
cfg.Resume = false
|
|
cfg.ChunkCache = ""
|
|
yamlData, err = yaml.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jsonData, err = json.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, serialized := range []string{string(yamlData), string(jsonData)} {
|
|
if strings.Contains(serialized, "resume") || strings.Contains(serialized, "chunk_cache") {
|
|
t.Fatalf("zero-value cache controls were serialized: %s", serialized)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNotariusRelativePathsDoNotMakeEffectiveDigestLocationDependent(t *testing.T) {
|
|
pipelineYAML := testPipelineBaseYAML + `
|
|
notarius:
|
|
enabled: true
|
|
config_path: notarius/config.yml
|
|
pipeline_id: dnd-session
|
|
outputs:
|
|
npc_registry:
|
|
lane_id: npc-registry
|
|
media_type: application/json
|
|
schema_id: notarius.dnd.npc_registry
|
|
schema_version: v1
|
|
`
|
|
paths := make([]string, 2)
|
|
configs := make([]*PipelineConfig, 2)
|
|
for index := range paths {
|
|
dir := t.TempDir()
|
|
paths[index] = filepath.Join(dir, "pipeline.yml")
|
|
if err := os.WriteFile(paths[index], []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatalf("write pipeline %d: %v", index, err)
|
|
}
|
|
loaded, err := LoadPipeline(paths[index])
|
|
if err != nil {
|
|
t.Fatalf("LoadPipeline(%d) error = %v", index, err)
|
|
}
|
|
configs[index] = loaded
|
|
}
|
|
if configs[0].Notarius.ConfigPath == configs[1].Notarius.ConfigPath ||
|
|
configs[0].Notarius.WorkingDirectory == configs[1].Notarius.WorkingDirectory {
|
|
t.Fatalf("runtime Notarius paths should remain location-specific: %#v / %#v", configs[0].Notarius, configs[1].Notarius)
|
|
}
|
|
if first, second := EffectivePipelineDigest(configs[0]), EffectivePipelineDigest(configs[1]); first == "" || first != second {
|
|
t.Fatalf("relocated logical configuration digests = %q / %q, want equal non-empty values", first, second)
|
|
}
|
|
}
|
|
|
|
func TestNotariusStrictYAML(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
}{
|
|
{name: "unknown section field", yaml: "notarius:\n unknown: true\n"},
|
|
{name: "unknown output field", yaml: "notarius:\n outputs:\n npc_registry:\n lane_id: npc-registry\n unknown: true\n"},
|
|
{name: "unsupported session id", yaml: "notarius:\n session_id: forbidden\n"},
|
|
{name: "unsupported model", yaml: "notarius:\n model: forbidden\n"},
|
|
{name: "duplicate reference selector", yaml: "notarius:\n references:\n party: narratio.input.party\n party: narratio.input.players\n"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
|
if err := os.WriteFile(path, []byte(testPipelineBaseYAML+"\n"+tt.yaml), 0o644); err != nil {
|
|
t.Fatalf("write pipeline: %v", err)
|
|
}
|
|
_, err := LoadPipeline(path)
|
|
want := "strict decode failed"
|
|
if tt.name == "duplicate reference selector" {
|
|
want = "yaml.duplicate_key"
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("LoadPipeline() error = %v, want containing %q", err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotariusReferenceValidationAndNormalization(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
cfg.References = map[string]string{
|
|
" party ": " narratio.input.party ",
|
|
" chunk . players ": "narratio.input.players",
|
|
" npc-registry . extract . glossary ": "narratio.input.glossary",
|
|
"spells": "narratio.input.spell_catalog",
|
|
}
|
|
|
|
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
|
|
t.Fatalf("NormalizePipelineConfig() error = %v", err)
|
|
}
|
|
if err := validateNotarius(cfg, nil); err != nil {
|
|
t.Fatalf("validateNotarius() after normalization error = %v", err)
|
|
}
|
|
want := map[string]string{
|
|
"party": "narratio.input.party",
|
|
"chunk.players": "narratio.input.players",
|
|
"npc-registry.extract.glossary": "narratio.input.glossary",
|
|
"spells": "narratio.input.spell_catalog",
|
|
}
|
|
if len(cfg.References) != len(want) {
|
|
t.Fatalf("normalized references = %#v, want %#v", cfg.References, want)
|
|
}
|
|
for selector, source := range want {
|
|
if cfg.References[selector] != source {
|
|
t.Fatalf("references[%q] = %q, want %q", selector, cfg.References[selector], source)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNotariusReferenceValidationRejectsInvalidBindings(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
references map[string]string
|
|
wantField string
|
|
wantReason string
|
|
}{
|
|
{name: "empty selector", references: map[string]string{" ": "narratio.input.party"}, wantField: "pipeline.notarius.references", wantReason: "selector is required"},
|
|
{name: "equals in selector", references: map[string]string{"party=x": "narratio.input.party"}, wantField: "pipeline.notarius.references", wantReason: "must not contain"},
|
|
{name: "invalid stage", references: map[string]string{"lane.prepare.party": "narratio.input.party"}, wantField: "pipeline.notarius.references", wantReason: "middle component"},
|
|
{name: "empty source", references: map[string]string{"party": " "}, wantField: "pipeline.notarius.references.party", wantReason: "source is required"},
|
|
{name: "unsupported source", references: map[string]string{"party": "narratio.input.unknown"}, wantField: "pipeline.notarius.references.party", wantReason: "not a supported prepared input source"},
|
|
{
|
|
name: "normalized collision",
|
|
references: map[string]string{
|
|
"chunk.party": "narratio.input.party",
|
|
" chunk . party ": "narratio.input.players",
|
|
},
|
|
wantField: "pipeline.notarius.references",
|
|
wantReason: "must be normalized",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
cfg.References = tt.references
|
|
err := validateNotarius(cfg, nil)
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantField) || !strings.Contains(err.Error(), tt.wantReason) {
|
|
t.Fatalf("validateNotarius() error = %v, want field %q and reason %q", err, tt.wantField, tt.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotariusReferenceLimit(t *testing.T) {
|
|
for _, count := range []int{MaxNotariusReferenceBindings, MaxNotariusReferenceBindings + 1} {
|
|
t.Run(fmt.Sprintf("count_%d", count), func(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
cfg.References = make(map[string]string, count)
|
|
for i := 0; i < count; i++ {
|
|
cfg.References[fmt.Sprintf("lane-%03d.party", i)] = "narratio.input.party"
|
|
}
|
|
err := validateNotarius(cfg, nil)
|
|
if count == MaxNotariusReferenceBindings {
|
|
if err != nil {
|
|
t.Fatalf("validateNotarius() at limit error = %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), "at most 256 bindings") {
|
|
t.Fatalf("validateNotarius() above limit error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotariusNilAndEmptyReferencesAreValid(t *testing.T) {
|
|
for _, references := range []map[string]string{nil, {}} {
|
|
cfg := validNotariusConfig()
|
|
cfg.References = references
|
|
if err := validateNotarius(cfg, nil); err != nil {
|
|
t.Fatalf("validateNotarius(%#v) error = %v", references, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNotariusValidationFailureDoesNotMutateConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*NotariusConfig)
|
|
want string
|
|
}{
|
|
{
|
|
name: "reference",
|
|
mutate: func(cfg *NotariusConfig) {
|
|
cfg.References = map[string]string{" party ": "unsupported"}
|
|
},
|
|
want: "pipeline.notarius.references selector",
|
|
},
|
|
{
|
|
name: "output",
|
|
mutate: func(cfg *NotariusConfig) {
|
|
cfg.References = map[string]string{"party": "narratio.input.party"}
|
|
cfg.Outputs["npc_registry"] = NotariusOutputConfig{LaneID: " missing-schema "}
|
|
},
|
|
want: "pipeline.notarius.outputs.npc_registry.lane_id",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
test.mutate(cfg)
|
|
before := cloneNotariusConfigForTest(cfg)
|
|
|
|
err := validateNotarius(cfg, nil)
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("validateNotarius() error = %v, want field containing %q", err, test.want)
|
|
}
|
|
if !reflect.DeepEqual(cfg, before) {
|
|
t.Fatalf("config mutated after validation failure:\n got: %#v\nwant: %#v", cfg, before)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotariusNormalizationProducesCanonicalValues(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
cfg.References = map[string]string{
|
|
" party ": " narratio.input.party ",
|
|
"chunk . players": " narratio.input.players ",
|
|
}
|
|
cfg.Outputs = map[string]NotariusOutputConfig{
|
|
" npc_registry ": {
|
|
LaneID: " npc-registry ", MediaType: " application/json ",
|
|
SchemaID: " notarius.dnd.npc_registry ", SchemaVersion: " v1 ",
|
|
},
|
|
"spells": {
|
|
LaneID: " spells ", MediaType: " application/json ",
|
|
SchemaID: " notarius.dnd.spells ", SchemaVersion: " v1 ",
|
|
},
|
|
}
|
|
|
|
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
|
|
t.Fatalf("NormalizePipelineConfig() error = %v", err)
|
|
}
|
|
if err := validateNotarius(cfg, nil); err != nil {
|
|
t.Fatalf("validateNotarius() after normalization error = %v", err)
|
|
}
|
|
wantReferences := map[string]string{
|
|
"party": "narratio.input.party",
|
|
"chunk.players": "narratio.input.players",
|
|
}
|
|
if !reflect.DeepEqual(cfg.References, wantReferences) {
|
|
t.Fatalf("references = %#v, want %#v", cfg.References, wantReferences)
|
|
}
|
|
if _, ok := cfg.Outputs[" npc_registry "]; ok {
|
|
t.Fatal("outputs retain unnormalized key")
|
|
}
|
|
if got := cfg.Outputs["npc_registry"]; got.LaneID != "npc-registry" || got.MediaType != "application/json" || got.SchemaID != "notarius.dnd.npc_registry" || got.SchemaVersion != "v1" {
|
|
t.Fatalf("normalized npc_registry output = %#v", got)
|
|
}
|
|
beforeSecondPass := cloneNotariusConfigForTest(cfg)
|
|
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
|
|
t.Fatalf("second NormalizePipelineConfig() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(cfg, beforeSecondPass) {
|
|
t.Fatalf("normalization is not deterministic:\n got: %#v\nwant: %#v", cfg, beforeSecondPass)
|
|
}
|
|
}
|
|
|
|
func TestNotariusValidationDoesNotMutateAcceptedConfig(t *testing.T) {
|
|
cfg := validNotariusConfig()
|
|
before := cloneNotariusConfigForTest(cfg)
|
|
if err := validateNotarius(cfg, nil); err != nil {
|
|
t.Fatalf("validateNotarius() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(cfg, before) {
|
|
t.Fatalf("accepted config mutated during validation:\n got: %#v\nwant: %#v", cfg, before)
|
|
}
|
|
}
|
|
|
|
func TestNotariusDiagnosticsUseSortedMapKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
configure func(*NotariusConfig, []string)
|
|
wantField string
|
|
}{
|
|
{
|
|
name: "references",
|
|
configure: func(cfg *NotariusConfig, keys []string) {
|
|
cfg.References = make(map[string]string, len(keys))
|
|
for _, key := range keys {
|
|
cfg.References[key] = "narratio.input.unsupported"
|
|
}
|
|
},
|
|
wantField: "pipeline.notarius.references.alpha",
|
|
},
|
|
{
|
|
name: "outputs",
|
|
configure: func(cfg *NotariusConfig, keys []string) {
|
|
cfg.References = nil
|
|
cfg.Outputs = make(map[string]NotariusOutputConfig, len(keys))
|
|
for _, key := range keys {
|
|
cfg.Outputs[key] = NotariusOutputConfig{LaneID: key}
|
|
}
|
|
},
|
|
wantField: "pipeline.notarius.outputs.alpha.media_type",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
orders := [][]string{{"zeta", "alpha"}, {"alpha", "zeta"}}
|
|
diagnostics := make([]string, 0, len(orders))
|
|
for _, keys := range orders {
|
|
cfg := validNotariusConfig()
|
|
test.configure(cfg, keys)
|
|
err := validateNotarius(cfg, nil)
|
|
if err == nil || !strings.Contains(err.Error(), test.wantField) {
|
|
t.Fatalf("validateNotarius() error = %v, want field %q", err, test.wantField)
|
|
}
|
|
diagnostics = append(diagnostics, err.Error())
|
|
}
|
|
if diagnostics[0] != diagnostics[1] {
|
|
t.Fatalf("equivalent maps produced different diagnostics:\nfirst: %s\nsecond: %s", diagnostics[0], diagnostics[1])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func cloneNotariusConfigForTest(cfg *NotariusConfig) *NotariusConfig {
|
|
clone := *cfg
|
|
if cfg.References != nil {
|
|
clone.References = make(map[string]string, len(cfg.References))
|
|
for key, value := range cfg.References {
|
|
clone.References[key] = value
|
|
}
|
|
}
|
|
if cfg.Outputs != nil {
|
|
clone.Outputs = make(map[string]NotariusOutputConfig, len(cfg.Outputs))
|
|
for key, value := range cfg.Outputs {
|
|
clone.Outputs[key] = value
|
|
}
|
|
}
|
|
return &clone
|
|
}
|
|
|
|
func TestNotariusSpellCatalogReferenceRequiresEffectiveInput(t *testing.T) {
|
|
cfg := loadedValidConfig(t)
|
|
cfg.Pipeline.Notarius = validNotariusConfig()
|
|
cfg.Pipeline.Notarius.References = map[string]string{
|
|
"zeta": "narratio.input.spell_catalog",
|
|
"alpha": "narratio.input.spell_catalog",
|
|
}
|
|
|
|
err := Validate(cfg)
|
|
if err == nil || !strings.Contains(err.Error(), "pipeline.notarius.references.alpha") || !strings.Contains(err.Error(), "requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file") {
|
|
t.Fatalf("Validate() error = %v, want sorted selector and missing spell catalog input", err)
|
|
}
|
|
|
|
cfg.StableInputs.SpellCatalogFile = ResolvedInputFile{Path: "./spells.json", Source: "campaign_config"}
|
|
if err := Validate(cfg); err != nil {
|
|
t.Fatalf("Validate() with spell catalog error = %v", err)
|
|
}
|
|
}
|
|
|
|
func validNotariusConfig() *NotariusConfig {
|
|
return &NotariusConfig{
|
|
Enabled: true,
|
|
Binary: "notarius",
|
|
ConfigPath: "notarius.yml",
|
|
PipelineID: "dnd-session",
|
|
Timeout: "45m",
|
|
WorkingDirectory: ".",
|
|
Outputs: map[string]NotariusOutputConfig{
|
|
"npc_registry": {
|
|
LaneID: "npc-registry",
|
|
MediaType: "application/json",
|
|
SchemaID: "notarius.dnd.npc_registry",
|
|
SchemaVersion: "v1",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestNotariusEnabledValidation(t *testing.T) {
|
|
valid := `notarius:
|
|
enabled: true
|
|
config_path: ./notarius.yml
|
|
pipeline_id: dnd-session
|
|
timeout: 45m
|
|
outputs:
|
|
npc_registry:
|
|
lane_id: npc-registry
|
|
media_type: application/json
|
|
schema_id: notarius.dnd.npc_registry
|
|
schema_version: v1
|
|
module_key: dnd/npc-registry
|
|
`
|
|
tests := []struct {
|
|
name string
|
|
section string
|
|
wantErr []string
|
|
}{
|
|
{name: "valid", section: valid},
|
|
{name: "blank binary", section: strings.Replace(valid, " enabled: true\n", " enabled: true\n binary: \" \"\n", 1), wantErr: []string{"pipeline.notarius.binary", "required"}},
|
|
{name: "missing config path", section: strings.Replace(valid, " config_path: ./notarius.yml\n", "", 1), wantErr: []string{"pipeline.notarius.config_path", "required"}},
|
|
{name: "missing pipeline id", section: strings.Replace(valid, " pipeline_id: dnd-session\n", "", 1), wantErr: []string{"pipeline.notarius.pipeline_id", "required"}},
|
|
{name: "missing outputs", section: strings.Split(valid, " outputs:\n")[0], wantErr: []string{"pipeline.notarius.outputs", "at least one"}},
|
|
{name: "zero timeout", section: strings.Replace(valid, " timeout: 45m", " timeout: 0s", 1), wantErr: []string{"pipeline.notarius.timeout", "positive"}},
|
|
{name: "negative timeout", section: strings.Replace(valid, " timeout: 45m", " timeout: -1s", 1), wantErr: []string{"pipeline.notarius.timeout", "positive"}},
|
|
{name: "invalid timeout", section: strings.Replace(valid, " timeout: 45m", " timeout: later", 1), wantErr: []string{"pipeline.notarius.timeout", "valid duration"}},
|
|
{name: "invalid output key", section: strings.Replace(valid, " npc_registry:", " npc-registry:", 1), wantErr: []string{"pipeline.notarius.outputs keys", "must match"}},
|
|
{name: "missing lane", section: strings.Replace(valid, " lane_id: npc-registry\n", "", 1), wantErr: []string{"pipeline.notarius.outputs.npc_registry.lane_id", "required"}},
|
|
{name: "missing media type", section: strings.Replace(valid, " media_type: application/json\n", "", 1), wantErr: []string{"pipeline.notarius.outputs.npc_registry.media_type", "required"}},
|
|
{name: "missing schema id", section: strings.Replace(valid, " schema_id: notarius.dnd.npc_registry\n", "", 1), wantErr: []string{"pipeline.notarius.outputs.npc_registry.schema_id", "required"}},
|
|
{name: "missing schema version", section: strings.Replace(valid, " schema_version: v1\n", "", 1), wantErr: []string{"pipeline.notarius.outputs.npc_registry.schema_version", "required"}},
|
|
{
|
|
name: "normalized key collision",
|
|
section: strings.Replace(valid, " module_key: dnd/npc-registry\n", ` module_key: dnd/npc-registry
|
|
" npc_registry ":
|
|
lane_id: npc-registry-two
|
|
media_type: application/json
|
|
schema_id: two
|
|
schema_version: v1
|
|
`, 1),
|
|
wantErr: []string{"pipeline.notarius.outputs keys", "duplicate source"},
|
|
},
|
|
{
|
|
name: "duplicate normalized lane",
|
|
section: strings.Replace(valid, " module_key: dnd/npc-registry\n", ` module_key: dnd/npc-registry
|
|
spells:
|
|
lane_id: " npc-registry "
|
|
media_type: application/json
|
|
schema_id: two
|
|
schema_version: v1
|
|
`, 1),
|
|
wantErr: []string{"pipeline.notarius.outputs.spells.lane_id", "duplicates"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML+"\n"+tt.section, testSessionBaseYAML)
|
|
cfg, err := Load(pipelinePath, sessionPath)
|
|
if err != nil {
|
|
if len(tt.wantErr) > 0 {
|
|
for _, fragment := range tt.wantErr {
|
|
if !strings.Contains(err.Error(), fragment) {
|
|
t.Fatalf("Load() error = %v, want containing %q", err, fragment)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
err = Validate(cfg)
|
|
if len(tt.wantErr) == 0 {
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
output := cfg.Pipeline.Notarius.Outputs["npc_registry"]
|
|
if output.LaneID != "npc-registry" || output.ModuleKey != "dnd/npc-registry" {
|
|
t.Fatalf("normalized output = %#v", output)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("Validate() error = nil, want containing %q", tt.wantErr)
|
|
}
|
|
for _, fragment := range tt.wantErr {
|
|
if !strings.Contains(err.Error(), fragment) {
|
|
t.Fatalf("Validate() error = %v, want containing %q", err, fragment)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractionReferencesRequireDeclaredOutput(t *testing.T) {
|
|
declared := `notarius:
|
|
enabled: false
|
|
outputs:
|
|
npc_registry: {}
|
|
`
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantErr []string
|
|
}{
|
|
{
|
|
name: "scriptorium declared extraction",
|
|
body: declared + `scriptorium:
|
|
artifacts:
|
|
recap:
|
|
inputs:
|
|
npcs:
|
|
source: narratio.extraction.npc_registry
|
|
`,
|
|
},
|
|
{
|
|
name: "scriptorium unknown extraction",
|
|
body: declared + `scriptorium:
|
|
artifacts:
|
|
recap:
|
|
inputs:
|
|
npcs:
|
|
source: narratio.extraction.unknown
|
|
`,
|
|
wantErr: []string{"pipeline.scriptorium.artifacts.recap.inputs.npcs.source", "unknown extraction output"},
|
|
},
|
|
{
|
|
name: "publish declared extraction",
|
|
body: declared + `publish:
|
|
outputs:
|
|
- source: narratio.extraction.npc_registry
|
|
dest: artifacts/npc-registry.json
|
|
`,
|
|
},
|
|
{
|
|
name: "publish unknown extraction",
|
|
body: declared + `publish:
|
|
outputs:
|
|
- source: narratio.extraction.unknown
|
|
dest: artifacts/unknown.json
|
|
`,
|
|
wantErr: []string{"pipeline.publish.outputs[0].source", "not defined"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML+"\n"+tt.body, testSessionBaseYAML)
|
|
cfg, err := Load(pipelinePath, sessionPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
err = Validate(cfg)
|
|
if len(tt.wantErr) == 0 {
|
|
if err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("Validate() error = nil, want containing %q", tt.wantErr)
|
|
}
|
|
for _, fragment := range tt.wantErr {
|
|
if !strings.Contains(err.Error(), fragment) {
|
|
t.Fatalf("Validate() error = %v, want containing %q", err, fragment)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|