2026-08-09 23:30:32 +00:00
package config
import (
2026-09-16 17:42:16 +00:00
"encoding/json"
2026-08-29 14:55:24 +00:00
"fmt"
2026-08-09 23:30:32 +00:00
"os"
"path/filepath"
2026-09-05 00:26:54 +00:00
"reflect"
2026-08-09 23:30:32 +00:00
"strings"
"testing"
2026-09-16 17:42:16 +00:00
"gopkg.in/yaml.v3"
2026-08-09 23:30:32 +00:00
)
func TestNotariusOmittedAndDisabledBehavior ( t * testing . T ) {
dir := t . TempDir ( )
omittedPath := filepath . Join ( dir , "omitted.yml" )
if err := os . WriteFile ( omittedPath , [ ] byte ( testPipelineBaseYAML ) , 0 o644 ) ; 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 ) , 0 o644 ) ; 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 )
}
2026-09-16 17:42:16 +00:00
if disabled . Notarius . Resume || disabled . Notarius . ChunkCache != "" {
t . Fatalf ( "disabled cache-control defaults = %#v" , disabled . Notarius )
}
2026-08-09 23:30:32 +00:00
if disabled . Notarius . ConfigPath != "relative/notarius.yml" || disabled . Notarius . WorkingDirectory != "" {
t . Fatalf ( "disabled paths were resolved unexpectedly: %#v" , disabled . Notarius )
}
}
2026-09-16 14:22:45 +00:00
func TestNotariusCredentialReferenceLoading ( t * testing . T ) {
const envName = "NARRATIO_TEST_NOTARIUS_KEY"
2026-09-25 15:33:46 +00:00
const secondName = "NARRATIO_TEST_NOTARIUS_SECOND_KEY"
2026-09-16 14:22:45 +00:00
const canary = "notarius-credential-canary"
t . Setenv ( envName , canary )
2026-09-25 15:33:46 +00:00
t . Setenv ( secondName , "second-canary" )
2026-09-16 14:22:45 +00:00
tests := [ ] struct {
name string
credential string
wantNil bool
} {
{ name : "omitted" , wantNil : true } ,
2026-09-25 15:33:46 +00:00
{ name : "null" , credential : " credentials: null\n" , wantNil : true } ,
{ name : "empty" , credential : " credentials: []\n" } ,
{ name : "custom references" , credential : " credentials: [" + envName + ", " + secondName + "]\n" } ,
2026-09-16 14:22:45 +00:00
}
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 ) , 0 o644 ) ; err != nil {
t . Fatal ( err )
}
cfg , err := LoadPipeline ( path )
if err != nil {
t . Fatal ( err )
}
2026-09-25 15:33:46 +00:00
if test . wantNil && cfg . Notarius . Credentials != nil {
t . Fatal ( "credential list should be nil" )
}
if test . name == "empty" && len ( cfg . Notarius . Credentials ) == 0 {
return
}
2026-09-16 14:22:45 +00:00
if test . wantNil {
return
}
2026-09-25 15:33:46 +00:00
if len ( cfg . Notarius . Credentials ) != 2 ||
cfg . Notarius . Credentials [ 0 ] . EnvName ( ) != envName || cfg . Notarius . Credentials [ 0 ] . Value ( ) != canary ||
cfg . Notarius . Credentials [ 1 ] . EnvName ( ) != secondName || cfg . Notarius . Credentials [ 1 ] . Value ( ) != "second-canary" {
t . Fatal ( "credential references were not loaded and resolved" )
2026-09-16 14:22:45 +00:00
}
} )
}
}
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" )
2026-09-25 15:33:46 +00:00
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n credentials: [" + test . reference + "]\n"
2026-09-16 14:22:45 +00:00
if err := os . WriteFile ( path , [ ] byte ( pipelineYAML ) , 0 o644 ) ; 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 )
}
} )
}
}
2026-09-25 15:33:46 +00:00
func TestNotariusRejectsRetiredCredentialField ( t * testing . T ) {
path := filepath . Join ( t . TempDir ( ) , "pipeline.yml" )
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n llm_api_key: NARRATIO_TEST_NOTARIUS_KEY\n"
if err := os . WriteFile ( path , [ ] byte ( pipelineYAML ) , 0 o644 ) ; err != nil {
t . Fatal ( err )
}
if _ , err := LoadPipeline ( path ) ; err == nil || ! strings . Contains ( err . Error ( ) , "decode.unknown_field" ) {
t . Fatalf ( "LoadPipeline() error = %v, want unknown field" , err )
}
}
2026-08-09 23:30:32 +00:00
func TestNotariusEnabledDefaultsAndPathResolution ( t * testing . T ) {
dir := t . TempDir ( )
pipelinePath := filepath . Join ( dir , "deployment" , "pipeline.yml" )
if err := os . MkdirAll ( filepath . Dir ( pipelinePath ) , 0 o755 ) ; 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 ) , 0 o644 ) ; 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 ) , 0 o644 ) ; 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" ) )
}
2026-09-07 11:54:35 +00:00
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 ) , 0 o644 ) ; 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 )
}
} )
}
2026-08-09 23:30:32 +00:00
}
2026-09-16 17:42:16 +00:00
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 )
}
}
}
2026-08-30 18:51:20 +00:00
func TestNotariusRelativePathsDoNotMakeEffectiveDigestLocationDependent ( t * testing . T ) {
pipelineYAML := testPipelineBaseYAML + `
notarius :
enabled : true
config_path : notarius / config . yml
pipeline_id : dnd - session
2026-09-07 03:54:41 +00:00
outputs :
npc_registry :
lane_id : npc - registry
media_type : application / json
schema_id : notarius . dnd . npc_registry
schema_version : v1
2026-08-30 18:51:20 +00:00
`
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 ) , 0 o644 ) ; 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 )
}
}
2026-08-09 23:30:32 +00:00
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" } ,
2026-08-29 14:55:24 +00:00
{ name : "duplicate reference selector" , yaml : "notarius:\n references:\n party: narratio.input.party\n party: narratio.input.players\n" } ,
2026-08-09 23:30:32 +00:00
}
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 ) , 0 o644 ) ; err != nil {
t . Fatalf ( "write pipeline: %v" , err )
}
2026-08-30 12:33:43 +00:00
_ , err := LoadPipeline ( path )
want := "strict decode failed"
if tt . name == "duplicate reference selector" {
2026-09-07 03:54:41 +00:00
want = "yaml.duplicate_key"
2026-08-30 12:33:43 +00:00
}
if err == nil || ! strings . Contains ( err . Error ( ) , want ) {
t . Fatalf ( "LoadPipeline() error = %v, want containing %q" , err , want )
2026-08-09 23:30:32 +00:00
}
} )
}
}
2026-08-29 14:55:24 +00:00
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" ,
}
2026-09-07 03:32:50 +00:00
if err := NormalizePipelineConfig ( & PipelineConfig { Notarius : cfg } ) ; err != nil {
t . Fatalf ( "NormalizePipelineConfig() error = %v" , err )
}
2026-08-29 14:55:24 +00:00
if err := validateNotarius ( cfg , nil ) ; err != nil {
2026-09-07 03:32:50 +00:00
t . Fatalf ( "validateNotarius() after normalization error = %v" , err )
2026-08-29 14:55:24 +00:00
}
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
2026-09-05 00:42:47 +00:00
wantField string
wantReason string
2026-08-29 14:55:24 +00:00
} {
2026-09-05 00:42:47 +00:00
{ 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" } ,
2026-08-29 14:55:24 +00:00
{
name : "normalized collision" ,
references : map [ string ] string {
"chunk.party" : "narratio.input.party" ,
" chunk . party " : "narratio.input.players" ,
} ,
2026-09-05 00:42:47 +00:00
wantField : "pipeline.notarius.references" ,
2026-09-07 03:32:50 +00:00
wantReason : "must be normalized" ,
2026-08-29 14:55:24 +00:00
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
cfg := validNotariusConfig ( )
cfg . References = tt . references
err := validateNotarius ( cfg , nil )
2026-09-05 00:42:47 +00:00
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 )
2026-08-29 14:55:24 +00:00
}
} )
}
}
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 )
}
}
}
2026-09-05 00:26:54 +00:00
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" }
} ,
2026-09-07 03:32:50 +00:00
want : "pipeline.notarius.references selector" ,
2026-09-05 00:26:54 +00:00
} ,
{
name : "output" ,
mutate : func ( cfg * NotariusConfig ) {
2026-09-07 03:32:50 +00:00
cfg . References = map [ string ] string { "party" : "narratio.input.party" }
2026-09-05 00:26:54 +00:00
cfg . Outputs [ "npc_registry" ] = NotariusOutputConfig { LaneID : " missing-schema " }
} ,
2026-09-07 03:32:50 +00:00
want : "pipeline.notarius.outputs.npc_registry.lane_id" ,
2026-09-05 00:26:54 +00:00
} ,
}
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 )
}
} )
}
}
2026-09-05 03:38:07 +00:00
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 " ,
} ,
2026-09-05 00:26:54 +00:00
}
2026-09-07 03:32:50 +00:00
if err := NormalizePipelineConfig ( & PipelineConfig { Notarius : cfg } ) ; err != nil {
t . Fatalf ( "NormalizePipelineConfig() error = %v" , err )
}
2026-09-05 03:38:07 +00:00
if err := validateNotarius ( cfg , nil ) ; err != nil {
2026-09-07 03:32:50 +00:00
t . Fatalf ( "validateNotarius() after normalization error = %v" , err )
2026-09-05 00:26:54 +00:00
}
2026-09-05 03:38:07 +00:00
wantReferences := map [ string ] string {
"party" : "narratio.input.party" ,
"chunk.players" : "narratio.input.players" ,
2026-09-05 00:26:54 +00:00
}
2026-09-05 03:38:07 +00:00
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 )
}
2026-09-07 03:32:50 +00:00
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 )
}
2026-09-05 03:38:07 +00:00
}
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 ] )
}
} )
2026-09-05 00:26:54 +00:00
}
}
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
}
2026-08-29 14:55:24 +00:00
func TestNotariusSpellCatalogReferenceRequiresEffectiveInput ( t * testing . T ) {
cfg := loadedValidConfig ( t )
cfg . Pipeline . Notarius = validNotariusConfig ( )
2026-09-05 03:37:00 +00:00
cfg . Pipeline . Notarius . References = map [ string ] string {
"zeta" : "narratio.input.spell_catalog" ,
"alpha" : "narratio.input.spell_catalog" ,
}
2026-08-29 14:55:24 +00:00
err := Validate ( cfg )
2026-09-05 03:37:00 +00:00
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 )
2026-08-29 14:55:24 +00:00
}
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" ,
2026-09-07 03:32:50 +00:00
ConfigPath : "notarius.yml" ,
2026-08-29 14:55:24 +00:00
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" ,
} ,
} ,
}
}
2026-08-09 23:30:32 +00:00
func TestNotariusEnabledValidation ( t * testing . T ) {
valid := ` notarius :
enabled : true
config_path : . / notarius . yml
pipeline_id : dnd - session
timeout : 45 m
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
2026-09-05 00:42:47 +00:00
wantErr [ ] string
2026-08-09 23:30:32 +00:00
} {
{ name : "valid" , section : valid } ,
2026-09-05 00:42:47 +00:00
{ 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" } } ,
2026-08-09 23:30:32 +00:00
{
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 ) ,
2026-09-05 00:42:47 +00:00
wantErr : [ ] string { "pipeline.notarius.outputs keys" , "duplicate source" } ,
2026-08-09 23:30:32 +00:00
} ,
{
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 ) ,
2026-09-05 00:42:47 +00:00
wantErr : [ ] string { "pipeline.notarius.outputs.spells.lane_id" , "duplicates" } ,
2026-08-09 23:30:32 +00:00
} ,
}
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 {
2026-09-07 03:32:50 +00:00
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
}
2026-08-09 23:30:32 +00:00
t . Fatalf ( "Load() error = %v" , err )
}
err = Validate ( cfg )
2026-09-05 00:42:47 +00:00
if len ( tt . wantErr ) == 0 {
2026-08-09 23:30:32 +00:00
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
}
2026-09-05 00:42:47 +00:00
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 )
}
2026-08-09 23:30:32 +00:00
}
} )
}
}
func TestExtractionReferencesRequireDeclaredOutput ( t * testing . T ) {
declared := ` notarius :
enabled : false
outputs :
npc_registry : { }
`
tests := [ ] struct {
name string
body string
2026-09-05 00:42:47 +00:00
wantErr [ ] string
2026-08-09 23:30:32 +00:00
} {
{
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
` ,
2026-09-05 00:42:47 +00:00
wantErr : [ ] string { "pipeline.scriptorium.artifacts.recap.inputs.npcs.source" , "unknown extraction output" } ,
2026-08-09 23:30:32 +00:00
} ,
{
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
` ,
2026-09-05 00:42:47 +00:00
wantErr : [ ] string { "pipeline.publish.outputs[0].source" , "not defined" } ,
2026-08-09 23:30:32 +00:00
} ,
}
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 )
2026-09-05 00:42:47 +00:00
if len ( tt . wantErr ) == 0 {
2026-08-09 23:30:32 +00:00
if err != nil {
t . Fatalf ( "Validate() error = %v" , err )
}
return
}
2026-09-05 00:42:47 +00:00
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 )
}
2026-08-09 23:30:32 +00:00
}
} )
}
}