package app import ( "context" "errors" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer" "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) type failingStage struct { name string err error } func (s failingStage) Name() string { return s.name } func (s failingStage) Declares() stage.IODecl { return stage.IODecl{} } func (s failingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { return nil, s.err } func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { cfg := testConfig(t) summary, err := executeStages(context.Background(), cfg, BuildFullPlan(), RunOptions{}) if err != nil { t.Fatalf("executeStages() error = %v", err) } if len(summary.StageNames) != 8 { t.Fatalf("stage count = %d, want 8", len(summary.StageNames)) } store := &manifest.LocalStore{} m, err := store.Load(context.Background(), summary.ManifestPath) if err != nil { t.Fatalf("Load manifest error = %v", err) } for _, name := range []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"} { sr := m.Stages[name] if sr == nil { t.Fatalf("missing stage record %q", name) } if sr.Status != manifest.StatusSucceeded { t.Fatalf("stage %q status = %q, want %q", name, sr.Status, manifest.StatusSucceeded) } if name == "prepare" { if sr.Metadata == nil || sr.Metadata["prepared"] != true { t.Fatalf("prepare metadata missing prepared=true: %#v", sr.Metadata) } continue } if sr.Metadata == nil || sr.Metadata["placeholder"] != true { t.Fatalf("stage %q missing placeholder metadata", name) } } if len(m.Inputs) == 0 { t.Fatalf("manifest inputs should be recorded by prepare") } if _, err := os.Stat(summary.ManifestPath); err != nil { t.Fatalf("manifest file missing at %q: %v", summary.ManifestPath, err) } } func TestExecuteStagesFailureUpdatesManifest(t *testing.T) { cfg := testConfig(t) stages := []stage.Stage{ BuildFullPlan()[0], failingStage{name: "transcribe", err: errors.New("boom")}, BuildFullPlan()[2], } summary, err := executeStages(context.Background(), cfg, stages, RunOptions{}) if err == nil { t.Fatal("expected error, got nil") } if summary != nil { t.Fatalf("summary = %#v, want nil on failure", summary) } if !strings.Contains(err.Error(), "stage \"transcribe\" failed") { t.Fatalf("error = %q, want stage failure", err.Error()) } manifestPath := manifestPathFor(cfg) store := &manifest.LocalStore{} m, loadErr := store.Load(context.Background(), manifestPath) if loadErr != nil { t.Fatalf("Load manifest error = %v", loadErr) } if got := m.Stages["prepare"]; got == nil || got.Status != manifest.StatusSucceeded { t.Fatalf("prepare status = %#v, want succeeded", got) } if got := m.Stages["transcribe"]; got == nil || got.Status != manifest.StatusFailed { t.Fatalf("transcribe status = %#v, want failed", got) } if got := m.Stages["normalize"]; got != nil { t.Fatalf("normalize should not run, got %#v", got) } } func TestExecuteStagesLoadsExistingManifest(t *testing.T) { cfg := testConfig(t) manifestPath := manifestPathFor(cfg) store := &manifest.LocalStore{} existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC)) existing.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil) if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := store.Save(context.Background(), manifestPath, existing); err != nil { t.Fatalf("Save manifest error = %v", err) } _, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[1]}, RunOptions{}) if err != nil { t.Fatalf("executeStages() error = %v", err) } loaded, err := store.Load(context.Background(), manifestPath) if err != nil { t.Fatalf("Load manifest error = %v", err) } if loaded.Stages["prepare"] == nil || loaded.Stages["prepare"].Status != manifest.StatusSucceeded { t.Fatalf("existing stage prepare should remain succeeded") } if loaded.Stages["transcribe"] == nil || loaded.Stages["transcribe"].Status != manifest.StatusSucceeded { t.Fatalf("transcribe should be succeeded after run") } } func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) { cases := []struct { name string env *Env }{ {name: "transcribe", env: &Env{WhisperX: &whisperx.FakeClient{Err: errors.New("transcribe fail")}}}, {name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}}, {name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}}, {name: "analyze", env: &Env{Analyzer: &analyzer.FakeRunner{Err: errors.New("analyze fail")}}}, {name: "archive", env: &Env{Storage: &storage.FakeBackend{Err: errors.New("archive fail")}}}, {name: "notify", env: &Env{Notifier: ¬ify.FakeSender{Err: errors.New("notify fail")}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := testConfig(t) selected, err := stage.Select(tc.name) if err != nil { t.Fatalf("Select() error = %v", err) } artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) tc.env.Config = cfg tc.env.ArtifactStore = artifactStore tc.env.ManifestStore = &manifest.LocalStore{} _, runErr := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{Env: tc.env}) if runErr == nil { t.Fatal("expected error, got nil") } m, loadErr := tc.env.ManifestStore.Load(context.Background(), manifestPathFor(cfg)) if loadErr != nil { t.Fatalf("load manifest error = %v", loadErr) } sr := m.Stages[tc.name] if sr == nil { t.Fatalf("missing stage record %q", tc.name) } if sr.Status != manifest.StatusFailed { t.Fatalf("status = %q, want %q", sr.Status, manifest.StatusFailed) } }) } } func testConfig(t *testing.T) *config.Config { t.Helper() workspace := t.TempDir() cfgDir := t.TempDir() sessionPath := filepath.Join(cfgDir, "session.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml") mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") mustWriteFile(t, sessionPath, "session_id: 2026-05-03\n") mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio") return &config.Config{ Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, PipelinePath: pipelinePath, SessionPath: sessionPath, Session: &config.SessionConfig{ SessionID: "2026-05-03", Inputs: config.SessionInputsConfig{ AudioDir: "./audio", SpeakersFile: "./speakers.yml", AutocorrectFile: "./autocorrect.yml", GlossaryFile: "./glossary.yml", }, }, } } func mustWriteFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("MkdirAll(%q): %v", path, err) } if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatalf("WriteFile(%q): %v", path, err) } }