package app import ( "context" "errors" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) type terminalSessionStore struct { events []string saveErr error saved *manifest.Manifest } type successfulStageSaveFailingStore struct { delegate manifest.Store stageName string dependent string failed bool } func (s *successfulStageSaveFailingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) { return s.delegate.Create(ctx, sessionID) } func (s *successfulStageSaveFailingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) { return s.delegate.Load(ctx, path) } func (s *successfulStageSaveFailingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error { record := m.Stages[s.stageName] if !s.failed && record != nil && record.Status == manifest.StatusSucceeded { s.failed = true m.MarkStageSucceeded(s.dependent, time.Now().UTC(), nil) return errors.New("injected successful-stage save failure") } return s.delegate.Save(ctx, path, m) } func (s *terminalSessionStore) Create(_ context.Context, sessionID string) (*manifest.Manifest, error) { return manifest.New(sessionID, time.Now().UTC()), nil } func (s *terminalSessionStore) Load(context.Context, string) (*manifest.Manifest, error) { return nil, errors.New("unexpected manifest load") } func (s *terminalSessionStore) Save(_ context.Context, _ string, m *manifest.Manifest) error { s.events = append(s.events, "session") s.saved = m return s.saveErr } func TestPersistSessionTerminalFailurePreservesCauseAndSessionAuthority(t *testing.T) { cause := errors.New("stage failed") sessionFailure := errors.New("session unavailable") for _, tc := range []struct { name string sessionErr error cause error alreadyTerminal bool }{ {name: "session saved", cause: cause}, {name: "session save fails", sessionErr: sessionFailure, cause: cause}, {name: "cancellation", cause: context.Canceled}, {name: "already terminal session", cause: cause, alreadyTerminal: true}, } { t.Run(tc.name, func(t *testing.T) { sessionStore := &terminalSessionStore{saveErr: tc.sessionErr} sessionManifest := manifest.New("2026-05-03", time.Now().UTC()) if tc.alreadyTerminal { sessionManifest.RecordFailure(time.Now().UTC(), "earlier failure") } err := persistSessionTerminalFailure( context.Background(), sessionStore, "session.json", sessionManifest, tc.cause, time.Now().UTC(), ) if !errors.Is(err, tc.cause) { t.Fatalf("error = %v, want original cause %v", err, tc.cause) } if tc.sessionErr != nil && !errors.Is(err, tc.sessionErr) { t.Fatalf("error = %v, want session failure %v", err, tc.sessionErr) } if got := sessionStore.events; len(got) != 1 || got[0] != "session" { t.Fatalf("save calls = %v, want [session]", got) } if sessionStore.saved == nil || sessionStore.saved.LastError == nil || sessionStore.saved.LastError.Message != tc.cause.Error() { t.Fatalf("session terminal error = %#v, want %q", sessionStore.saved, tc.cause) } }) } } func TestExecuteStagesTerminalizesCancelledStage(t *testing.T) { cfg := testConfig(t) _, err := executeStages(context.Background(), cfg, []stage.Stage{ failingStage{name: "prepare", err: context.Canceled}, }, RunOptions{}) if !errors.Is(err, context.Canceled) { t.Fatalf("executeStages() error = %v, want context cancellation", err) } store := &manifest.LocalStore{} sessionManifest, loadErr := store.Load(context.Background(), manifestPathFor(cfg)) if loadErr != nil { t.Fatalf("load session manifest: %v", loadErr) } if sessionManifest.Stages["prepare"] == nil || sessionManifest.Stages["prepare"].Status != manifest.StatusFailed || sessionManifest.LastError == nil { t.Fatalf("session terminal record = %#v, want failed prepare stage and last error", sessionManifest) } } func TestExecuteStagesResumeValidationFailureRecordsSessionFailure(t *testing.T) { cfg := testConfig(t) store := &manifest.LocalStore{} seed := manifest.New(cfg.Session.SessionID, time.Now().UTC()) seed.MarkStageSucceeded("checked", time.Now().UTC(), nil) if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil { t.Fatalf("save session manifest: %v", err) } _, err := executeStages(context.Background(), cfg, []stage.Stage{ resumeCheckingStage{name: "checked", validateErr: errors.New("inspection unavailable"), runs: new(int)}, }, RunOptions{}) if err == nil || !strings.Contains(err.Error(), "inspection unavailable") { t.Fatalf("executeStages() error = %v, want resume validation failure", err) } persisted, loadErr := store.Load(context.Background(), manifestPathFor(cfg)) if loadErr != nil { t.Fatalf("load session manifest: %v", loadErr) } if persisted.Stages["checked"].Status != manifest.StatusSucceeded || persisted.LastError == nil { t.Fatalf("session manifest = %#v, want preserved success with terminal error", persisted) } } func TestExecuteStagesSuccessfulSaveFailureMarksStageFailedAndInvalidatesDependents(t *testing.T) { cfg := testConfig(t) store := &successfulStageSaveFailingStore{ delegate: &manifest.LocalStore{}, stageName: "polish", dependent: "normalize", } _, err := executeStages(context.Background(), cfg, []stage.Stage{ countingStage{name: "polish", runs: new(int)}, }, RunOptions{Env: &Env{ManifestStore: store}}) if err == nil || !strings.Contains(err.Error(), "save manifest after stage \"polish\"") { t.Fatalf("executeStages() error = %v, want successful-stage save failure", err) } persisted, loadErr := store.delegate.Load(context.Background(), manifestPathFor(cfg)) if loadErr != nil { t.Fatalf("load session manifest: %v", loadErr) } if got := persisted.Stages["polish"]; got == nil || got.Status != manifest.StatusFailed { t.Fatalf("polish stage = %#v, want failed", got) } if got := persisted.Stages["normalize"]; got == nil || got.Status != manifest.StatusStale { t.Fatalf("normalize stage = %#v, want stale", got) } }