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
9 changes: 9 additions & 0 deletions frontend/src/__tests__/plans-overdue-181.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ describe('Planned Purchases: overdue pending rows (#181)', () => {
expect(hint?.textContent).toBe('Not run on its date.');
});

test('a step with no recommendations does not suggest Run now (#1695)', async () => {
setUser(true);
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({
purchases: [{ ...purchase('pp-bare', '2000-01-03', 'pending'), has_recommendations: false }],
});
await loadPlans();
expect(row('pp-bare').querySelector('.overdue-hint')?.textContent).toBe('Not run on its date.');
});

test('a paused row past its date is not flagged (pausing is the user\'s choice)', async () => {
setUser(true);
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [purchase('pp-paused', '2000-01-03', 'paused')] });
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,56 @@ describe('Plans Module', () => {
expect(html).not.toContain('$0');
});

describe('Run now on a step with no recommendations (#1695)', () => {
const bare = {
id: 'purchase-bare',
plan_id: 'plan-1',
plan_name: 'Azure compute 80% weekly',
scheduled_date: '2026-10-07',
provider: 'azure',
service: 'compute',
resource_type: 'Various',
region: 'Multiple',
count: 0,
term: 1,
payment: 'monthly',
upfront_cost: 0,
estimated_savings: 0,
has_recommendations: false,
status: 'pending',
step_number: 1,
total_steps: 4
};

beforeEach(async () => {
(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [bare] });
await loadPlans();
});

test('renders the run button disabled with an explanatory title', () => {
const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement;
expect(runBtn.disabled).toBe(true);
expect(runBtn.title).toBe('Run now: this step has no recommendations, so there is nothing to run');
expect(runBtn.getAttribute('aria-label')).toBe('Run now: this step has no recommendations, so there is nothing to run');
expect(runBtn.getAttribute('data-id')).toBe('purchase-bare');
});

test('the run action never calls the API or opens a confirm', async () => {
const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement;
runBtn.disabled = false;
runBtn.click();
await flushPromises();

expect(api.runPlannedPurchase).not.toHaveBeenCalled();
expect(mockConfirmDialog).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({
message: 'This step has no recommendations attached, so there is nothing to run.',
kind: 'info',
}));
});
});

test('shows empty message when no planned purchases', async () => {
(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,8 +454,9 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {

// #181: a pending step whose date has passed did not run; "pending" alone
// reads as on schedule. Flag it and name the next action the user can take.
const runLabel = hasNoRecommendations ? 'Run now: this step has no recommendations, so there is nothing to run' : 'Run now';
const overdue = isPending && isPastScheduledDate(purchase.scheduled_date);
const overdueHint = canRunPurchase ? 'Not run on its date. Run now or pause.' : 'Not run on its date.';
const overdueHint = canRunPurchase && !hasNoRecommendations ? 'Not run on its date. Run now or pause.' : 'Not run on its date.';
const overdueBadge = overdue
? badgeHtml('danger', 'Overdue', 'Scheduled date has passed')
: '';
Expand All @@ -478,7 +479,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
<td class="num savings">${savingsCell}</td>
<td><span class="status-badges">${statusBadgeHtml(purchase.status)}${overdueBadge}</span>${overdueHintHtml}</td>
<td class="actions"><span class="row-actions">
${canRunPurchase ? `<button data-action="run" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small primary icon-label row-action-run" title="Run now" aria-label="Run now">${iconSvg('play')}<span>Run now</span></button>` : ''}
${canRunPurchase ? `<button data-action="run" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small primary icon-label row-action-run"${hasNoRecommendations ? ' disabled' : ''} title="${runLabel}" aria-label="${runLabel}">${iconSvg('play')}<span>Run now</span></button>` : ''}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
${canPauseOrResumePurchase && isPending ? `<button data-action="pause" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small icon-btn" title="Pause" aria-label="Pause">${iconSvg('pause')}</button>` : ''}
${canPauseOrResumePurchase && isPaused ? `<button data-action="resume" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small icon-btn" title="Resume" aria-label="Resume">${iconSvg('play')}</button>` : ''}
${canEditPlan ? `<button data-action="edit" data-id="${escapeHtmlAttr(purchase.id)}" data-plan-id="${escapeHtmlAttr(purchase.plan_id)}" class="btn-small icon-btn" title="Edit Plan" aria-label="Edit Plan">${iconSvg('edit')}</button>` : ''}
Expand Down Expand Up @@ -509,9 +510,6 @@ const runNowInFlight = new Set<string>();

function runNowBody(target: PlannedPurchase | undefined): string | HTMLElement {
if (!target) return 'This immediately executes the purchase.';
if (target.has_recommendations === false) {
return 'This step has no recommendations attached, so running it will not buy anything.';
}
const summary = buildCommitmentSummary([{
service: target.service,
provider: target.provider,
Expand All @@ -532,6 +530,10 @@ async function runPlannedPurchaseNow(purchaseId: string): Promise<void> {
runNowInFlight.add(purchaseId);
try {
const target = lastLoadedPurchases.find(p => p.id === purchaseId);
if (target?.has_recommendations === false) {
showToast({ message: 'This step has no recommendations attached, so there is nothing to run.', kind: 'info', timeout: 5_000 });
return;
}
const ok = await confirmDialog({
title: target ? `Run step ${target.step_number}/${target.total_steps} of "${target.plan_name}" now?` : 'Run purchase now?',
body: runNowBody(target),
Expand Down
6 changes: 6 additions & 0 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,12 @@ func (h *Handler) runPlannedPurchase(ctx context.Context, req *events.LambdaFunc
if fetchErr != nil {
return nil, fmt.Errorf("failed to get execution: %w", fetchErr)
}
// An empty step has no constraint sets to evaluate (the auth check fails
// closed on them, which surfaced as a 500), and the executor refuses it
// anyway so it cannot complete and advance the ramp (issue #1695).
if len(execution.Recommendations) == 0 {
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be started: %v", executionID, purchase.ErrPlanStepNoRecommendations))
}
if constraintErr := h.enforcePurchaseConstraints(ctx, session, "execute", execution.Recommendations); constraintErr != nil {
return nil, constraintErr
}
Expand Down
15 changes: 15 additions & 0 deletions internal/api/handler_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,21 @@ func TestHandler_runPlannedPurchase_ZeroCommitment(t *testing.T) {
assert.Contains(t, ce.Error(), "zero total commitment")
}

// TestHandler_runPlannedPurchase_NoRecommendations: a step with zero recs must
// be refused with a clear 409 before any claim or provider call, not fall
// through to the constraints check and surface as a 500 (issue #1695).
func TestHandler_runPlannedPurchase_NoRecommendations(t *testing.T) {
err := runPlannedPurchaseGuardHarness(t,
auth.Permission{Action: auth.ActionExecute, Resource: auth.ResourcePurchases},
nil,
)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "expected a clientError, got: %v", err)
assert.Equal(t, 409, ce.code)
assert.Contains(t, ce.Error(), "no recommendations")
}

func TestHandler_deletePlannedPurchase(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
Expand Down
Loading