package json import ( "context" stdjson "encoding/json" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) func TestModuleSpecAndRegister(t *testing.T) { want := pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageOutput, 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) } } func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) { req := contracts.OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1", PipelineID: "pipeline-1"}, NormalizeOutputs: []contracts.NormalizeOutput{ 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", "manifest.json", "outputs/notes_items.json", "outputs/spells.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, "outputs/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 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 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 TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.NormalizeOutput{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.NormalizeOutput{normalizeOutput("dnd..spell.", `{"value":true}`)}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) } if got := outputFileNames(result.Files); !containsString(got, "outputs/dnd__spell.json") { t.Fatalf("file names = %#v, want sanitized output filename", got) } } func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) { _, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.NormalizeOutput{ 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.NormalizeOutput{ 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].Payload.Content[0] = '[' req.NormalizeOutputs[0].Payload.Metadata["name"] = "changed" req.Rejected[0].Message = "changed" req.Warnings[0].Message = "changed" if !stdjson.Valid(fileBytes(t, result.Files, "outputs/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.NormalizeOutput{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, "outputs/spells.json")) if _, ok := outputFile["warnings"]; ok { t.Fatalf("output file contains warnings: %#v", outputFile) } } func normalizeOutput(laneID string, content string) contracts.NormalizeOutput { return contracts.NormalizeOutput{ LaneID: laneID, NormalizerKey: "noop", SourceID: "source-1", Schema: contracts.ResponseSchema{ ID: "schema-id", Name: "schema-name", Version: "v1", }, Payload: contracts.RawPayload{ Content: []byte(content), MediaType: contentTypeJSON, Metadata: map[string]any{"name": laneID}, }, } } 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) }