diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index d75ceb1f..13b14b51 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -265,7 +265,7 @@ func (h *Handler) pausePlannedPurchase(ctx context.Context, req *events.LambdaFu } // Atomically transition to paused - if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "running"}, "paused", resolveCreatorUserID(session)); err != nil { + if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending"}, "paused", resolveCreatorUserID(session)); err != nil { return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be paused: %v", executionID, err)) } diff --git a/internal/api/handler_purchases_pause_claim_integration_test.go b/internal/api/handler_purchases_pause_claim_integration_test.go new file mode 100644 index 00000000..a1c7844f --- /dev/null +++ b/internal/api/handler_purchases_pause_claim_integration_test.go @@ -0,0 +1,274 @@ +//go:build integration + +package api + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/provider" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/LeanerCloud/cloud-commitments-platform/internal/purchase" + "github.com/aws/aws-lambda-go/events" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type pauseClaimService struct { + fanoutServiceClient + calls atomic.Int32 + entered, release chan struct{} + once sync.Once +} + +func (s *pauseClaimService) unblock() { s.once.Do(func() { close(s.release) }) } + +func (s *pauseClaimService) PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) { + if s.calls.Add(1) == 1 { + close(s.entered) + select { + case <-s.release: + case <-ctx.Done(): + return common.PurchaseResult{}, ctx.Err() + } + } + return s.fanoutServiceClient.PurchaseCommitment(ctx, rec, opts) +} + +type pauseClaimProvider struct { + fanoutProvider + service *pauseClaimService +} + +func (p *pauseClaimProvider) GetServiceClient(context.Context, common.ServiceType, string) (provider.ServiceClient, error) { + return p.service, nil +} + +type pauseClaimFactory struct{ provider *pauseClaimProvider } + +func (f pauseClaimFactory) CreateAndValidateProvider(context.Context, string, *provider.ProviderConfig) (provider.Provider, error) { + return f.provider, nil +} + +type pauseClaimFixture struct { + *createConcurrencyFixture + ctx context.Context + service *pauseClaimService + manager *purchase.Manager + execution *config.PurchaseExecution + request *events.LambdaFunctionURLRequest + body string + workers sync.WaitGroup +} + +func newPauseClaimFixture(t *testing.T) *pauseClaimFixture { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + t.Cleanup(cancel) + f := &pauseClaimFixture{createConcurrencyFixture: newCreateConcurrencyFixture(ctx, t), ctx: ctx} + plan, err := f.store.GetPurchasePlan(ctx, f.planID) + require.NoError(t, err) + plan.AutoPurchase = true + require.NoError(t, f.store.UpdatePurchasePlan(ctx, plan)) + accounts, err := f.store.GetPlanAccounts(ctx, plan.ID) + require.NoError(t, err) + require.Len(t, accounts, 1) + account := accounts[0] + account.Enabled = true + account.AWSAuthMode = "access_keys" + require.NoError(t, f.store.UpdateCloudAccount(ctx, &account)) + var creator string + require.NoError(t, f.pool.QueryRow(ctx, "SELECT id::text FROM users WHERE email='creator@example.com'").Scan(&creator)) + plan, err = f.store.GetPurchasePlan(ctx, plan.ID) + require.NoError(t, err) + require.True(t, plan.AutoPurchase) + require.Equal(t, 2, plan.RampSchedule.CurrentStep) + persistedAccount, err := f.store.GetCloudAccount(ctx, account.ID) + require.NoError(t, err) + require.True(t, persistedAccount.Enabled) + require.Equal(t, "access_keys", persistedAccount.AWSAuthMode) + f.service = &pauseClaimService{entered: make(chan struct{}), release: make(chan struct{})} + f.manager = purchase.NewManager(purchase.ManagerConfig{ + ConfigStore: f.store, EmailSender: &stubEmailNotifier{}, CredentialStore: &fanoutCredStore{}, + ProviderFactory: pauseClaimFactory{provider: &pauseClaimProvider{service: f.service}}, + }) + f.execution = &config.PurchaseExecution{ + ExecutionID: uuid.NewString(), PlanID: plan.ID, CloudAccountID: &account.ID, + Status: "pending", StepNumber: 3, ScheduledDate: time.Now(), IdempotencyKey: uuid.NewString(), + CreatedByUserID: &creator, Recommendations: fanoutRecs(), + } + require.NoError(t, f.store.SavePurchaseExecution(ctx, f.execution)) + row, err := f.store.GetExecutionByID(ctx, f.execution.ExecutionID) + require.NoError(t, err) + require.Equal(t, "pending", row.Status) + require.Equal(t, 3, row.StepNumber) + require.Equal(t, f.execution.CloudAccountID, row.CloudAccountID) + require.NotEqual(t, common.PurchaseSourceWeb, row.Source) + f.request = &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token"}} + body, err := json.Marshal(purchase.AsyncMessage{Type: purchase.MessageTypeExecutePurchase, ExecutionID: row.ExecutionID}) + require.NoError(t, err) + f.body = string(body) + t.Cleanup(func() { f.service.unblock(); cancel(); f.workers.Wait() }) + return f +} + +func (f *pauseClaimFixture) startWorker() <-chan error { + done := make(chan error, 1) + f.workers.Add(1) + go func() { defer f.workers.Done(); done <- f.manager.ProcessMessage(f.ctx, f.body) }() + return done +} + +func (f *pauseClaimFixture) waitWorker(t *testing.T, done <-chan error) { + t.Helper() + select { + case err := <-done: + require.NoError(t, err) + case <-f.ctx.Done(): + t.Fatal("purchase worker did not finish") + } +} + +func (f *pauseClaimFixture) pause() error { + _, err := f.handler.pausePlannedPurchase(f.ctx, f.request, f.execution.ExecutionID) + return err +} + +func (f *pauseClaimFixture) resume() error { + _, err := f.handler.resumePlannedPurchase(f.ctx, f.request, f.execution.ExecutionID) + return err +} + +type pauseClaimState struct { + Status string + Actor *string + TransitionedAt *time.Time +} + +func (f *pauseClaimFixture) state(t *testing.T) pauseClaimState { + t.Helper() + var state pauseClaimState + require.NoError(t, f.pool.QueryRow(f.ctx, + "SELECT status, transitioned_by::text, transitioned_at FROM purchase_executions WHERE execution_id=$1", + f.execution.ExecutionID).Scan(&state.Status, &state.Actor, &state.TransitionedAt)) + return state +} + +func (f *pauseClaimFixture) assertCompleted(t *testing.T) { + t.Helper() + row, err := f.store.GetExecutionByID(f.ctx, f.execution.ExecutionID) + require.NoError(t, err) + assert.Equal(t, "completed", row.Status) + assert.NotNil(t, row.CompletedAt) + assert.EqualValues(t, 1, f.service.calls.Load(), "only one worker may enter the provider") + var histories int + require.NoError(t, f.pool.QueryRow(f.ctx, + "SELECT count(*) FROM purchase_history WHERE plan_id=$1 AND purchase_id=$2", + f.planID, "ri-"+common.DeriveIdempotencyToken(f.execution.IdempotencyKey, 0)).Scan(&histories)) + assert.Equal(t, 1, histories) + plan, err := f.store.GetPurchasePlan(f.ctx, f.planID) + require.NoError(t, err) + assert.Equal(t, 3, plan.RampSchedule.CurrentStep) +} + +func assertPauseConflict(t *testing.T, err error) { + t.Helper() + ce, ok := IsClientError(err) + if assert.True(t, ok, "expected conflict, got %v", err) { + assert.Equal(t, 409, ce.code) + } +} + +func TestPauseClaim_RunningWorkerCannotBeReclaimed(t *testing.T) { + f := newPauseClaimFixture(t) + first := f.startWorker() + select { + case <-f.service.entered: + case <-f.ctx.Done(): + t.Fatal("first worker did not reach the provider") + } + before := f.state(t) + require.Equal(t, "running", before.Status) + require.Nil(t, before.Actor) + require.NoError(t, f.manager.ProcessMessage(f.ctx, f.body)) + require.EqualValues(t, 1, f.service.calls.Load()) + pauseErr, resumeErr := f.pause(), f.resume() + // Reach the purchase counter even when the old handler incorrectly accepts both actions. + require.NoError(t, f.manager.ProcessMessage(f.ctx, f.body)) + assert.EqualValues(t, 1, f.service.calls.Load(), "pause/resume must not allow a second provider entry") + assertPauseConflict(t, pauseErr) + assertPauseConflict(t, resumeErr) + assert.Equal(t, before, f.state(t), "failed transitions must preserve worker ownership and attribution") + f.service.unblock() + f.waitWorker(t, first) + f.assertCompleted(t) +} + +func TestPauseClaim_PausedQueueResumesExactlyOnce(t *testing.T) { + f := newPauseClaimFixture(t) + require.NoError(t, f.pause()) + paused := f.state(t) + require.Equal(t, "paused", paused.Status) + require.Equal(t, f.execution.CreatedByUserID, paused.Actor) + require.NotNil(t, paused.TransitionedAt) + require.NoError(t, f.manager.ProcessMessage(f.ctx, f.body)) + require.Zero(t, f.service.calls.Load()) + require.Equal(t, paused, f.state(t)) + require.NoError(t, f.resume()) + require.Equal(t, "pending", f.state(t).Status) + f.service.unblock() + require.NoError(t, f.manager.ProcessMessage(f.ctx, f.body)) + require.NoError(t, f.manager.ProcessMessage(f.ctx, f.body)) + f.assertCompleted(t) +} + +func TestPauseClaim_RejectsNonPendingStates(t *testing.T) { + f := newPauseClaimFixture(t) + for _, status := range []string{"running", "completed", "canceled", "failed", "approved", "notified", "scheduled", "paused"} { + t.Run(status, func(t *testing.T) { + f.execution.Status = status + require.NoError(t, f.store.SavePurchaseExecution(f.ctx, f.execution)) + before := f.state(t) + assertPauseConflict(t, f.pause()) + assert.Equal(t, before, f.state(t)) + }) + } + assert.Zero(t, f.service.calls.Load()) +} + +func TestPauseClaim_ConcurrentPauseAndWorker(t *testing.T) { + f := newPauseClaimFixture(t) + start := make(chan struct{}) + paused := make(chan error, 1) + worker := make(chan error, 1) + f.workers.Add(2) + go func() { defer f.workers.Done(); <-start; paused <- f.pause() }() + go func() { defer f.workers.Done(); <-start; worker <- f.manager.ProcessMessage(f.ctx, f.body) }() + close(start) + select { + case err := <-paused: + if err == nil { + f.waitWorker(t, worker) + require.Zero(t, f.service.calls.Load()) + require.Equal(t, "paused", f.state(t).Status) + require.NoError(t, f.resume()) + f.service.unblock() + require.NoError(t, f.manager.ProcessMessage(f.ctx, f.body)) + } else { + assertPauseConflict(t, err) + require.Equal(t, "running", f.state(t).Status) + require.NoError(t, f.manager.ProcessMessage(f.ctx, f.body)) + f.service.unblock() + f.waitWorker(t, worker) + } + case <-f.ctx.Done(): + t.Fatal("pause did not finish") + } + f.assertCompleted(t) +} diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 0595599e..62735e63 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -1499,7 +1499,7 @@ func TestHandler_pausePlannedPurchase(t *testing.T) { paused := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "paused"} mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "running"}, "paused", mock.Anything).Return(paused, nil) + mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending"}, "paused", mock.Anything).Return(paused, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1526,7 +1526,7 @@ func TestHandler_pausePlannedPurchase_NotFound(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "running"}, "paused", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) + mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending"}, "paused", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -2084,7 +2084,7 @@ func TestHandler_pausePlannedPurchase_NilExecution(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdmin() - mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending", "running"}, "paused", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) + mockStore.On("TransitionExecutionStatus", ctx, "99999999-9999-9999-9999-999999999999", []string{"pending"}, "paused", mock.Anything).Return(nil, fmt.Errorf("execution not found: 99999999-9999-9999-9999-999999999999")) handler := &Handler{config: mockStore, auth: mockAuth} @@ -2104,41 +2104,43 @@ func TestHandler_pausePlannedPurchase_NilExecution(t *testing.T) { // Postgres CHECK constraint error (SQLSTATE 23514). This is the regression test // for issue #772. func TestHandler_pausePlannedPurchase_IneligibleStatus(t *testing.T) { - ctx := context.Background() - mockStore := new(MockConfigStore) - mockAuth := new(MockAuthService) - t.Cleanup(func() { - mockStore.AssertExpectations(t) - mockAuth.AssertExpectations(t) - }) + for _, status := range []string{"running", "completed", "canceled", "approved", "notified", "scheduled"} { + t.Run(status, func(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + t.Cleanup(func() { + mockStore.AssertExpectations(t) + mockAuth.AssertExpectations(t) + }) + + adminSession := &Session{ + UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Email: "admin@example.com", + } - adminSession := &Session{ - UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - Email: "admin@example.com", - } + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() + mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending"}, "paused", mock.Anything). + Return(nil, fmt.Errorf("%w: execution 11111111-1111-1111-1111-111111111111 cannot transition from %q to %q", + config.ErrExecutionNotInExpectedStatus, status, "paused")) - mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) - mockAuth.grantAdmin() - // Store returns ErrExecutionNotInExpectedStatus when the row is 'completed' - // and cannot be transitioned to 'paused'. - mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", []string{"pending", "running"}, "paused", mock.Anything). - Return(nil, fmt.Errorf("%w: execution 11111111-1111-1111-1111-111111111111 cannot transition from %q to %q", - config.ErrExecutionNotInExpectedStatus, "completed", "paused")) + handler := &Handler{config: mockStore, auth: mockAuth} - handler := &Handler{config: mockStore, auth: mockAuth} + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer admin-token"}, + } + result, err := handler.pausePlannedPurchase(ctx, req, "11111111-1111-1111-1111-111111111111") + require.Error(t, err, "pausing a %s execution must fail", status) + assert.Nil(t, result) - req := &events.LambdaFunctionURLRequest{ - Headers: map[string]string{"Authorization": "Bearer admin-token"}, + // Must be a 409 client error, not a 500. + ce, ok := IsClientError(err) + require.True(t, ok, "expected ClientError, got %T: %v", err, err) + assert.Equal(t, 409, ce.code, "ineligible-status pause must return 409") + assert.Contains(t, ce.message, "cannot be paused", "error message must name the action") + }) } - result, err := handler.pausePlannedPurchase(ctx, req, "11111111-1111-1111-1111-111111111111") - require.Error(t, err, "pausing a completed execution must fail") - assert.Nil(t, result) - - // Must be a 409 client error, not a 500. - ce, ok := IsClientError(err) - require.True(t, ok, "expected ClientError, got %T: %v", err, err) - assert.Equal(t, 409, ce.code, "ineligible-status pause must return 409") - assert.Contains(t, ce.message, "cannot be paused", "error message must name the action") } func TestHandler_resumePlannedPurchase_NilExecution(t *testing.T) { @@ -2240,7 +2242,7 @@ func TestHandler_pausePlannedPurchase_ActorStamped(t *testing.T) { paused := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "paused"} // Actor must equal the session UserID (pointer value comparison via reflect.DeepEqual). mockStore.On("TransitionExecutionStatus", ctx, "11111111-1111-1111-1111-111111111111", - []string{"pending", "running"}, "paused", + []string{"pending"}, "paused", mock.MatchedBy(func(a *string) bool { return a != nil && *a == actorID }), ).Return(paused, nil) @@ -5232,20 +5234,20 @@ func TestHandler_runPlannedPurchase_NonOwner_Rejected(t *testing.T) { // TestHandler_pausePlannedPurchase_Owner_Allowed: user A manages their OWN P1. func TestHandler_pausePlannedPurchase_Owner_Allowed(t *testing.T) { handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserA, false) - mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "running"}, "paused", mock.Anything). + mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "paused"}, nil) res, err := handler.pausePlannedPurchase(context.Background(), manageReq(), ownExecID) require.NoError(t, err) assert.Equal(t, "paused", res.Status) - mockConfig.AssertCalled(t, "TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "running"}, "paused", mock.Anything) + mockConfig.AssertCalled(t, "TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending"}, "paused", mock.Anything) } // TestHandler_pausePlannedPurchase_UpdateAny_AllowsAny: a privileged user with // update-any:purchases manages P2 created by user B. func TestHandler_pausePlannedPurchase_UpdateAny_AllowsAny(t *testing.T) { handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, true) - mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "running"}, "paused", mock.Anything). + mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "paused"}, nil) res, err := handler.pausePlannedPurchase(context.Background(), manageReq(), ownExecID) diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index b79b32e5..5ca59578 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -1188,7 +1188,7 @@ func TestHandler_HandleRequest_PausePlannedPurchase(t *testing.T) { mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) paused := &config.PurchaseExecution{ExecutionID: "11111111-1111-1111-1111-111111111111", Status: "paused"} - mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"pending", "running"}, "paused", mock.Anything).Return(paused, nil) + mockStore.On("TransitionExecutionStatus", mock.Anything, "11111111-1111-1111-1111-111111111111", []string{"pending"}, "paused", mock.Anything).Return(paused, nil) handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 7ebb1567..9d97c87c 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -576,6 +576,7 @@ paths: operationId: pausePlannedPurchase tags: [PlannedPurchases] summary: Pause a planned purchase + description: Only pending executions can be paused. parameters: - $ref: '#/components/parameters/CSRFToken' responses: @@ -585,6 +586,12 @@ paths: application/json: schema: $ref: '#/components/schemas/StatusResponse' + '409': + description: The execution is not pending and cannot be paused. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': diff --git a/internal/api/router_660_permission_flips_test.go b/internal/api/router_660_permission_flips_test.go index 8cc4f32e..0971e058 100644 --- a/internal/api/router_660_permission_flips_test.go +++ b/internal/api/router_660_permission_flips_test.go @@ -262,7 +262,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &creator}, nil) // TransitionExecutionStatus is called next; stub it. - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "running"}, "paused", mock.Anything). + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "paused"}, nil) h := &Handler{auth: mockAuth, config: mockStore} @@ -300,7 +300,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { // authorizeExecutionManagement; only requireExecutionAccess fetches. mockStore.On("GetExecutionByID", ctx, execID). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending"}, nil) - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "running"}, "paused", mock.Anything). + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "paused"}, nil) h := &Handler{auth: mockAuth, config: mockStore} @@ -321,7 +321,7 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { // immediately without calling GetAllowedAccountsAPI or GetExecutionByID). mockAuth := authForAdmin(ctx, t) mockStore := new(MockConfigStore) - mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "running"}, "paused", mock.Anything). + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending"}, "paused", mock.Anything). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "paused"}, nil) h := &Handler{auth: mockAuth, config: mockStore}