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
64 changes: 64 additions & 0 deletions frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,3 +1025,67 @@ describe('#1503 — payment-option coercion is disclosed in the purchase toast',
expect(notice).toContain('Pay Monthly');
});
});

describe('handleExecutePurchase - missing payment option (#431)', () => {
beforeEach(() => {
jest.clearAllMocks();
(recs.getFanOutBuckets as jest.Mock).mockReturnValue(null);
(plans.closePurchaseModal as jest.Mock).mockImplementation(() => undefined);
});

afterEach(() => {
document.body.textContent = '';
});

test.each([
['undefined', undefined],
['empty string', ''],
])('refuses with an error toast and no POST when payment is %s', async (_label, payment) => {
const rec: Record<string, unknown> = { ...buildMinimalRec() };
if (payment === undefined) delete rec.payment;
else rec.payment = payment;
(recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([rec]);

const btn = setup();
btn.click();
await new Promise((r) => setTimeout(r, 0));

expect(api.executePurchase).not.toHaveBeenCalled();
expect(confirmDialog).not.toHaveBeenCalled();
expect(lastToastKind()).toBe('error');
expect(lastToastMessage()).toContain('no payment option');
expect(btn.disabled).toBe(false);
expect(plans.closePurchaseModal).not.toHaveBeenCalled();
});

test('refuses the whole batch when only one of several recs lacks payment', async () => {
const missing: Record<string, unknown> = { ...buildMinimalRec(), id: 'rec-2' };
delete missing.payment;
(recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([buildMinimalRec(), missing]);

const btn = setup();
btn.click();
await new Promise((r) => setTimeout(r, 0));

expect(api.executePurchase).not.toHaveBeenCalled();
expect(lastToastMessage()).toContain('1 of 2');
});

test('a rec with a payment option is sent unchanged', async () => {
(recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([
{ ...buildMinimalRec(), payment: 'no-upfront' },
]);
(api.executePurchase as jest.Mock).mockResolvedValue({
execution_id: 'exec-1',
status: 'queued',
email_sent: true,
});

const btn = setup();
btn.click();
await new Promise((r) => setTimeout(r, 0));

const sent = (api.executePurchase as jest.Mock).mock.calls[0][0];
expect(sent[0].payment).toBe('no-upfront');
});
});
24 changes: 19 additions & 5 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,21 @@ export async function handleExecutePurchase(): Promise<void> {
return;
}

// Refuse before the confirm dialog and the POST when any rec has no payment
// option. Defaulting here would silently pick the most cash-intensive
// schedule on a money path (issue #431).
const recsWithPayment = localRecs.filter(
(r): r is typeof r & { payment: string } => !!r.payment,
);
const missingCount = localRecs.length - recsWithPayment.length;
if (missingCount > 0) {
showToast({
message: `Cannot submit: ${missingCount} of ${localRecs.length} selected recommendation${localRecs.length === 1 ? '' : 's'} ${missingCount === 1 ? 'has' : 'have'} no payment option. Choose a payment option for each row and try again.`,
kind: 'error',
});
return;
}

// Read the execute mode set by the modal toggle (issue #289).
// "direct" means the session has execute-any/execute-own and chose to
// bypass approval; "" is the default approval-required path.
Expand Down Expand Up @@ -407,16 +422,15 @@ export async function handleExecutePurchase(): Promise<void> {
// future additions) flow through unchanged. Only `payment`, `selected`,
// and `purchased` are overridden: `payment` uses the user-edited value
// (issue #111), and `selected`/`purchased` are forced to the canonical
// purchase-intent values. The `?? 'all-upfront'` on payment is defensive
// only — direct test-harness callers that bypass the modal may not set
// `payment`; the production path always does. Passing `details` ensures
// purchase-intent values. A missing `payment` was refused above, so it is
// never defaulted here. Passing `details` ensures
// Windows EC2, dedicated-tenancy, AZ-scoped RIs, and non-default-engine
// RDS/Cache recs reach the backend with the correct ServiceDetails payload
// instead of falling back to Linux/regional/default (issue #597).
const apiRecs: api.Recommendation[] = localRecs.map((r) => ({
const apiRecs: api.Recommendation[] = recsWithPayment.map((r) => ({
...r,
monthly_cost: r.monthly_cost ?? null,
payment: r.payment ?? 'all-upfront',
payment: r.payment,
selected: true,
purchased: false,
}));
Expand Down
Loading