Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,11 @@ describe('Plans Module', () => {
const container = document.getElementById('planned-purchases-list');
expect(container?.innerHTML).toContain('data-action="resume"');
expect(container?.innerHTML).toContain('data-action="run"');
// Paused rows stay visible with a Paused badge and are NOT replaced by
// the empty-state message.
expect(container?.innerHTML).toContain('status-paused');
expect(container?.innerHTML).toContain('>paused<');
expect(container?.innerHTML).not.toContain('No planned purchases');
});

test('renders running purchase without pause/resume buttons', async () => {
Expand Down Expand Up @@ -800,6 +805,9 @@ describe('Plans Module', () => {
await new Promise(resolve => setTimeout(resolve, 50));

expect(api.pausePlannedPurchase).toHaveBeenCalledWith('purchase-1');
// Pause must give visible feedback via a success toast.
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Purchase paused', kind: 'success' }));
});

test('disable action deletes planned purchase with confirmation', async () => {
Expand Down Expand Up @@ -932,6 +940,9 @@ describe('Plans Module', () => {
await new Promise(resolve => setTimeout(resolve, 50));

expect(api.resumePlannedPurchase).toHaveBeenCalledWith('purchase-1');
// Resume must give visible feedback via a success toast.
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Purchase resumed', kind: 'success' }));
});
});

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,15 @@ async function handlePlannedPurchaseAction(action: string, purchaseId: string, p
}
break;
case 'pause':
// Pause is reversible and scoped to a single execution: the plan stays
// enabled (unlike Disable plan) and the row stays listed with a Paused
// badge (unlike the old silent-removal behaviour).
await api.pausePlannedPurchase(purchaseId);
showToast({ message: 'Purchase paused', kind: 'success', timeout: 5_000 });
break;
case 'resume':
await api.resumePlannedPurchase(purchaseId);
showToast({ message: 'Purchase resumed', kind: 'success', timeout: 5_000 });
break;
case 'edit':
// Open edit modal for the parent plan using plan_id, not the purchase id.
Expand Down
4 changes: 4 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ func (m *mockConfigStore) GetExecutionsByStatuses(ctx context.Context, statuses
return nil, nil
}

func (m *mockConfigStore) GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]config.PurchaseExecution, error) {
return nil, nil
}

func (m *mockConfigStore) GetStaleApprovedExecutions(ctx context.Context, olderThan time.Duration) ([]config.PurchaseExecution, error) {
return nil, nil
}
Expand Down
17 changes: 15 additions & 2 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,28 @@ func buildSuppressions(recs []config.RecommendationRecord, executionID string, c
return out
}

// plannedListStatuses are the execution statuses surfaced in the Scheduled
// (Planned) Purchases list. It deliberately includes "paused" so a paused
// execution stays VISIBLE with its badge instead of silently dropping out of
// the list. It does NOT use GetPendingExecutions, which the
// scheduler relies on to decide what to FIRE: paused rows must be listed but
// never fired, so the two concerns use different status sets.
var plannedListStatuses = []string{"pending", "notified", "paused"}

