package json import ( "context" stdjson "encoding/json" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) func TestModuleSpecAndRegister(t *testing.T) { want := pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"normalized"}, Provides: []string{"encoded"}, } if got := ModuleSpec(); !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) } registry := pipeline.NewOutputEncoderRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } spec, ok := registry.Spec(Key) if !ok { t.Fatalf("Spec(%q) ok = false, want true", Key) } if !reflect.DeepEqual(spec, want) { t.Fatalf("registered spec = %#v, want %#v", spec, want) } if err := registry.ValidateOptions(Key, map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { t.Fatalf("ValidateOptions() error = %v, want unknown option error", err) } if err := registry.ValidateOptions(Key, map[string]any{"include_chunk_map": true}); err != nil { t.Fatalf("ValidateOptions() error = %v, want nil", err) } } func TestDecodeOptions(t *testing.T) { for _, test := range []struct { name string options map[string]any want Options wantErr string }{ {name: "omitted", want: Options{}}, {name: "disabled", options: map[string]any{"include_chunk_map": false}, want: Options{}}, {name: "enabled", options: map[string]any{"include_chunk_map": true}, want: Options{IncludeChunkMap: true}}, {name: "wrong type", options: map[string]any{"include_chunk_map": "true"}, wantErr: "must be a boolean"}, {name: "unknown", options: map[string]any{"unknown": true}, wantErr: "unknown option"}, } { t.Run(test.name, func(t *testing.T) { got, err := DecodeOptions(test.options) if test.wantErr != "" { if err == nil || !strings.Contains(err.Error(), test.wantErr) { t.Fatalf("DecodeOptions() error = %v, want %q", err, test.wantErr) } return } if err != nil { t.Fatalf("DecodeOptions() error = %v, want nil", err) } if !reflect.DeepEqual(got, test.want) { t.Fatalf("DecodeOptions() = %#v, want %#v", got, test.want) } }) } } func TestDecodeEvidenceContextOptions(t *testing.T) { for _, test := range []struct { name string options map[string]any want pipeline.EvidenceContextPolicy wantErr string }{ {name: "disabled", options: map[string]any{"evidence_context": map[string]any{"enabled": false}}}, {name: "enabled default window", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}}}, want: pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: 3, LaneIDs: []string{"npcs"}}}, {name: "explicit zero window and normalized lanes", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{" spells ", "npcs"}, "window_units": 0}}, want: pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: 0, LaneIDs: []string{"npcs", "spells"}}}, {name: "duplicate lanes", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs", " npcs "}}}, wantErr: "duplicated"}, {name: "unknown nested option", options: map[string]any{"evidence_context": map[string]any{"enabled": false, "extra": true}}, wantErr: "unknown option"}, {name: "disabled nested fields", options: map[string]any{"evidence_context": map[string]any{"enabled": false, "lanes": []any{"npcs"}}}, wantErr: "not allowed"}, {name: "invalid object", options: map[string]any{"evidence_context": true}, wantErr: "must be an object"}, {name: "invalid lane type", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": "npcs"}}, wantErr: "must be an array"}, {name: "invalid window type", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}, "window_units": "3"}}, wantErr: "must be an integer"}, } { t.Run(test.name, func(t *testing.T) { got, err := DecodeOptions(test.options) if test.wantErr != "" { if err == nil || !strings.Contains(err.Error(), test.wantErr) { t.Fatalf("DecodeOptions() error = %v, want %q", err, test.wantErr) } return } if err != nil { t.Fatalf("DecodeOptions() error = %v, want nil", err) } if !reflect.DeepEqual(got.EvidenceContext, test.want) { t.Fatalf("EvidenceContext = %#v, want %#v", got.EvidenceContext, test.want) } }) } } func TestProfileValidationRejectsUnknownEvidenceLaneAndCopiesInputs(t *testing.T) { registry := pipeline.NewOutputEncoderRegistry() if err := Register(registry); err != nil { t.Fatal(err) } options := map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}}} if err := registry.ValidateProfileOptions(Key, pipeline.OutputProfileOptionContext{LaneIDs: []string{"npcs", "spells"}}, options); err != nil { t.Fatalf("ValidateProfileOptions() error = %v, want nil", err) } if err := registry.ValidateProfileOptions(Key, pipeline.OutputProfileOptionContext{LaneIDs: []string{"spells"}}, options); err == nil || !strings.Contains(err.Error(), "not configured") { t.Fatalf("ValidateProfileOptions() error = %v, want unknown lane failure", err) } } func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) { req := contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1", PipelineID: "pipeline-1"}, NormalizeOutputs: []contracts.SerializedOutput{ normalizeOutput("spells", `{"spell_casts":[{"spell":"Cure Wounds"}]}`), normalizeOutput("notes/items", `{"items":[{"name":"Torch"}]}`), }, Rejected: []contracts.RejectedOutput{ { Stage: "extract", LaneID: "spells", ModuleKey: "dnd/spells", ChunkID: "chunk-1", ChunkIndex: 1, ReasonCode: "invalid", Message: "not accepted", AttemptCount: 1, ValidatorName: "validator", }, }, Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "check source"}}, } result, err := New().Encode(context.Background(), req) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } wantNames := []string{ "index.json", "lanes/notes_items.json", "lanes/spells.json", "manifest.json", "rejected.json", "warnings.json", } if got := outputFileNames(result.Files); !reflect.DeepEqual(got, wantNames) { t.Fatalf("file names = %#v, want %#v", got, wantNames) } for _, file := range result.Files { if !strings.HasSuffix(string(file.Bytes), "\n") { t.Fatalf("%s does not end with newline: %q", file.Name, string(file.Bytes)) } if file.ContentType == contentTypeJSON && !stdjson.Valid(file.Bytes) { t.Fatalf("%s has invalid JSON: %s", file.Name, file.Bytes) } } spells := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json")) spellCasts := spells["spell_casts"].([]any) if spellCasts[0].(map[string]any)["spell"] != "Cure Wounds" { t.Fatalf("spells output = %#v, want raw normalized content", spells) } index := decodeObject(t, fileBytes(t, result.Files, "index.json")) outputFiles := index["output_files"].([]any) if len(outputFiles) != 2 { t.Fatalf("len(index output_files) = %d, want 2", len(outputFiles)) } firstIndex := outputFiles[0].(map[string]any) secondIndex := outputFiles[1].(map[string]any) if firstIndex["lane_id"] != "notes/items" || secondIndex["lane_id"] != "spells" { t.Fatalf("output_files = %#v, want sorted by lane id", outputFiles) } rejected := decodeObject(t, fileBytes(t, result.Files, "rejected.json")) if got := rejected["rejected"].([]any); len(got) != 1 { t.Fatalf("rejected = %#v, want one rejected output", got) } } func TestEncodeIncludesRejectedAndWarningsWhenEmpty(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } rejected := decodeObject(t, fileBytes(t, result.Files, "rejected.json")) if got := rejected["rejected"].([]any); len(got) != 0 { t.Fatalf("rejected = %#v, want empty array", got) } warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json")) if got := warnings["warnings"].([]any); len(got) != 0 { t.Fatalf("warnings = %#v, want empty array", got) } } func TestEncodeChunkMapExportIsOptIn(t *testing.T) { artifact := acceptedChunkMapArtifact(t) request := contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, ChunkMap: &artifact, } for _, test := range []struct { name string encoder *Encoder }{ {name: "default", encoder: New()}, {name: "explicitly disabled", encoder: NewWithOptions(Options{IncludeChunkMap: false})}, } { t.Run(test.name, func(t *testing.T) { result, err := test.encoder.Encode(context.Background(), request) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } if got, want := outputFileNames(result.Files), []string{"index.json", "manifest.json", "rejected.json", "warnings.json"}; !reflect.DeepEqual(got, want) { t.Fatalf("file names = %#v, want %#v", got, want) } index := decodeObject(t, fileBytes(t, result.Files, "index.json")) if _, ok := index["chunk_map"]; ok { t.Fatalf("index = %#v, want no chunk map descriptor", index) } }) } } func TestEncodeIncludesValidatedChunkMap(t *testing.T) { artifact := acceptedChunkMapArtifact(t) result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, NormalizeOutputs: []contracts.SerializedOutput{ normalizeOutput("spells", `{"spell_casts":[]}`), }, ChunkMap: &artifact, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } if got, want := outputFileNames(result.Files), []string{"chunk-map.json", "index.json", "lanes/spells.json", "manifest.json", "rejected.json", "warnings.json"}; !reflect.DeepEqual(got, want) { t.Fatalf("file names = %#v, want %#v", got, want) } chunkMapFile := fileBytes(t, result.Files, chunkMapFileName) if !stdjson.Valid(chunkMapFile) || !strings.HasSuffix(string(chunkMapFile), "\n") || !strings.Contains(string(chunkMapFile), "\n \"source_id\"") { t.Fatalf("chunk map file = %q, want pretty valid newline-terminated JSON", chunkMapFile) } if _, err := chunkmap.New().Decode(chunkMapFile); err != nil { t.Fatalf("Decode(chunk map file) error = %v, want nil", err) } index := decodeObject(t, fileBytes(t, result.Files, "index.json")) if got, want := index["chunk_map"], map[string]any{ "artifact_kind": "source/chunk-map", "file": chunkMapFileName, "media_type": chunkmap.MediaType, "schema_id": chunkmap.SchemaID, "schema_name": chunkmap.SchemaName, "schema_version": chunkmap.SchemaVersion, }; !reflect.DeepEqual(got, want) { t.Fatalf("chunk map descriptor = %#v, want %#v", got, want) } for _, entry := range index["output_files"].([]any) { if entry.(map[string]any)["file"] == chunkMapFileName { t.Fatalf("output_files = %#v, want no chunk map", index["output_files"]) } } } func TestEncodePreservesChunkMapAnnotationNumbers(t *testing.T) { artifact := acceptedChunkMapArtifactWithPlanAnnotation(t, stdjson.RawMessage(`{"decimal":1.0,"large":9007199254740993}`)) result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{ ChunkMap: &artifact, }) if err != nil { t.Fatalf("Encode() error = %v", err) } value, err := chunkmap.New().Decode(fileBytes(t, result.Files, chunkMapFileName)) if err != nil { t.Fatalf("Decode(emitted chunk map) error = %v", err) } if got, want := string(value.PlanAnnotations["test/numbers"]), `{"decimal":1.0,"large":9007199254740993}`; got != want { t.Fatalf("numeric annotation = %s, want %s", got, want) } } func TestEncodeOmitsChunkMapWithoutAcceptedArtifact(t *testing.T) { result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } if got := outputFileNames(result.Files); containsString(got, chunkMapFileName) { t.Fatalf("file names = %#v, want no chunk map", got) } index := decodeObject(t, fileBytes(t, result.Files, "index.json")) if _, ok := index["chunk_map"]; ok { t.Fatalf("index = %#v, want no chunk map descriptor", index) } } func TestEncodeRejectsInvalidChunkMapArtifact(t *testing.T) { artifact := acceptedChunkMapArtifact(t) for _, test := range []struct { name string mutate func(*contracts.SerializedArtifact) }{ {name: "kind", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Kind = "other/chunk-map" }}, {name: "schema", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.Version = "v2" }}, {name: "media type", mutate: func(artifact *contracts.SerializedArtifact) { artifact.MediaType = "text/plain" }}, {name: "content", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Content = []byte(`{}`) }}, } { t.Run(test.name, func(t *testing.T) { candidate := contracts.CloneSerializedArtifact(artifact) test.mutate(&candidate) _, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{ChunkMap: &candidate}) if err == nil { t.Fatal("Encode() error = nil, want invalid chunk map error") } }) } } func TestEncodeIncludesValidatedEvidenceContext(t *testing.T) { artifact := acceptedEvidenceContextArtifact(t) result, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &artifact}) if err != nil { t.Fatalf("Encode() error = %v", err) } if got := string(fileBytes(t, result.Files, evidenceContextFileName)); !strings.HasSuffix(got, "\n") || !stdjson.Valid([]byte(got)) { t.Fatalf("evidence context file = %q, want pretty valid newline-terminated JSON", got) } value, err := evidencecontext.New().Decode(fileBytes(t, result.Files, evidenceContextFileName)) if err != nil { t.Fatalf("Decode(evidence context file) error = %v", err) } if len(value) != 1 || value[0].ID != 7 || value[0].Text != "Source content retained only in the evidence artifact." { t.Fatalf("evidence context = %#v, want the published source-unit array", value) } index := decodeObject(t, fileBytes(t, result.Files, "index.json")) if got, want := index["evidence_context"], map[string]any{ "artifact_kind": string(evidencecontext.ArtifactKind), "file": evidenceContextFileName, "media_type": evidencecontext.MediaType, "schema_id": evidencecontext.SchemaID, "schema_name": evidencecontext.SchemaName, "schema_version": evidencecontext.SchemaVersion, }; !reflect.DeepEqual(got, want) { t.Fatalf("evidence context descriptor = %#v, want %#v", got, want) } for _, entry := range index["output_files"].([]any) { if entry.(map[string]any)["file"] == evidenceContextFileName { t.Fatalf("output_files = %#v, want no evidence context lane entry", index["output_files"]) } } } func TestEncodeRejectsInvalidEvidenceContextArtifactWithoutContentLeakage(t *testing.T) { artifact := acceptedEvidenceContextArtifact(t) for _, test := range []struct { name string mutate func(*contracts.SerializedArtifact) }{ {name: "kind", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Kind = "other/evidence" }}, {name: "schema identity", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.Version = "v2" }}, {name: "schema digest", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.JSONSchema[0] = '[' }}, {name: "media type", mutate: func(artifact *contracts.SerializedArtifact) { artifact.MediaType = "text/plain" }}, {name: "payload", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Content = []byte(`{"source_id":"secret transcript text"}`) }}, } { t.Run(test.name, func(t *testing.T) { candidate := contracts.CloneSerializedArtifact(artifact) test.mutate(&candidate) _, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &candidate}) if err == nil || err.Error() != "json output encoder: evidence context artifact is invalid" { t.Fatalf("Encode() error = %v, want fixed evidence artifact error", err) } if strings.Contains(err.Error(), "secret transcript text") { t.Fatalf("Encode() leaked artifact content: %v", err) } }) } } func TestEncodeOmitsEvidenceContextWhenArtifactIsAbsent(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{Manifest: artifacts.RunManifest{RunID: "run-1"}}) if err != nil { t.Fatalf("Encode() error = %v", err) } if got := outputFileNames(result.Files); containsString(got, evidenceContextFileName) { t.Fatalf("file names = %#v, want no evidence context", got) } index := decodeObject(t, fileBytes(t, result.Files, "index.json")) if _, ok := index["evidence_context"]; ok { t.Fatalf("index = %#v, want no evidence context descriptor", index) } } func TestEncodeChunkMapDoesNotMutateRequest(t *testing.T) { artifact := acceptedChunkMapArtifact(t) artifact.Metadata = map[string]any{"owner": "caller"} request := contracts.OutputRequest{ChunkMap: &artifact} before := contracts.CloneSerializedArtifact(artifact) result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), request) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } if !reflect.DeepEqual(*request.ChunkMap, before) { t.Fatalf("chunk map artifact mutated:\nbefore: %#v\nafter: %#v", before, *request.ChunkMap) } request.ChunkMap.Content[0] = '[' request.ChunkMap.Schema.JSONSchema[0] = '[' request.ChunkMap.Metadata["owner"] = "changed" if _, err := chunkmap.New().Decode(fileBytes(t, result.Files, chunkMapFileName)); err != nil { t.Fatalf("chunk map output changed after request mutation: %v", err) } } func TestEncodePrettyPrintsJSON(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } manifest := string(fileBytes(t, result.Files, "manifest.json")) if !strings.Contains(manifest, "\n \"run_id\": \"run-1\"\n") { t.Fatalf("manifest JSON = %q, want two-space indentation", manifest) } } func TestEncodePublishesLLMProfileProvenance(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{ RunID: "run-1", LLMProfiles: []artifacts.LLMProfileManifest{ { ID: "endpoint-profile", Provider: "promptkit", Model: "endpoint-model", ReasoningEffort: "low", }, { ID: "profile", Provider: "promptkit", Model: "model", BackendID: "openrouter", ReasoningEffort: "high", }, }, }, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json")) profiles := manifest["llm_profiles"].([]any) if len(profiles) != 2 { t.Fatalf("llm_profiles = %#v, want two entries", profiles) } endpointProfile := profiles[0].(map[string]any) if _, exists := endpointProfile["backend_id"]; exists || endpointProfile["reasoning_effort"] != "low" { t.Fatalf("endpoint-only LLM profile = %#v, want omitted backend and published reasoning", endpointProfile) } backendProfile := profiles[1].(map[string]any) if backendProfile["backend_id"] != "openrouter" || backendProfile["reasoning_effort"] != "high" { t.Fatalf("backend LLM profile = %#v, want published backend and reasoning fields", backendProfile) } } func TestEncodeIncludesManifestReferences(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{ RunID: "run-1", References: []artifacts.ReferenceProvenance{ { Stage: "extract", LaneID: "events", SlotName: "roster", OriginType: "file", OriginURI: "file:///tmp/roster.txt", Digest: "sha256:reference", MediaType: "text/plain; charset=utf-8", SizeBytes: 12, BindingSource: "config", }, }, }, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json")) references := manifest["references"].([]any) if len(references) != 1 { t.Fatalf("references = %#v, want one entry", references) } reference := references[0].(map[string]any) if reference["lane_id"] != "events" || reference["slot_name"] != "roster" || reference["digest"] != "sha256:reference" { t.Fatalf("reference manifest = %#v, want lane slot digest", reference) } if _, ok := reference["content"]; ok { t.Fatalf("reference manifest = %#v, want no content field", reference) } } func TestEncodeIncludesManifestRawOutputProvenance(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ Manifest: artifacts.RunManifest{ RunID: "run-1", NormalizedOutputs: []artifacts.NormalizedOutputManifest{ { LaneID: "spells", ModuleKey: "noop", SourceID: "source-1", MediaType: contentTypeJSON, Schema: artifacts.OutputSchemaProvenance{ ID: "schema-id", Name: "schema-name", Version: "v1", }, }, }, RejectedOutputs: []artifacts.RejectedOutputManifest{ { Stage: "extract", LaneID: "spells", ModuleKey: "dnd/spells", ChunkID: "chunk-0", ReasonCode: "raw_output_rejected", AttemptCount: 2, }, }, }, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json")) normalized := manifest["normalized_outputs"].([]any) if len(normalized) != 1 { t.Fatalf("normalized_outputs = %#v, want one entry", normalized) } normalizedEntry := normalized[0].(map[string]any) if normalizedEntry["lane_id"] != "spells" || normalizedEntry["media_type"] != contentTypeJSON { t.Fatalf("normalized output manifest = %#v, want lane and media type", normalizedEntry) } rejected := manifest["rejected_outputs"].([]any) if len(rejected) != 1 { t.Fatalf("rejected_outputs = %#v, want one entry", rejected) } rejectedEntry := rejected[0].(map[string]any) if rejectedEntry["attempt_count"] != float64(2) || rejectedEntry["chunk_id"] != "chunk-0" { t.Fatalf("rejected output manifest = %#v, want attempt count and chunk", rejectedEntry) } } func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("///", `{"value":true}`)}, }) if err == nil { t.Fatal("Encode() error = nil, want unsafe lane id error") } if !strings.Contains(err.Error(), "json output encoder") || !strings.Contains(err.Error(), "safe file name") { t.Fatalf("Encode() error = %q, want safe file name context", err.Error()) } } func TestEncodeSanitizesParentPathSequences(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("dnd..spell.", `{"value":true}`)}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } if got := outputFileNames(result.Files); !containsString(got, "lanes/dnd__spell.json") { t.Fatalf("file names = %#v, want sanitized output filename", got) } } func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) { tests := []struct { name string output contracts.SerializedOutput want string }{ { name: "invalid JSON", output: normalizeOutput("spells", `{"spell_casts":[`), want: "invalid JSON", }, { name: "unsupported media type", output: func() contracts.SerializedOutput { output := normalizeOutput("spells", `{"spell_casts":[]}`) output.Artifact.MediaType = "text/plain" return output }(), want: "unsupported media type", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.SerializedOutput{test.output}, }) if err == nil { t.Fatal("Encode() error = nil, want error") } if !strings.Contains(err.Error(), test.want) { t.Fatalf("Encode() error = %q, want %q", err.Error(), test.want) } }) } } func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.SerializedOutput{ normalizeOutput("a/b", `{"value":"slash"}`), normalizeOutput("a?b", `{"value":"question"}`), }, }) if err == nil { t.Fatal("Encode() error = nil, want duplicate file error") } if !strings.Contains(err.Error(), "duplicate output file") { t.Fatalf("Encode() error = %q, want duplicate file context", err.Error()) } } func TestEncodeDoesNotMutateInputs(t *testing.T) { req := contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, NormalizeOutputs: []contracts.SerializedOutput{ normalizeOutput("spells", `{"name":"original"}`), }, Rejected: []contracts.RejectedOutput{ {Stage: "extract", LaneID: "spells", Message: "not accepted"}, }, Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "message"}}, } before := mustMarshal(t, req) result, err := New().Encode(context.Background(), req) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } after := mustMarshal(t, req) if before != after { t.Fatalf("request mutated:\nbefore: %s\nafter: %s", before, after) } req.NormalizeOutputs[0].Artifact.Content[0] = '[' req.NormalizeOutputs[0].Artifact.Metadata["name"] = "changed" req.Rejected[0].Message = "changed" req.Warnings[0].Message = "changed" if !stdjson.Valid(fileBytes(t, result.Files, "lanes/spells.json")) { t.Fatal("output changed after request mutation") } warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json")) gotWarnings := warnings["warnings"].([]any) if gotWarnings[0].(map[string]any)["message"] != "message" { t.Fatalf("warnings output changed after request mutation: %#v", gotWarnings) } } func TestOutputFilesDoNotContainWarnings(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("spells", `{"spell":"Shield"}`)}, Warnings: []contracts.Warning{ {ReasonCode: "pipeline-warning", Message: "warning"}, }, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } outputFile := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json")) if _, ok := outputFile["warnings"]; ok { t.Fatalf("output file contains warnings: %#v", outputFile) } } func normalizeOutput(laneID string, content string) contracts.SerializedOutput { return contracts.SerializedOutput{ LaneID: laneID, NormalizerKey: "noop", SourceID: "source-1", Artifact: contracts.SerializedArtifact{Schema: contracts.ArtifactSchema{ ID: "schema-id", Name: "schema-name", Version: "v1", }, Content: []byte(content), MediaType: contentTypeJSON, Metadata: map[string]any{"name": laneID}, }, } } func acceptedChunkMapArtifact(t *testing.T) contracts.SerializedArtifact { return acceptedChunkMapArtifactWithPlanAnnotation(t, nil) } func acceptedChunkMapArtifactWithPlanAnnotation(t *testing.T, annotation stdjson.RawMessage) contracts.SerializedArtifact { t.Helper() document := &source.SourceDocument{ ID: "source-1", Kind: "text", Format: "text/plain", Units: []source.SourceUnit{{ ID: 1, Kind: "text", Text: "Accepted source content.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, }}, } digest, err := source.DigestDocument(document) if err != nil { t.Fatal(err) } document.Digest = digest plan := source.ChunkPlan{SourceDigest: digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 1}}} if annotation != nil { plan.Annotations = source.ChunkAnnotations{"test/numbers": append(stdjson.RawMessage(nil), annotation...)} } chunks, err := source.MaterializeChunkPlan(document, plan) if err != nil { t.Fatal(err) } artifact, err := chunkmap.Serialize(chunkmap.BuildRequest{ Source: document, Plan: plan, Chunks: chunks, RequestedChunker: "generic/units", Producer: chunkmap.Producer{InputModule: "test/input", ChunkModule: "generic/units"}, }) if err != nil { t.Fatal(err) } return artifact } func acceptedEvidenceContextArtifact(t *testing.T) contracts.SerializedArtifact { t.Helper() document := &source.SourceDocument{ ID: "source-1", Kind: "text", Format: "text/plain", Units: []source.SourceUnit{{ ID: 7, Kind: "text", Text: "Source content retained only in the evidence artifact.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 7, EndUnitID: 7}, }}, } digest, err := source.DigestDocument(document) if err != nil { t.Fatal(err) } document.Digest = digest artifact, err := evidencecontext.Serialize(evidencecontext.BuildRequest{ Source: document, WindowUnits: 3, SourceRefs: []source.SourceRef{{SourceID: "source-1", StartUnitID: 7, EndUnitID: 7}}, }) if err != nil { t.Fatal(err) } return artifact } func outputFileNames(files []contracts.OutputFile) []string { names := make([]string, 0, len(files)) for _, file := range files { names = append(names, file.Name) } return names } func containsString(values []string, want string) bool { for _, value := range values { if value == want { return true } } return false } func fileBytes(t *testing.T, files []contracts.OutputFile, name string) []byte { t.Helper() for _, file := range files { if file.Name == name { return file.Bytes } } t.Fatalf("file %q not found in %#v", name, outputFileNames(files)) return nil } func decodeObject(t *testing.T, data []byte) map[string]any { t.Helper() var got map[string]any if err := stdjson.Unmarshal(data, &got); err != nil { t.Fatalf("Unmarshal() error = %v, want nil\n%s", err, data) } return got } func mustMarshal(t *testing.T, value any) string { t.Helper() data, err := stdjson.Marshal(value) if err != nil { t.Fatalf("Marshal() error = %v, want nil", err) } return string(data) }