package pipeline import ( "errors" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) func TestResolvePipelineValidatesModuleAndValidatorOptions(t *testing.T) { tests := []struct { name string mutate func(*PipelineProfile) want []string }{ { name: "module", mutate: func(profile *PipelineProfile) { profile.Input.Options = map[string]any{"surprise": true} }, want: []string{`pipeline "construction" input module "input" options`, `unknown option "surprise"`}, }, { name: "validator", mutate: func(profile *PipelineProfile) { profile.Chunk.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured", Options: map[string]any{"surprise": true}}}} }, want: []string{`pipeline "construction" chunk module "chunk" validator "configured" options`, `unknown option "surprise"`}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { registries, _ := constructionRegistries(t, nil, nil) profile := constructionProfile() test.mutate(&profile) _, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog()) if err == nil { t.Fatal("ResolvePipeline() error = nil, want option validation error") } for _, want := range test.want { if !strings.Contains(err.Error(), want) { t.Fatalf("ResolvePipeline() error = %q, want substring %q", err, want) } } }) } } func TestPrepareConstructsEverythingInStableOrder(t *testing.T) { var built []string registries, _ := constructionRegistries(t, &built, nil) resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog()) if err != nil { t.Fatalf("ResolvePipeline() error = %v, want nil", err) } prepared, err := Prepare(resolved, registries, ModuleDependencies{}) if err != nil { t.Fatalf("Prepare() error = %v, want nil", err) } want := []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"} if !reflect.DeepEqual(built, want) { t.Fatalf("construction order = %#v, want %#v", built, want) } if prepared.Input.Module != "input" || prepared.Chunk.Module != "chunk" || prepared.Output.Module != "output" || len(prepared.ArtifactLanes) != 1 { t.Fatalf("PreparedPipeline = %#v, want explicit resolved components", prepared) } } func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) { tests := []struct { name string deps ModuleDependencies configure func(*constructionFailure) want string wantBuilt []string }{ { name: "missing required llm dependency", configure: func(failure *constructionFailure) { failure.requireExtractorLLM = true }, want: `lane "artifact" extract module "extract"`, wantBuilt: []string{"input", "chunk", "validator", "extract"}, }, { name: "late output construction", configure: func(failure *constructionFailure) { failure.output = errors.New("output unavailable") }, want: `output module "output"`, wantBuilt: []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { failure := &constructionFailure{} test.configure(failure) var built []string registries, input := constructionRegistries(t, &built, failure) resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog()) if err != nil { t.Fatalf("ResolvePipeline() error = %v, want nil", err) } _, err = Prepare(resolved, registries, test.deps) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Prepare() error = %v, want substring %q", err, test.want) } if len(input.requests) != 0 { t.Fatalf("input Parse calls = %d, want zero", len(input.requests)) } if !reflect.DeepEqual(built, test.wantBuilt) { t.Fatalf("construction order = %#v, want %#v", built, test.wantBuilt) } }) } } type constructionFailure struct { requireExtractorLLM bool output error } func constructionProfile() PipelineProfile { validators := ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured"}}} return PipelineProfile{ ID: "construction", Input: Binding("input"), Chunk: ModuleBinding{Module: "chunk", Validators: validators}, Artifacts: map[string]ArtifactLaneProfile{ "artifact": { Extract: ModuleBinding{Module: "extract", Validators: validators}, Merge: ModuleBinding{Module: "merge", Validators: validators}, Normalize: ModuleBinding{Module: "normalize", Validators: validators}, }, }, Output: Binding("output"), } } func constructionRegistries(t *testing.T, built *[]string, failure *constructionFailure) (Registries, *runnerInputAdapter) { t.Helper() if built == nil { built = &[]string{} } if failure == nil { failure = &constructionFailure{} } record := func(name string) { *built = append(*built, name) } strict := func(options map[string]any) error { return RejectUnknownOptions(options, "known") } modules := defaultRunnerModules() registries := Registries{ Inputs: NewInputAdapterRegistry(), Chunkers: NewChunkerRegistry(), ArtifactCodecs: NewArtifactCodecRegistry(), Extractors: NewExtractorRegistry(), Mergers: NewMergerRegistry(), Normalizers: NewNormalizerRegistry(), Validators: NewValidatorRegistry(), ValidatorChains: NewValidatorChainRegistry(), Outputs: NewOutputEncoderRegistry(), } if err := registries.Inputs.RegisterBuilderWithSpec(defaultModuleSpec("input", StageInput), strict, func(BuildRequest) (contracts.InputAdapter, error) { record("input") return modules.input, nil }); err != nil { t.Fatal(err) } if err := registries.Chunkers.RegisterBuilderWithSpec(defaultModuleSpec("chunk", StageChunk), strict, func(BuildRequest) (contracts.Chunker, error) { record("chunk") return modules.chunker, nil }); err != nil { t.Fatal(err) } if err := registries.Extractors.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("extract", StageExtract), strict, func(request BuildRequest) (contracts.LegacyRawExtractor, error) { record("extract") if failure.requireExtractorLLM && request.Dependencies.LLM == nil { return nil, errors.New("structured LLM client is required") } return &runnerExtractor{key: "extract"}, nil }); err != nil { t.Fatal(err) } if err := registries.Mergers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("merge", StageMerge), strict, func(BuildRequest) (contracts.LegacyRawMerger, error) { record("merge") return modules.mergers["merge"], nil }); err != nil { t.Fatal(err) } if err := registries.Normalizers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("normalize", StageNormalize), strict, func(BuildRequest) (contracts.LegacyRawNormalizer, error) { record("normalize") return modules.normalizers["normalize"], nil }); err != nil { t.Fatal(err) } if err := registries.Validators.RegisterLegacyRawBuilderWithSpec(ValidatorSpec{Key: "configured", ExecutionClass: contracts.ExecutionClassDeterministic}, strict, func(BuildRequest) (contracts.LegacyRawValidator, error) { record("validator") return modules.validators["configured"], nil }); err != nil { t.Fatal(err) } if err := registries.Outputs.RegisterBuilderWithSpec(defaultModuleSpec("output", StageOutput), strict, func(BuildRequest) (contracts.OutputEncoder, error) { record("output") if failure.output != nil { return nil, failure.output } return modules.output, nil }); err != nil { t.Fatal(err) } return registries, modules.input }