func (h *Handler) getPlannedPurchases(ctx context.Context, req *events.LambdaFunctionURLRequest) (*PlannedPurchasesResponse, error) {
session, err := h.requirePermission(ctx, req, "view", "purchases")
if err != nil {
return nil, err
}

executions, err := h.config.GetPendingExecutions(ctx)
// GetPlannedExecutions orders scheduled_date ASC at the DB level so the
// soonest-first list isn't truncated when total rows exceed MaxListLimit.
// (GetExecutionsByStatuses uses DESC + LIMIT for History; mixing them here
// drops the genuinely-soonest rows, exactly the rows this list must show.
// An in-memory re-sort cannot recover what LIMIT already discarded.)
executions, err := h.config.GetPlannedExecutions(ctx, plannedListStatuses, config.MaxListLimit)
if err != nil {
return nil, fmt.Errorf("failed to get pending executions: %w", err)
return nil, fmt.Errorf("failed to get planned executions: %w", err)
}

plans, err := h.config.ListPurchasePlans(ctx, config.PurchasePlanFilter{})
Expand Down
163 changes: 159 additions & 4 deletions internal/api/handler_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,12 @@ func TestHandler_getPlannedPurchases(t *testing.T) {
}

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("GetPendingExecutions", ctx).Return(executions, nil)
// The planned list must request paused executions alongside pending/notified
// so a paused row stays VISIBLE. Assert the status set
// explicitly rather than mock.Anything to lock the invariant.
mockStore.On("GetPlannedExecutions", ctx,
[]string{"pending", "notified", "paused"}, config.MaxListLimit).
Return(executions, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)

handler := &Handler{config: mockStore, auth: mockAuth}
Expand All @@ -763,6 +768,7 @@ func TestHandler_getPlannedPurchases(t *testing.T) {
}
result, err := handler.getPlannedPurchases(ctx, req)
require.NoError(t, err)
mockStore.AssertExpectations(t)

assert.Len(t, result.Purchases, 1)
assert.Equal(t, "11111111-1111-1111-1111-111111111111", result.Purchases[0].ID)
Expand Down Expand Up @@ -791,7 +797,8 @@ func TestHandler_getPlannedPurchases_ErrorGettingExecutions(t *testing.T) {
}

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("GetPendingExecutions", ctx).Return(nil, errors.New("database error"))
mockStore.On("GetPlannedExecutions", ctx, mock.Anything, mock.Anything).
Return(nil, errors.New("database error"))

handler := &Handler{config: mockStore, auth: mockAuth}

Expand All @@ -803,7 +810,155 @@ func TestHandler_getPlannedPurchases_ErrorGettingExecutions(t *testing.T) {
result, err := handler.getPlannedPurchases(ctx, req)
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "failed to get pending executions")
assert.Contains(t, err.Error(), "failed to get planned executions")
}

// TestHandler_getPlannedPurchases_PausedStaysVisible is a regression guard:
// a paused execution must remain in the list (not silently disappear) and
// rows must be ordered soonest-first end-to-end.
func TestHandler_getPlannedPurchases_PausedStaysVisible(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
mockAuth := new(MockAuthService)

adminSession := &Session{
UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
Email: "admin@example.com",
Role: "admin",
}

soon := time.Now().AddDate(0, 0, 3)
later := time.Now().AddDate(0, 0, 10)
// GetPlannedExecutions returns rows soonest-first (ASC); the handler
// preserves that order with no in-memory re-sort.
executions := []config.PurchaseExecution{
{
ExecutionID: "33333333-3333-3333-3333-333333333333",
PlanID: "11111111-1111-1111-1111-111111111111",
Status: "pending",
ScheduledDate: soon,
StepNumber: 1,
},
{
ExecutionID: "22222222-2222-2222-2222-222222222222",
PlanID: "11111111-1111-1111-1111-111111111111",
Status: "paused",
ScheduledDate: later,
StepNumber: 2,
},
}
plans := []config.PurchasePlan{
{
ID: "11111111-1111-1111-1111-111111111111",
Name: "Test Plan",
Services: map[string]config.ServiceConfig{"aws/rds": {Provider: "aws", Service: "rds", Term: 3, Payment: "no-upfront"}},
RampSchedule: config.RampSchedule{TotalSteps: 5},
},
}

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("GetPlannedExecutions", ctx,
[]string{"pending", "notified", "paused"}, config.MaxListLimit).
Return(executions, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)

handler := &Handler{config: mockStore, auth: mockAuth}
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token"}}

result, err := handler.getPlannedPurchases(ctx, req)
require.NoError(t, err)
mockStore.AssertExpectations(t)

require.Len(t, result.Purchases, 2)
// Soonest-first ordering.
assert.Equal(t, "pending", result.Purchases[0].Status)
assert.Equal(t, "paused", result.Purchases[1].Status)
// The paused row is present, not dropped.
var statuses []string
for _, p := range result.Purchases {
statuses = append(statuses, p.Status)
}
assert.Contains(t, statuses, "paused")
}

