package cli import ( "bytes" "encoding/json" "errors" "io" "path/filepath" "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 TestRunResultEncodesRequiredFieldsAndCounts(t *testing.T) { result, err := newRunResult(testResolvedPipeline(pipeline.DefaultOutputModule), testRunOutput(), "relative-output", "relative-debug") if err != nil { t.Fatal(err) } encoded, err := encodeRunResult(result) if err != nil { t.Fatal(err) } if encoded[len(encoded)-1] != '\n' || bytes.Count(encoded, []byte{'\n'}) != 1 { t.Fatalf("encoded result is not one newline-terminated object: %q", encoded) } var decoded map[string]any if err := json.Unmarshal(encoded, &decoded); err != nil { t.Fatal(err) } if got := decoded["schema_version"]; got != runResultSchemaVersion { t.Fatalf("schema_version = %q", got) } if got := decoded["run_id"]; got != "run-123" { t.Fatalf("run_id = %q", got) } if got := decoded["pipeline_id"]; got != "sample" { t.Fatalf("pipeline_id = %q", got) } if got := decoded["validation_status"]; got != "rejected" { t.Fatalf("validation_status = %q", got) } if got := decoded["index_file"]; got != "index.json" { t.Fatalf("index_file = %q", got) } if got := decoded["normalized_output_count"]; got != float64(2) { t.Fatalf("normalized_output_count = %v", got) } if got := decoded["rejected_output_count"]; got != float64(1) { t.Fatalf("rejected_output_count = %v", got) } if got := decoded["warning_count"]; got != float64(1) { t.Fatalf("warning_count = %v", got) } if got := decoded["output_directory"]; got != filepath.Join(mustWorkingDirectory(t), "relative-output") { t.Fatalf("output_directory = %q", got) } if got := decoded["debug_directory"]; got != filepath.Join(mustWorkingDirectory(t), "relative-debug") { t.Fatalf("debug_directory = %q", got) } } func TestRunResultRejectsInvalidRequiredValues(t *testing.T) { tests := []struct { name string resolved pipeline.ResolvedPipeline output pipeline.RunOutput directory string }{ {name: "blank run ID", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.RunID = " " }), directory: "output"}, {name: "blank resolved pipeline ID", resolved: pipeline.ResolvedPipeline{Output: pipeline.ModuleBinding{Module: pipeline.DefaultOutputModule}}, output: testRunOutput(), directory: "output"}, {name: "blank manifest pipeline ID", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.PipelineID = "" }), directory: "output"}, {name: "mismatched pipeline IDs", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.PipelineID = "other" }), directory: "output"}, {name: "blank validation status", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.ValidationStatus = " " }), directory: "output"}, {name: "blank output directory", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutput(), directory: " "}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, err := newRunResult(tt.resolved, tt.output, tt.directory, ""); err == nil { t.Fatal("newRunResult() succeeded") } }) } } func TestRunResultOmitsIndexFileForOtherOutputModules(t *testing.T) { result, err := newRunResult(testResolvedPipeline("test/output"), testRunOutputWithout(func(output *pipeline.RunOutput) { output.OutputFiles = nil }), "output", "") if err != nil { t.Fatal(err) } if result.IndexFile != "" { t.Fatalf("index_file = %q", result.IndexFile) } encoded, err := encodeRunResult(result) if err != nil { t.Fatal(err) } var decoded map[string]any if err := json.Unmarshal(encoded, &decoded); err != nil { t.Fatal(err) } if _, ok := decoded["index_file"]; ok { t.Fatalf("encoded non-JSON result contains index_file: %s", encoded) } if _, ok := decoded["debug_directory"]; ok { t.Fatalf("encoded result without debug capture contains debug_directory: %s", encoded) } } func TestRunResultRequiresOneProductionIndexFile(t *testing.T) { tests := []struct { name string files []contracts.OutputFile }{ {name: "missing", files: nil}, {name: "duplicate", files: []contracts.OutputFile{{Name: "index.json"}, {Name: "index.json"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { output := testRunOutput() output.OutputFiles = tt.files if _, err := newRunResult(testResolvedPipeline(pipeline.DefaultOutputModule), output, "output", ""); err == nil { t.Fatal("newRunResult() succeeded") } }) } } func TestWriteRunResultCompletesAndReportsWriterFailure(t *testing.T) { content := []byte("result\n") var target bytes.Buffer if err := writeRunResult(partialResultWriter{writer: &target, limit: 2}, content); err != nil { t.Fatal(err) } if got := target.String(); got != string(content) { t.Fatalf("written result = %q", got) } writerErr := errors.New("result writer failed") if err := writeRunResult(failingResultWriter{err: writerErr}, content); !errors.Is(err, writerErr) { t.Fatalf("writeRunResult() error = %v", err) } if err := writeRunResult(zeroResultWriter{}, content); !errors.Is(err, io.ErrShortWrite) { t.Fatalf("zero-progress error = %v", err) } } func testResolvedPipeline(outputModule string) pipeline.ResolvedPipeline { return pipeline.ResolvedPipeline{ID: "sample", Output: pipeline.ModuleBinding{Module: outputModule}} } func testRunOutput() pipeline.RunOutput { return pipeline.RunOutput{ Manifest: artifacts.RunManifest{RunID: "run-123", PipelineID: "sample", ValidationStatus: "rejected"}, NormalizeOutputs: []contracts.SerializedOutput{{}, {}}, Rejected: []contracts.RejectedOutput{{}}, Warnings: []contracts.Warning{{}}, OutputFiles: []contracts.OutputFile{{Name: "index.json"}}, } } func testRunOutputWithout(change func(*pipeline.RunOutput)) pipeline.RunOutput { output := testRunOutput() change(&output) return output } func mustWorkingDirectory(t *testing.T) string { t.Helper() workingDirectory, err := filepath.Abs(".") if err != nil { t.Fatal(err) } return workingDirectory } type partialResultWriter struct { writer io.Writer limit int } func (w partialResultWriter) Write(content []byte) (int, error) { if len(content) > w.limit { content = content[:w.limit] } return w.writer.Write(content) } type failingResultWriter struct{ err error } func (w failingResultWriter) Write([]byte) (int, error) { return 0, w.err } type zeroResultWriter struct{} func (zeroResultWriter) Write([]byte) (int, error) { return 0, nil }