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
34 changes: 34 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,40 @@ describe('Plans Module', () => {
await expect(loadPlans()).resolves.not.toThrow();
});

test('renders notified purchase with run, pause and disable buttons', async () => {
(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({
purchases: [
{
id: 'purchase-1',
plan_id: 'plan-1',
plan_name: 'Test Plan',
scheduled_date: '2024-02-20',
provider: 'aws',
service: 'ec2',
resource_type: 't3.medium',
region: 'us-east-1',
count: 5,
term: 1,
payment: 'all-upfront',
upfront_cost: 1000,
estimated_savings: 200,
status: 'notified',
step_number: 1,
total_steps: 4
}
]
});

await loadPlans();

const container = document.getElementById('planned-purchases-list');
expect(container?.innerHTML).toContain('data-action="run"');
expect(container?.innerHTML).toContain('data-action="pause"');
expect(container?.innerHTML).toContain('data-action="disable"');
expect(container?.innerHTML).not.toContain('data-action="resume"');
});

test('renders paused purchase with correct buttons', async () => {
(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ export interface PlannedPurchase {
// upfront_cost and estimated_savings are then placeholders, not data
// (platform#609).
has_recommendations: boolean;
status: 'pending' | 'paused' | 'running' | 'completed' | 'failed';
status: 'pending' | 'notified' | 'paused' | 'running' | 'completed' | 'failed';
step_number: number;
total_steps: number;
// created_by_user_id is the UUID of the user who scheduled the purchase.
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ function canManageScheduledPurchase(purchase: PlannedPurchase): boolean {
function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
const statusClass = getPlannedPurchaseStatusClass(purchase.status);
const isPaused = purchase.status === 'paused';
const isPending = purchase.status === 'pending';
const isPending = purchase.status === 'pending' || purchase.status === 'notified';
const canRun = isPending || isPaused;
const termCell = purchase.term > 0
? `${purchase.term}yr ${escapeHtml((purchase.payment ?? '').replace('-', ' '))}`
Expand Down Expand Up @@ -767,7 +767,8 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
*/
function getPlannedPurchaseStatusClass(status: string): string {
switch (status) {
case 'pending': return 'status-pending';
case 'pending':
case 'notified': return 'status-pending';
case 'paused': return 'status-paused';
case 'running': return 'status-running';
case 'completed': return 'status-completed';
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 @@ -307,6 +307,10 @@ func (m *mockConfigStore) TransitionExecutionStatusPinned(ctx context.Context, e
return nil, nil
}

func (m *mockConfigStore) RescheduleAwaitingExecution(_ context.Context, _ string, _ time.Time) (bool, error) {
return false, nil
}

func (m *mockConfigStore) SetCancelledBy(_ context.Context, _ string, _ string) error {
return nil
}
Expand Down
48 changes: 43 additions & 5 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func (h *Handler) pausePlannedPurchase(ctx context.Context, req *events.LambdaFu
}

// Atomically transition to paused
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending"}, "paused", resolveCreatorUserID(session)); err != nil {
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "notified"}, "paused", resolveCreatorUserID(session)); err != nil {
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be paused: %v", executionID, err))
}

Expand All @@ -229,13 +229,41 @@ func (h *Handler) resumePlannedPurchase(ctx context.Context, req *events.LambdaF
}

// Atomically transition from paused back to pending
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"paused"}, "pending", resolveCreatorUserID(session)); err != nil {
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be resumed: %v", executionID, err))
resumed, transitionErr := h.config.TransitionExecutionStatus(ctx, executionID, []string{"paused"}, "pending", resolveCreatorUserID(session))
if transitionErr != nil {
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be resumed: %v", executionID, transitionErr))
}
if pushErr := h.pushPastScheduledDate(ctx, resumed, time.Now()); pushErr != nil {
return nil, pushErr
}

return &StatusResponse{Status: "resumed"}, nil
}

// pushPastScheduledDate moves a resumed recommendation-bearing row whose
// scheduled_date has already passed to now + the plan's NotificationDaysBefore
// days, so that once it is re-notified the scheduler cannot buy it before the
// veto window has run. The row is pending, which the scheduler does not
// execute while it carries recommendations, so the gap between the resume
// and this write cannot buy. Rows without recommendations keep their date
// (the scheduler fails them).
func (h *Handler) pushPastScheduledDate(ctx context.Context, row *config.PurchaseExecution, now time.Time) error {
if len(row.Recommendations) == 0 || !row.ScheduledDate.Before(now) {
return nil
}
plan, err := h.config.GetPurchasePlan(ctx, row.PlanID)
if err != nil {
return fmt.Errorf("failed to read plan %s to reschedule resumed execution %s: %w", row.PlanID, row.ExecutionID, err)
}
// Conditional single-column update, not a full-row save: a cancel or
// approve landing after the resume must not be overwritten. A lost
// condition is a benign no-op (the row is no longer awaiting approval).
if _, err := h.config.RescheduleAwaitingExecution(ctx, row.ExecutionID, now.AddDate(0, 0, plan.NotificationDaysBefore)); err != nil {
return fmt.Errorf("failed to reschedule resumed execution %s: %w", row.ExecutionID, err)
}
return nil
}

// runPlannedPurchase forces a pending or paused scheduled purchase to
// execute immediately (the "Run now" button) instead of waiting for its
// scheduled date. It delegates to purchase.Manager.RunPlannedPurchaseNow,
Expand Down Expand Up @@ -454,14 +482,21 @@ func (h *Handler) disablePlanIfInScope(ctx context.Context, session *Session, pl
}

// cancelOrRecoverExecution transitions the execution to "canceled" if it is
// still in {pending, paused}. If a prior attempt already canceled it
// still in {pending, notified, paused}. If a prior attempt already canceled it
// (ErrExecutionNotInExpectedStatus), it fetches the row instead so the caller
// can still drive the plan-disable side-effect, keeping the operation
// idempotent across retries.
// actor is the UUID of the user initiating the cancel (nil for system-initiated paths).
func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID string, actor *string) (*config.PurchaseExecution, error) {
canceled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "canceled", actor)
canceled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "notified", "paused"}, "canceled", actor)
if err == nil {
// A recommendation-bearing row owns suppressions (written with its
// recommendations); release them like the other cancel paths. The
// delete is idempotent and also runs on the recovery path below, so a
// retry heals a failure here.
if delErr := h.config.DeleteSuppressionsByExecution(ctx, executionID); delErr != nil {
return nil, fmt.Errorf("cancel execution %s: failed to release suppressions: %w", executionID, delErr)
}
return canceled, nil
}
if !errors.Is(err, config.ErrExecutionNotInExpectedStatus) {
Expand Down Expand Up @@ -500,6 +535,9 @@ func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID stri
if existing.Status == config.LegacyStatusCanceled {
existing.Status = config.StatusCanceled
}
if delErr := h.config.DeleteSuppressionsByExecution(ctx, executionID); delErr != nil {
return nil, fmt.Errorf("cancel execution %s: failed to release suppressions: %w", executionID, delErr)
}
return existing, nil
}

Expand Down
87 changes: 87 additions & 0 deletions internal/api/handler_purchases_notified_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//go:build integration

package api

import (
"testing"
"time"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Real PostgreSQL through the handlers. The rec-bearing row is a hand-built
// fixture (nothing attaches recommendations to a plan step yet).

func (f *pauseClaimFixture) setRow(t *testing.T, status string, date time.Time) {
t.Helper()
_, err := f.pool.Exec(f.ctx, `UPDATE purchase_executions SET status=$2, scheduled_date=$3 WHERE execution_id=$1`, f.execution.ExecutionID, status, date)
require.NoError(t, err)
}

func (f *pauseClaimFixture) scheduledDate(t *testing.T) time.Time {
t.Helper()
row, err := f.store.GetExecutionByID(f.ctx, f.execution.ExecutionID)
require.NoError(t, err)
return row.ScheduledDate
}

func (f *pauseClaimFixture) suppressionCount(t *testing.T) int {
t.Helper()
var n int
require.NoError(t, f.pool.QueryRow(f.ctx, `SELECT count(*) FROM purchase_suppressions WHERE execution_id=$1`, f.execution.ExecutionID).Scan(&n))
return n
}

func TestNotifiedRow_PauseResumeHandlersMoveDatePastWindow(t *testing.T) {
f := newPauseClaimFixture(t)
f.setRow(t, "notified", time.Now().Add(-48*time.Hour))
require.NoError(t, f.pause())
require.Equal(t, "paused", f.state(t).Status)

before := time.Now()
require.NoError(t, f.resume())
assert.Equal(t, "pending", f.state(t).Status)
plan, err := f.store.GetPurchasePlan(f.ctx, f.execution.PlanID)
require.NoError(t, err)
assert.WithinDuration(t, before.AddDate(0, 0, plan.NotificationDaysBefore), f.scheduledDate(t), time.Minute)
}

// F1: a cancel landing between the paused->pending CAS and the reschedule is
// not overwritten by the reschedule (replayed with the stale resume snapshot).
func TestNotifiedRow_RescheduleDoesNotOverwriteConcurrentCancel(t *testing.T) {
f := newPauseClaimFixture(t)
past := time.Now().Add(-48 * time.Hour)
f.setRow(t, "paused", past)
resumed, err := f.store.TransitionExecutionStatus(f.ctx, f.execution.ExecutionID, []string{"paused"}, "pending", nil)
require.NoError(t, err)
_, err = f.store.TransitionExecutionStatus(f.ctx, f.execution.ExecutionID, []string{"pending", "notified", "paused"}, "canceled", nil)
require.NoError(t, err)
dateBefore := f.scheduledDate(t)

require.NoError(t, f.handler.pushPastScheduledDate(f.ctx, resumed, time.Now()))

assert.Equal(t, "canceled", f.state(t).Status)
assert.WithinDuration(t, dateBefore, f.scheduledDate(t), time.Second)
}

// F2: canceling or deleting a notified rec-bearing row releases its suppressions.
func TestNotifiedRow_DeleteReleasesSuppressions(t *testing.T) {
f := newPauseClaimFixture(t)
f.setRow(t, "notified", time.Now().Add(time.Hour))
global, err := f.store.GetGlobalConfig(f.ctx)
require.NoError(t, err)
sups := config.BuildSuppressions(f.execution.Recommendations, f.execution.ExecutionID, global, time.Now())
require.NotEmpty(t, sups)
for i := range sups {
require.NoError(t, f.store.CreateSuppression(f.ctx, &sups[i]))
}
require.Positive(t, f.suppressionCount(t))

_, err = f.handler.deletePlannedPurchase(f.ctx, f.request, f.execution.ExecutionID)
require.NoError(t, err)

assert.Equal(t, "canceled", f.state(t).Status)
assert.Zero(t, f.suppressionCount(t))
}
89 changes: 89 additions & 0 deletions internal/api/handler_purchases_notified_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package api

import (
"context"
"testing"
"time"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

// Fixture rows (mock store): nothing attaches recommendations to a plan step
// yet, so rec-bearing rows are built by hand.

const notifiedExecID = "11111111-1111-1111-1111-111111111111"

func notifiedAdminHandler(ctx context.Context) (*Handler, *MockConfigStore, *events.LambdaFunctionURLRequest) {
store := new(MockConfigStore)
auth := new(MockAuthService)
auth.On("ValidateSession", ctx, "admin-token").Return(&Session{UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Email: "admin@example.com"}, nil)
auth.grantAdmin()
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer admin-token"}}
return &Handler{config: store, auth: auth}, store, req
}

func TestPausePlannedPurchase_AcceptsNotifiedRow(t *testing.T) {
ctx := context.Background()
handler, store, req := notifiedAdminHandler(ctx)
store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"pending", "notified"}, "paused", mock.Anything).
Return(&config.PurchaseExecution{ExecutionID: notifiedExecID, Status: "paused"}, nil)

res, err := handler.pausePlannedPurchase(ctx, req, notifiedExecID)
require.NoError(t, err)
assert.Equal(t, "paused", res.Status)
}

func TestResumePlannedPurchase_PastScheduledDate(t *testing.T) {
ctx := context.Background()
recs := []config.RecommendationRecord{{ID: "rec-1", Count: 1}}
cases := []struct {
name string
recs []config.RecommendationRecord
date time.Time
wantMoved bool
}{
{"rec-bearing past date is moved", recs, time.Now().Add(-48 * time.Hour), true},
{"rec-bearing future date is kept", recs, time.Now().Add(72 * time.Hour), false},
{"empty row past date is kept", nil, time.Now().Add(-48 * time.Hour), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
handler, store, req := notifiedAdminHandler(ctx)
row := &config.PurchaseExecution{ExecutionID: notifiedExecID, PlanID: "plan-1", Status: "pending", Recommendations: tc.recs, ScheduledDate: tc.date}
store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"paused"}, "pending", mock.Anything).Return(row, nil)
store.On("GetPurchasePlan", ctx, "plan-1").Return(&config.PurchasePlan{ID: "plan-1", NotificationDaysBefore: 3}, nil).Maybe()
var movedTo *time.Time
store.On("RescheduleAwaitingExecution", ctx, notifiedExecID, mock.Anything).Run(func(a mock.Arguments) {
d := a.Get(2).(time.Time)
movedTo = &d
}).Return(true, nil).Maybe()

before := time.Now()
_, err := handler.resumePlannedPurchase(ctx, req, notifiedExecID)
require.NoError(t, err)
if !tc.wantMoved {
assert.Nil(t, movedTo)
return
}
require.NotNil(t, movedTo)
assert.WithinDuration(t, before.AddDate(0, 0, 3), *movedTo, time.Minute)
store.AssertNotCalled(t, "SavePurchaseExecution", mock.Anything, mock.Anything)
})
}
}

