package app import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestExecuteValidCommands(t *testing.T) { workspaceRoot := t.TempDir() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"source":"commands-test","segments":[{"speaker":"alice"}]}`)) })) defer srv.Close() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) manifestPath := writeManifestPathForExecute(t) cases := []struct { name string args []string wantOut string }{ {name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=8 skipped=0; manifest="}, {name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, {name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"}, {name: "resume", args: []string{"resume", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, {name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(tc.args, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0", code) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } if !strings.Contains(stdout.String(), tc.wantOut) { t.Fatalf("stdout = %q, want to contain %q", stdout.String(), tc.wantOut) } }) } } func TestExecuteMissingRequiredFlags(t *testing.T) { cases := []struct { name string args []string want string }{ {name: "run missing flags", args: []string{"run"}, want: "run: --config and --session are required"}, {name: "plan missing flags", args: []string{"plan"}, want: "plan: --config and --session are required"}, {name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"}, {name: "resume missing flags", args: []string{"resume"}, want: "resume: --config and --session are required"}, {name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"}, {name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --config and --session are required"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(tc.args, &stdout, &stderr) if code == 0 { t.Fatalf("exit code = 0, want non-zero") } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), tc.want) { t.Fatalf("stderr = %q, want to contain %q", stderr.String(), tc.want) } }) } } func TestExecuteRunStageUnknownFails(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "unknown"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "unknown stage") { t.Fatalf("stderr = %q, want unknown stage error", stderr.String()) } } func TestExecuteRunStageNormalizeIsRejected(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "normalize"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "unknown stage") { t.Fatalf("stderr = %q, want unknown stage error", stderr.String()) } } func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) { workspaceRoot := t.TempDir() var serverCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serverCalls++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"source":"run-stage-test","segments":[{"speaker":"alice"}]}`)) })) defer srv.Close() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "prepare"}, &stdout, &stderr) if code != 0 { t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String()) } stdout.Reset() stderr.Reset() code = Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr) if code != 0 { t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String()) } if serverCalls == 0 { t.Fatal("expected whisperx server to be called at least once") } outPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "transcripts", "raw", "alice.json") data, err := os.ReadFile(outPath) if err != nil { t.Fatalf("ReadFile(%q): %v", outPath, err) } got := strings.TrimSpace(string(data)) if got == `{"schema":"speaker_transcript.v1","segments":[]}` { t.Fatalf("got noop transcript output: %q", got) } if !strings.Contains(got, `"source":"run-stage-test"`) { t.Fatalf("output = %q, want run-stage server json", got) } } func TestExecuteInvalidCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"bogus"}, &stdout, &stderr) if code == 0 { t.Fatalf("exit code = 0, want non-zero") } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } out := stderr.String() if !strings.Contains(out, "unknown command") { t.Fatalf("stderr = %q, want unknown command message", out) } if !strings.Contains(out, "Usage: narratio") { t.Fatalf("stderr = %q, want usage message", out) } } func TestExecuteMissingCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(nil, &stdout, &stderr) if code == 0 { t.Fatalf("exit code = 0, want non-zero") } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), "Usage: narratio") { t.Fatalf("stderr = %q, want usage message", stderr.String()) } } func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...string) (string, string) { t.Helper() dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") sessionPath := filepath.Join(dir, "session.yml") url := "https://example.com/transcribe" if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" { url = transcribeURL[0] } seriatimBinary := writeSeriatimAppTestWrapper(t) auditaBinary := writeAuditaAppTestWrapper(t) t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1") t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1") t.Setenv("AUDITA_LLM_API_KEY", "test-audita-key") pipelineYAML := `workspace: root: ` + workspaceRoot + ` storage: backend: s3 whisperx: transcribe_url: ` + url + ` timeout: 2s retries: 0 retry_delay: 1ms concurrency: 1 seriatim: binary: ` + seriatimBinary + ` timeout: 10m output_schema: seriatim-intermediate coalesce_gap: 3.0 report: true audita: binary: ` + auditaBinary + ` analyzer: timeout: 20m artifacts: output_dir: artifacts notification: timeout: 10s ` sessionYAML := `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml ` if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline config: %v", err) } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session config: %v", err) } mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") return pipelinePath, sessionPath } func writeManifestPathForExecute(t *testing.T) string { t.Helper() store := &manifest.LocalStore{} m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) path := filepath.Join(t.TempDir(), "manifest.json") if err := store.Save(context.Background(), path, m); err != nil { t.Fatalf("save manifest: %v", err) } return path } func mustWriteTestFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %q: %v", path, err) } if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatalf("write %q: %v", path, err) } } func writeSeriatimAppTestWrapper(t *testing.T) string { t.Helper() exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } path := filepath.Join(t.TempDir(), "seriatim-helper-wrapper.sh") content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestSeriatimAppHelper -- \"$@\"\n" if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("WriteFile(%q): %v", path, err) } return path } func TestSeriatimAppHelper(t *testing.T) { if os.Getenv("GO_WANT_APP_SERIATIM_HELPER") != "1" { return } args := os.Args start := -1 for i := range args { if args[i] == "--" { start = i + 1 break } } if start < 0 || start >= len(args) { _, _ = os.Stderr.WriteString("missing -- args separator\n") os.Exit(2) } mergeArgs := args[start:] outputPath := appSeriatimFlagValue(mergeArgs, "--output-file") reportPath := appSeriatimFlagValue(mergeArgs, "--report-file") if strings.TrimSpace(outputPath) == "" { _, _ = os.Stderr.WriteString("missing --output-file\n") os.Exit(2) } if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir output dir: %v\n", err)) os.Exit(2) } if err := os.WriteFile(outputPath, []byte(`{"schema":"seriatim-intermediate","segments":[]}`), 0o644); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err)) os.Exit(2) } if strings.TrimSpace(reportPath) != "" { if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir report dir: %v\n", err)) os.Exit(2) } if err := os.WriteFile(reportPath, []byte(`{"report":true}`), 0o644); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("write report: %v\n", err)) os.Exit(2) } } _, _ = os.Stdout.WriteString("seriatim helper stdout\n") _, _ = os.Stderr.WriteString("seriatim helper stderr\n") os.Exit(0) } func writeAuditaAppTestWrapper(t *testing.T) string { t.Helper() exe, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } path := filepath.Join(t.TempDir(), "audita-helper-wrapper.sh") content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestAuditaAppHelper -- \"$@\"\n" if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("WriteFile(%q): %v", path, err) } return path } func TestAuditaAppHelper(t *testing.T) { if os.Getenv("GO_WANT_APP_AUDITA_HELPER") != "1" { return } args := os.Args start := -1 for i := range args { if args[i] == "--" { start = i + 1 break } } if start < 0 || start >= len(args) { _, _ = os.Stderr.WriteString("missing -- args separator\n") os.Exit(2) } processArgs := args[start:] outputPath := appSeriatimFlagValue(processArgs, "--output") reportPath := appSeriatimFlagValue(processArgs, "--report-json") workDir := appSeriatimFlagValue(processArgs, "--work-dir") if strings.TrimSpace(outputPath) == "" { _, _ = os.Stderr.WriteString("missing --output\n") os.Exit(2) } if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir output dir: %v\n", err)) os.Exit(2) } if err := os.WriteFile(outputPath, []byte(`{"schema":"audita.processed.v1","segments":[]}`), 0o644); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err)) os.Exit(2) } if strings.TrimSpace(reportPath) != "" { if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir report dir: %v\n", err)) os.Exit(2) } if err := os.WriteFile(reportPath, []byte(`{"report":true}`), 0o644); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("write report: %v\n", err)) os.Exit(2) } } if strings.TrimSpace(workDir) != "" { if err := os.MkdirAll(workDir, 0o755); err != nil { _, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir work dir: %v\n", err)) os.Exit(2) } } _, _ = os.Stdout.WriteString("audita helper stdout\n") _, _ = os.Stderr.WriteString("audita helper stderr\n") os.Exit(0) } func appSeriatimFlagValue(args []string, name string) string { for i := 0; i < len(args)-1; i++ { if args[i] == name { return args[i+1] } } return "" }