package daemon import ( "context" "errors" "io" "log/slog" "strings" "testing" ) // TestRunStartSteps_RollsBackInReverseOnFailure pins the driver // contract at the heart of commit 1's refactor: on a step failure // (a) every step that succeeded BEFORE the failing one gets its // undo fired in reverse order; (b) the failing step's undo also // fires, because steps may acquire partial state before returning // err; (c) the final error wraps both the run error and any // rollback errors via errors.Join. func TestRunStartSteps_RollsBackInReverseOnFailure(t *testing.T) { s := &VMService{} op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} sc := &startContext{} var events []string record := func(label string) func(context.Context, *startContext) error { return func(context.Context, *startContext) error { events = append(events, label) return nil } } recordErr := func(label string, err error) func(context.Context, *startContext) error { return func(context.Context, *startContext) error { events = append(events, label) return err } } steps := []startStep{ {name: "first", run: record("run-first"), undo: record("undo-first")}, {name: "second", run: record("run-second"), undo: record("undo-second")}, {name: "third", run: recordErr("run-third", errors.New("boom")), undo: record("undo-third")}, {name: "fourth", run: record("run-fourth"), undo: record("undo-fourth")}, } err := s.runStartSteps(context.Background(), op, sc, steps) if err == nil || !strings.Contains(err.Error(), "boom") { t.Fatalf("runStartSteps err = %v, want containing 'boom'", err) } want := []string{ // Forward run: first, second, third (fails — fourth never runs). "run-first", "run-second", "run-third", // Reverse undo: third, second, first. Fourth never ran so no undo-fourth. "undo-third", "undo-second", "undo-first", } if len(events) != len(want) { t.Fatalf("events length = %d, want %d:\n got: %v\n want: %v", len(events), len(want), events, want) } for i := range want { if events[i] != want[i] { t.Fatalf("events[%d] = %q, want %q\n got: %v\n want: %v", i, events[i], want[i], events, want) } } } // TestRunStartSteps_SkipsNilUndos proves the optional-undo contract: // steps without teardown obligations leave `undo` nil and the driver // must silently skip them during rollback rather than panicking. func TestRunStartSteps_SkipsNilUndos(t *testing.T) { s := &VMService{} op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} sc := &startContext{} var undoCalls []string undo := func(label string) func(context.Context, *startContext) error { return func(context.Context, *startContext) error { undoCalls = append(undoCalls, label) return nil } } noop := func(context.Context, *startContext) error { return nil } steps := []startStep{ {name: "has-undo", run: noop, undo: undo("has-undo")}, {name: "no-undo", run: noop}, // undo nil intentionally {name: "failing", run: func(context.Context, *startContext) error { return errors.New("x") }, undo: undo("failing")}, } if err := s.runStartSteps(context.Background(), op, sc, steps); err == nil { t.Fatal("runStartSteps err = nil, want failure") } // Rollback order: failing (acquired state, so its undo runs), no-undo // (skipped — nil), has-undo. want := []string{"failing", "has-undo"} if len(undoCalls) != len(want) || undoCalls[0] != want[0] || undoCalls[1] != want[1] { t.Fatalf("undo calls = %v, want %v", undoCalls, want) } } // TestRunStartSteps_JoinsRollbackErrors asserts that undo errors are // joined onto the original run error rather than hiding it — the // caller must always see the root cause ("boom") even when the // rollback path itself is messy. func TestRunStartSteps_JoinsRollbackErrors(t *testing.T) { s := &VMService{} op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} sc := &startContext{} rootErr := errors.New("boom") undoErr := errors.New("undo-fail") steps := []startStep{ { name: "ok", run: func(context.Context, *startContext) error { return nil }, undo: func(context.Context, *startContext) error { return undoErr }, }, { name: "fail", run: func(context.Context, *startContext) error { return rootErr }, }, } err := s.runStartSteps(context.Background(), op, sc, steps) if err == nil { t.Fatal("err = nil, want joined error") } if !errors.Is(err, rootErr) { t.Fatalf("err does not wrap rootErr; got: %v", err) } if !errors.Is(err, undoErr) { t.Fatalf("err does not wrap undoErr; got: %v", err) } } // TestRunStartSteps_HappyPathNoRollback confirms that when every // step's run returns nil, no undo fires — rollback is strictly a // failure-path concern. func TestRunStartSteps_HappyPathNoRollback(t *testing.T) { s := &VMService{} op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} sc := &startContext{} var undoCalled bool steps := []startStep{ { name: "a", run: func(context.Context, *startContext) error { return nil }, undo: func(context.Context, *startContext) error { undoCalled = true; return nil }, }, { name: "b", run: func(context.Context, *startContext) error { return nil }, }, } if err := s.runStartSteps(context.Background(), op, sc, steps); err != nil { t.Fatalf("runStartSteps err = %v, want nil", err) } if undoCalled { t.Fatal("undo fired on happy path — rollback must only run on failure") } }