func TestCancelOrRecoverExecution_ReleasesSuppressionsOfNotifiedRow(t *testing.T) {
ctx := context.Background()
handler, store, _ := notifiedAdminHandler(ctx)
store.On("TransitionExecutionStatus", ctx, notifiedExecID, []string{"pending", "notified", "paused"}, "canceled", mock.Anything).
Return(&config.PurchaseExecution{ExecutionID: notifiedExecID, Status: "canceled"}, nil)
store.On("DeleteSuppressionsByExecution", ctx, notifiedExecID).Return(nil)

_, err := handler.cancelOrRecoverExecution(ctx, notifiedExecID, nil)
require.NoError(t, err)
store.AssertCalled(t, "DeleteSuppressionsByExecution", ctx, notifiedExecID)
}
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ func TestPauseClaim_PausedQueueResumesExactlyOnce(t *testing.T) {

func TestPauseClaim_RejectsNonPendingStates(t *testing.T) {
f := newPauseClaimFixture(t)
for _, status := range []string{"running", "completed", "canceled", "failed", "approved", "notified", "scheduled", "paused"} {
for _, status := range []string{"running", "completed", "canceled", "failed", "approved", "scheduled", "paused"} {
t.Run(status, func(t *testing.T) {
f.execution.Status = status
require.NoError(t, f.store.SavePurchaseExecution(f.ctx, f.execution))
Expand Down
Loading
Loading