// TestHandler_getPlannedPurchases_SoonestRowsNotTruncated is the end-to-end
// regression guard for CodeRabbit's truncation finding on PR #904: when total
// pending/notified/paused rows exceed MaxListLimit, the store's DESC + LIMIT
// drops the soonest rows entirely, and an in-memory ASC re-sort of the
// already-truncated subset cannot recover them.
//
// The fix routes the handler through GetPlannedExecutions (ASC + LIMIT) and
// removes the post-fetch in-memory sort. This test:
// - registers a mock expectation on GetPlannedExecutions and asserts
// GetExecutionsByStatuses is NOT called (regressing to the DESC path
// would surface here),
// - returns rows in ASC order (mimicking what the real SQL would produce)
// and asserts the handler preserves that order without re-shuffling,
// - asserts all 5 rows survive (none are dropped by the handler itself).
func TestHandler_getPlannedPurchases_SoonestRowsNotTruncated(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
mockAuth := new(MockAuthService)

adminSession := &Session{
UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
Email: "admin@example.com",
Role: "admin",
}

now := time.Now()
// 5 rows, soonest first, the order the store will return them when
// ORDER BY scheduled_date ASC is used (the fix). Pre-fix code called
// GetExecutionsByStatuses (DESC) and re-sorted in-memory, but the
// regression scenario is that the DB has truncated the soonest rows
// away before the in-memory sort sees them.
soonest := []config.PurchaseExecution{
{ExecutionID: "11111111-1111-1111-1111-111111111111", PlanID: "00000000-0000-0000-0000-000000000001", Status: "pending", ScheduledDate: now.AddDate(0, 0, 1), StepNumber: 1},
{ExecutionID: "22222222-2222-2222-2222-222222222222", PlanID: "00000000-0000-0000-0000-000000000001", Status: "notified", ScheduledDate: now.AddDate(0, 0, 2), StepNumber: 2},
{ExecutionID: "33333333-3333-3333-3333-333333333333", PlanID: "00000000-0000-0000-0000-000000000001", Status: "paused", ScheduledDate: now.AddDate(0, 0, 3), StepNumber: 3},
{ExecutionID: "44444444-4444-4444-4444-444444444444", PlanID: "00000000-0000-0000-0000-000000000001", Status: "pending", ScheduledDate: now.AddDate(0, 0, 4), StepNumber: 4},
{ExecutionID: "55555555-5555-5555-5555-555555555555", PlanID: "00000000-0000-0000-0000-000000000001", Status: "pending", ScheduledDate: now.AddDate(0, 0, 5), StepNumber: 5},
}
plans := []config.PurchasePlan{
{
ID: "00000000-0000-0000-0000-000000000001",
Name: "Test Plan",
Services: map[string]config.ServiceConfig{"aws/rds": {Provider: "aws", Service: "rds", Term: 3, Payment: "no-upfront"}},
RampSchedule: config.RampSchedule{TotalSteps: 5},
},
}

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
// Strict args lock the contract: planned statuses + MaxListLimit (so the
// DB receives the same cap the handler intends, no off-by-one budget).
mockStore.On("GetPlannedExecutions", ctx,
[]string{"pending", "notified", "paused"}, config.MaxListLimit).
Return(soonest, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)

handler := &Handler{config: mockStore, auth: mockAuth}
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token"}}

result, err := handler.getPlannedPurchases(ctx, req)
require.NoError(t, err)
mockStore.AssertExpectations(t)
// Regression anchor: the handler must NOT fall back to the DESC path.
// AssertNotCalled fails if some refactor re-introduces a parallel
// GetExecutionsByStatuses call on the planned list code path.
mockStore.AssertNotCalled(t, "GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything)

require.Len(t, result.Purchases, 5, "all 5 rows must reach the response, none dropped by handler post-processing")
// Ordering preserved end-to-end: the store returns ASC, the handler must
// pass that through unchanged (the in-memory re-sort has been removed).
for i, expected := range []string{
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
"33333333-3333-3333-3333-333333333333",
"44444444-4444-4444-4444-444444444444",
"55555555-5555-5555-5555-555555555555",
} {
assert.Equal(t, expected, result.Purchases[i].ID, "row %d (soonest-first) must be %s", i, expected)
}
}

func TestHandler_pausePlannedPurchase(t *testing.T) {
Expand Down Expand Up @@ -1311,7 +1466,7 @@ func TestHandler_getPlannedPurchases_ErrorGettingPlans(t *testing.T) {
executions := []config.PurchaseExecution{{ExecutionID: "11111111-1111-1111-1111-111111111111", PlanID: "11111111-1111-1111-1111-111111111111"}}

mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil)
mockStore.On("GetPendingExecutions", ctx).Return(executions, nil)
mockStore.On("GetPlannedExecutions", ctx, mock.Anything, mock.Anything).Return(executions, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(nil, errors.New("database error"))

handler := &Handler{config: mockStore, auth: mockAuth}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,7 @@ func TestHandler_HandleRequest_GetPlannedPurchases(t *testing.T) {
{ID: "11111111-1111-1111-1111-111111111111", Name: "Test Plan", Services: map[string]config.ServiceConfig{"aws/rds": {Provider: "aws", Service: "rds"}}},
}

mockStore.On("GetPendingExecutions", ctx).Return(executions, nil)
mockStore.On("GetPlannedExecutions", ctx, mock.Anything, mock.Anything).Return(executions, nil)
mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return(plans, nil)

handler := &Handler{config: mockStore, auth: mockAuth, corsAllowedOrigin: "*", apiKey: "test-key"}
Expand Down
8 changes: 8 additions & 0 deletions internal/api/mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ func (m *MockConfigStore) GetExecutionsByStatuses(ctx context.Context, statuses
return args.Get(0).([]config.PurchaseExecution), args.Error(1)
}

func (m *MockConfigStore) GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]config.PurchaseExecution, error) {
args := m.Called(ctx, statuses, limit)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]config.PurchaseExecution), args.Error(1)
}

