Files
audita/internal/cli/run_test.go

443 lines
13 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
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 <command> [options]") {
t.Fatalf("expected root usage in stdout, got %q", stdout.String())
}
if !strings.Contains(stdout.String(), "process") {
t.Fatalf("expected process command in root help, 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, got %q", expectedFlag, stdout.String())
}
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"unknown"}, &stdout, &stderr)
if exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "unknown command") {
t.Fatalf("expected unknown command error in 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 TestRunProcessUnreadableTranscript(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
missingTranscript := filepath.Join(t.TempDir(), "missing.json")
exitCode := Run([]string{"process", missingTranscript, "--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(), "failed to read transcript file") {
t.Fatalf("expected unreadable transcript error, got %q", stderr.String())
}
}
func TestRunProcessMalformedTranscriptJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"process", fixturePath("malformed_transcript.json"), "--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(), "transcript file is not valid JSON") {
t.Fatalf("expected malformed transcript error, got %q", stderr.String())
}
}
func TestRunProcessOutputToFile(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(), "corrected.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 when --output is used, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
}
inputBytes := readFile(t, transcriptPath)
outputBytes := readFile(t, outputPath)
assertJSONSemanticallyEqual(t, inputBytes, outputBytes)
}
func TestRunProcessOutputToStdout(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
transcriptPath := fixturePath("tiny_transcript.json")
glossaryPath := fixturePath("tiny_glossary.yaml")
exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &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())
}
inputBytes := readFile(t, transcriptPath)
assertJSONSemanticallyEqual(t, inputBytes, stdout.Bytes())
}
func TestRunProcessReportJSONSuccess(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(), "corrected.json")
reportPath := filepath.Join(t.TempDir(), "report.json")
exitCode := Run([]string{
"process",
transcriptPath,
"--glossary",
glossaryPath,
"--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 when --output is used, got %q", stdout.String())
}
report := readProcessReport(t, reportPath)
if report.Status != "success" {
t.Fatalf("expected success report status, got %q", report.Status)
}
if report.Operation != "process" {
t.Fatalf("expected operation process, got %q", report.Operation)
}
if report.TranscriptPath != transcriptPath {
t.Fatalf("unexpected transcript path in report: %q", report.TranscriptPath)
}
if report.GlossaryPath != glossaryPath {
t.Fatalf("unexpected glossary path in report: %q", report.GlossaryPath)
}
if report.OutputPath != outputPath {
t.Fatalf("unexpected output path in report: %q", report.OutputPath)
}
if len(report.Modules) == 0 {
t.Fatalf("expected configured module sequence in report")
}
if report.StartedAt == "" || report.CompletedAt == "" {
t.Fatalf("expected started_at and completed_at timestamps in report")
}
if report.ErrorMessage != "" {
t.Fatalf("did not expect error message in success report, got %q", report.ErrorMessage)
}
}
func TestRunProcessReportJSONNoStdoutPollution(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
transcriptPath := fixturePath("tiny_transcript.json")
glossaryPath := fixturePath("tiny_glossary.yaml")
reportPath := filepath.Join(t.TempDir(), "report.json")
exitCode := Run([]string{
"process",
transcriptPath,
"--glossary",
glossaryPath,
"--report-json",
reportPath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
}
assertJSONSemanticallyEqual(t, readFile(t, transcriptPath), stdout.Bytes())
reportBytes := readFile(t, reportPath)
if bytes.Contains(stdout.Bytes(), reportBytes) {
t.Fatalf("stdout was polluted with report JSON")
}
}
func TestRunProcessReportJSONRedactsSecrets(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
secretPrimary := "top-secret-primary-key"
secretValidation := "top-secret-validation-key"
t.Setenv("AUDITA_LLM_API_KEY", secretPrimary)
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secretValidation)
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), secretPrimary) || strings.Contains(string(reportBytes), secretValidation) {
t.Fatalf("report JSON leaked API key material: %q", string(reportBytes))
}
}
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 for malformed transcript")
}
report := readProcessReport(t, reportPath)
if report.Status != "failed" {
t.Fatalf("expected failed report status, got %q", report.Status)
}
if report.ErrorMessage == "" {
t.Fatalf("expected error message in failed report")
}
if !strings.Contains(report.ErrorMessage, "not valid JSON") {
t.Fatalf("expected malformed JSON failure in report error, got %q", report.ErrorMessage)
}
}
func TestRunProcessCLIOverridesEnvironment(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
t.Setenv("AUDITA_MODEL", "env-model")
t.Setenv("AUDITA_VALIDATION_LLM_CONCURRENCY", "2")
transcriptPath := fixturePath("tiny_transcript.json")
glossaryPath := fixturePath("tiny_glossary.yaml")
var captured processInvocation
originalRunner := processRunner
processRunner = func(inv processInvocation, output io.Writer) error {
_ = output
captured = inv
return nil
}
t.Cleanup(func() {
processRunner = originalRunner
})
exitCode := Run([]string{
"process",
transcriptPath,
"--glossary",
glossaryPath,
"--model",
"cli-model",
"--validation-llm-concurrency",
"5",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if captured.Config.PrimaryLLM.Model != "cli-model" {
t.Fatalf("expected CLI model override, got %q", captured.Config.PrimaryLLM.Model)
}
if captured.Config.ValidationLLM.Concurrency == nil || *captured.Config.ValidationLLM.Concurrency != 5 {
t.Fatalf("expected CLI validation concurrency override, got %#v", captured.Config.ValidationLLM.Concurrency)
}
if captured.GlossaryPath != glossaryPath {
t.Fatalf("unexpected glossary path: %q", captured.GlossaryPath)
}
}
func fixturePath(name string) string {
return filepath.Join("testdata", name)
}
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 assertJSONSemanticallyEqual(t *testing.T, expected []byte, actual []byte) {
t.Helper()
if !json.Valid(actual) {
t.Fatalf("actual output is not valid JSON: %q", string(actual))
}
var expectedValue any
var actualValue any
if err := json.Unmarshal(expected, &expectedValue); err != nil {
t.Fatalf("failed to unmarshal expected JSON: %v", err)
}
if err := json.Unmarshal(actual, &actualValue); err != nil {
t.Fatalf("failed to unmarshal actual JSON: %v", err)
}
if !reflect.DeepEqual(expectedValue, actualValue) {
t.Fatalf("JSON content mismatch: expected %q got %q", string(expected), string(actual))
}
}
type minimalProcessReport struct {
Status string `json:"status"`
Operation string `json:"operation"`
TranscriptPath string `json:"transcript_path"`
GlossaryPath string `json:"glossary_path"`
OutputPath string `json:"output_path"`
Modules []string `json:"modules"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
ErrorMessage string `json:"error_message"`
}
func readProcessReport(t *testing.T, path string) minimalProcessReport {
t.Helper()
raw := readFile(t, path)
var report minimalProcessReport
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatalf("failed to unmarshal process report JSON: %v (raw: %q)", err, string(raw))
}
return report
}