package app import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/config" "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, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) cases := []struct { name string args []string wantOut string }{ {name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, {name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, {name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"}, {name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, {name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, 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 session", args: []string{"run"}, want: "run: session_id is required"}, {name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`}, {name: "status old top-level removed", args: []string{"status"}, want: `unknown command: "status"`}, {name: "resume missing session", args: []string{"resume"}, want: "resume: session_id is required"}, {name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"}, {name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"}, {name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"}, } 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, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "unknown", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &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 TestExecuteRunStageNormalizeIsAccepted(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1}]}`) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "normalize", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "stage=normalize executed=1 skipped=0") { t.Fatalf("stdout = %q, want normalize stage execution", stdout.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, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "prepare", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &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", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &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", "sample-campaign", "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 TestExecuteRunStagePolishLoadsCredentialFromSecretsDir(t *testing.T) { workspaceRoot := t.TempDir() configDir := t.TempDir() sessionID := "2026-05-03" secretsDir := filepath.Join(configDir, "secrets") if err := os.MkdirAll(secretsDir, 0o755); err != nil { t.Fatalf("MkdirAll(%q): %v", secretsDir, err) } if err := os.WriteFile(filepath.Join(secretsDir, "OPENROUTER_API_KEY"), []byte("from-secret-file\n"), 0o600); err != nil { t.Fatalf("write OPENROUTER_API_KEY secret file: %v", err) } seriatimBinary := writeSeriatimAppTestWrapper(t) auditaBinary := writeAuditaAppTestWrapper(t) t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1") t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1") pipelinePath := filepath.Join(configDir, "pipeline.yml") campaignPath := writeAppTestCampaignConfig(t, configDir) sessionPath := filepath.Join(configDir, "session.yml") pipelineYAML := `workspace: root: ` + workspaceRoot + ` storage: backend: local secrets: env_dir: ./secrets whisperx: transcribe_url: https://example.com/transcribe seriatim: binary: ` + seriatimBinary + ` audita: binary: ` + auditaBinary + ` llm_api_key_env: OPENROUTER_API_KEY notification: timeout: 10s ` sessionYAML := `session_id: ` + sessionID + ` campaign: sample-campaign 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.yml: %v", err) } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } originalWD, err := os.Getwd() if err != nil { t.Fatalf("Getwd(): %v", err) } if err := os.Chdir(configDir); err != nil { t.Fatalf("Chdir(%q): %v", configDir, err) } t.Cleanup(func() { _ = os.Chdir(originalWD) }) workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"schema":"seriatim-intermediate","segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run-stage", "polish", sessionID, "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "stage=polish executed=1 skipped=0") { t.Fatalf("stdout = %q, want polish execution", stdout.String()) } } func TestExecuteRunFailsWhenConfiguredSecretsDirMissing(t *testing.T) { workspaceRoot := t.TempDir() configDir := t.TempDir() pipelinePath := filepath.Join(configDir, "pipeline.yml") campaignPath := writeAppTestCampaignConfig(t, configDir) sessionPath := filepath.Join(configDir, "session.yml") pipelineYAML := `workspace: root: ` + workspaceRoot + ` storage: backend: local secrets: env_dir: ./missing-secrets whisperx: transcribe_url: https://example.com/transcribe seriatim: binary: seriatim audita: binary: audita notification: timeout: 10s ` sessionYAML := `session_id: 2026-05-03 campaign: sample-campaign 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.yml: %v", err) } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "read secrets env_dir") { t.Fatalf("stderr = %q, want secrets read-dir error context", stderr.String()) } } func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(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":"default-config-test","segments":[{"speaker":"alice"}]}`)) })) defer srv.Close() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} config.DefaultCampaignConfigSearchPaths = []string{campaignPath} defer func() { config.DefaultPipelineConfigSearchPaths = originalDefaults config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults }() var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run", "2026-05-03", "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") { t.Fatalf("stdout = %q, want successful run output", stdout.String()) } } func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot) missingCampaignPath := filepath.Join(t.TempDir(), "campaign.yml") originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) config.DefaultCampaignConfigSearchPaths = []string{missingCampaignPath} defer func() { config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults }() var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if stdout.Len() != 0 { t.Fatalf("stdout = %q, want empty", stdout.String()) } if !strings.Contains(stderr.String(), "no campaign config path provided and no default campaign config found; searched:") { t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String()) } if !strings.Contains(stderr.String(), "pass --campaign") { t.Fatalf("stderr = %q, want explicit campaign guidance", stderr.String()) } } 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, string) { t.Helper() dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") campaignPath := filepath.Join(dir, "campaign.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 + ` cache: root: ` + filepath.Join(workspaceRoot, "cache") + ` spool: root: ` + filepath.Join(workspaceRoot, "spool") + ` storage: backend: s3 s3: bucket: test-bucket archive: enabled: true upload_run: false 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 + ` notification: timeout: 10s ` sessionYAML := `session_id: 2026-05-03 inputs: audio_dir: ./audio ` campaignYAML := `campaign: sample-campaign inputs: 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(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign 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, campaignPath, sessionPath } func writeAppTestCampaignConfig(t *testing.T, dir string) string { t.Helper() campaignPath := filepath.Join(dir, "campaign.yml") campaignYAML := `campaign: sample-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml ` if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign.yml: %v", err) } return campaignPath } 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 "" }