func (m *MockConfigStore) GetStaleApprovedExecutions(ctx context.Context, olderThan time.Duration) ([]config.PurchaseExecution, error) {
args := m.Called(ctx, olderThan)
if args.Get(0) == nil {
Expand Down
9 changes: 9 additions & 0 deletions internal/config/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ type StoreInterface interface {
// this method's status filter) to avoid accidental double-processing of
// failed / expired rows.
GetExecutionsByStatuses(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error)
// GetPlannedExecutions returns executions in any of the given states
// ordered by scheduled_date ASC (soonest first), the order the Planned
// Purchases UI lists rows so the user acts on imminent purchases first.
// Distinct from GetExecutionsByStatuses (which is DESC for History's
// "newest first" semantics): when the result set exceeds `limit`, an
// ORDER-BY-DESC + LIMIT in SQL truncates away the soonest rows, exactly
// the rows this list must surface. Secondary sort by id ASC stabilises
// ordering when multiple rows share a scheduled_date.
GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error)
// GetStaleApprovedExecutions returns executions stuck in the "approved"
// status with updated_at older than olderThan — strands left behind when a
// synchronous purchase run was interrupted before finalizing (issue #632).
Expand Down
39 changes: 39 additions & 0 deletions internal/config/store_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,45 @@ func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses []
return s.queryExecutions(ctx, query, statuses, limit)
}

// GetPlannedExecutions returns executions whose Status is any of the supplied
// values, ordered by scheduled_date ASC (soonest first), capped at `limit`.
// Used by the Planned Purchases handler where the user expects to act on
// imminent rows first.
//
// Distinct from GetExecutionsByStatuses (DESC + LIMIT for History's
// newest-first semantics): when total rows exceed `limit`, a DESC truncation
// drops the soonest rows, exactly the ones this list must surface. Sorting
// the already-truncated subset in-memory cannot recover them.
//
// Secondary sort by id ASC keeps ordering stable when multiple rows share a
// scheduled_date. NULLS LAST is defensive: the schema makes scheduled_date
// NOT NULL today, but the clause guards against a future relaxation silently
// hiding rows at the top of the list.
func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error) {
if len(statuses) == 0 {
return nil, nil
}
if limit <= 0 {
limit = DefaultListLimit
}
if limit > MaxListLimit {
limit = MaxListLimit
}
query := `
SELECT plan_id, execution_id, status, step_number, scheduled_date,
notification_sent, approval_token, recommendations,
total_upfront_cost, estimated_savings, completed_at, error, expires_at,
cloud_account_id, source, approved_by, cancelled_by, capacity_percent,
created_by_user_id, retry_execution_id, retry_attempt_n,
approval_token_expires_at
FROM purchase_executions
WHERE status = ANY($1)
ORDER BY scheduled_date ASC NULLS LAST, id ASC
LIMIT $2
`
return s.queryExecutions(ctx, query, statuses, limit)
}

// GetStaleApprovedExecutions returns executions stuck in the "approved" status
// whose last update is older than olderThan. These are executions that were
// flipped to "approved" by ApproveAndExecute but whose synchronous purchase run
Expand Down
Loading
Loading