package cli import ( "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/audita/internal/core/normalization" "gitea.maximumdirect.net/eric/audita/internal/core/reporting" "gitea.maximumdirect.net/eric/audita/internal/core/schema" ) func TestRunRootHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"--help"}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d", exitCode) } if !strings.Contains(stdout.String(), "audita [options]") { t.Fatalf("expected root usage in stdout, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) } } func TestRunProcessHelpListsExpectedFlags(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", "--help"}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d", exitCode) } for _, expectedFlag := range []string{ "--glossary", "--output", "--report-json", "--modules", "--llm-api-key", "--validation-llm-api-key", "--model", "--validation-model", "--base-url", "--validation-base-url", "--llm-timeout-seconds", "--validation-llm-timeout-seconds", "--validation-max-prompt-tokens", "--target-sections", "--max-retries", "--validation-max-retries", "--validation-llm-concurrency", "--max-section-tokens", "--min-section-tokens", "--glossary-confidence-threshold", "--grammar-confidence-threshold", "--homophones-confidence-threshold", "--spoken-word-confidence-threshold", "--normalize-max-segment-gap", "--normalize-ellipsis-gap", "--normalize-max-segment-duration", "--normalize-max-segment-tokens", "--work-dir", "--work-dir-retention", } { if !strings.Contains(stdout.String(), expectedFlag) { t.Fatalf("expected process help to include %q", expectedFlag) } } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) } } func TestRunProcessMissingTranscriptPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "expected exactly 1 transcript JSON path argument") { t.Fatalf("expected missing transcript error, got %q", stderr.String()) } } func TestRunProcessMissingGlossary(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", fixturePath("tiny_transcript.json")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "--glossary is required") { t.Fatalf("expected missing glossary error, got %q", stderr.String()) } } func TestRunProcessAcceptsBothTranscriptTopLevelForms(t *testing.T) { testCases := []string{ schemaFixturePath("transcript_bare_array.json"), schemaFixturePath("transcript_object.json"), } for _, transcriptPath := range testCases { t.Run(filepath.Base(transcriptPath), func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } parsed, err := schema.ParseTranscriptJSON(stdout.Bytes()) if err != nil { t.Fatalf("expected normalized transcript JSON output, got %v", err) } if len(parsed.Segments) == 0 { t.Fatalf("expected at least one output segment") } }) } } func TestRunProcessOutputToFileLeavesStdoutEmpty(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := fixturePath("tiny_transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") outputPath := filepath.Join(t.TempDir(), "normalized.json") exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout with --output, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } outputBytes := readFile(t, outputPath) if !json.Valid(outputBytes) { t.Fatalf("expected valid JSON output file, got %q", string(outputBytes)) } } func TestRunProcessInvalidTranscriptSchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", schemaFixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid transcript schema") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "transcript_schema") { t.Fatalf("expected transcript schema phase in stderr, got %q", stderr.String()) } if !strings.Contains(stderr.String(), "speaker") { t.Fatalf("expected speaker validation details, got %q", stderr.String()) } } func TestRunProcessInvalidGlossarySchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", fixturePath("tiny_transcript.json"), "--glossary", schemaFixturePath("glossary_missing_fields.yaml")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid glossary schema") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "glossary_schema") { t.Fatalf("expected glossary schema phase in stderr, got %q", stderr.String()) } } func TestRunProcessCLIOverridesEnvironment(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := writeFile(t, "transcript.json", `[ {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}, {"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"} ]`) t.Setenv("AUDITA_NORMALIZE_MAX_SEGMENT_GAP", "0") exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), "--normalize-max-segment-gap", "2.0", }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } transcript, err := schema.ParseTranscriptJSON(stdout.Bytes()) if err != nil { t.Fatalf("expected valid transcript JSON output: %v", err) } if len(transcript.Segments) != 1 { t.Fatalf("expected merged output from CLI override, got %d segments", len(transcript.Segments)) } } func TestRunProcessReportJSONSuccessIncludesNormalizationSummary(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := writeFile(t, "transcript.json", `[ {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}, {"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"} ]`) reportPath := filepath.Join(t.TempDir(), "report.json") outputPath := filepath.Join(t.TempDir(), "normalized.json") exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), "--output", outputPath, "--report-json", reportPath, }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout with --output, got %q", stdout.String()) } report := readProcessReport(t, reportPath) if report.Status != "success" { t.Fatalf("expected success status, got %q", report.Status) } if report.Operation != "process" { t.Fatalf("expected operation process, got %q", report.Operation) } if report.InputSegmentCount == nil || *report.InputSegmentCount != 2 { t.Fatalf("expected input segment count 2, got %v", report.InputSegmentCount) } if report.NormalizedSegmentCount == nil || *report.NormalizedSegmentCount != 1 { t.Fatalf("expected normalized segment count 1, got %v", report.NormalizedSegmentCount) } if report.NormalizationMerges == nil || *report.NormalizationMerges != 1 { t.Fatalf("expected 1 merge, got %v", report.NormalizationMerges) } } func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", fixturePath("malformed_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--report-json", reportPath, }, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) } report := readProcessReport(t, reportPath) if report.Status != "failed" { t.Fatalf("expected failed status, got %q", report.Status) } if report.ErrorPhase != "transcript_schema" { t.Fatalf("expected transcript_schema error phase, got %q", report.ErrorPhase) } if !strings.Contains(report.ErrorMessage, "not valid JSON") { t.Fatalf("expected parse error message, got %q", report.ErrorMessage) } } func TestRunProcessReportJSONDoesNotLeakAPIKeys(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer primaryKey := "top-secret-primary" validationKey := "top-secret-validation" t.Setenv("AUDITA_LLM_API_KEY", primaryKey) t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", validationKey) reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", fixturePath("tiny_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--report-json", reportPath, }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } reportBytes := readFile(t, reportPath) if strings.Contains(string(reportBytes), primaryKey) || strings.Contains(string(reportBytes), validationKey) { t.Fatalf("report leaked API key material: %s", string(reportBytes)) } } func TestRunProcessWritesNormalizationDiagnosticsArtifacts(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer workDir := t.TempDir() transcriptPath := writeFile(t, "transcript.json", `[ {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}, {"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"} ]`) exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), "--work-dir", workDir, "--work-dir-retention", "always", }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } runPath := onlyRunDir(t, workDir) for _, artifact := range []string{ "source-transcript.json", "source-transcript-parsed.json", "normalized-transcript.json", "normalization-summary.json", "report.json", } { if _, err := os.Stat(filepath.Join(runPath, artifact)); err != nil { t.Fatalf("expected artifact %s: %v", artifact, err) } } summaryBytes := readFile(t, filepath.Join(runPath, "normalization-summary.json")) var summary normalization.NormalizationSummary if err := json.Unmarshal(summaryBytes, &summary); err != nil { t.Fatalf("failed to parse normalization summary: %v", err) } if summary.OutputSegmentCount != 1 { t.Fatalf("expected merged output in normalization summary, got %d", summary.OutputSegmentCount) } } func TestRunProcessFailedRunRetainedWithNeverRetention(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer workDir := t.TempDir() exitCode := Run([]string{ "process", schemaFixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--work-dir", workDir, "--work-dir-retention", "never", }, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) } runPath := onlyRunDir(t, workDir) if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil { t.Fatalf("expected error.log in retained failed run: %v", err) } } func fixturePath(name string) string { return filepath.Join("testdata", name) } func schemaFixturePath(name string) string { return filepath.Join("..", "core", "schema", "testdata", name) } func writeFile(t *testing.T, name string, content string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write test file %q: %v", path, err) } return path } func readFile(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read file %q: %v", path, err) } return data } func readProcessReport(t *testing.T, path string) reporting.ProcessReport { t.Helper() raw := readFile(t, path) var report reporting.ProcessReport if err := json.Unmarshal(raw, &report); err != nil { t.Fatalf("failed to parse process report JSON: %v (raw=%s)", err, string(raw)) } return report } func onlyRunDir(t *testing.T, workDir string) string { t.Helper() entries, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work dir %q: %v", workDir, err) } if len(entries) != 1 { t.Fatalf("expected exactly one run dir in %q, got %d", workDir, len(entries)) } return filepath.Join(workDir, entries[0].Name()) }