package config import ( "fmt" "os" "path/filepath" "reflect" "strings" "testing" ) 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.ConfigPath != "relative/notarius.yml" || disabled.Notarius.WorkingDirectory != "" { t.Fatalf("disabled paths were resolved unexpectedly: %#v", disabled.Notarius) } } 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")) } } func TestNotariusRelativePathsDoNotMakeEffectiveDigestLocationDependent(t *testing.T) { pipelineYAML := testPipelineBaseYAML + ` notarius: enabled: true config_path: notarius/config.yml pipeline_id: dnd-session ` 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 = "duplicate YAML 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 := validateNotarius(cfg, nil); err != nil { t.Fatalf("validateNotarius() 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: "normalize to", }, } 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.Binary = " notarius " cfg.References = map[string]string{" party ": "unsupported"} }, want: "pipeline.notarius.references.party", }, { name: "output", mutate: func(cfg *NotariusConfig) { cfg.Binary = " notarius " cfg.References = map[string]string{" party ": " narratio.input.party "} cfg.Outputs["npc_registry"] = NotariusOutputConfig{LaneID: " missing-schema "} }, want: "pipeline.notarius.outputs.npc_registry.media_type", }, } 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 := validateNotarius(cfg, nil); err != nil { t.Fatalf("validateNotarius() 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) } } 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 { 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) } } }) } }