Skip to content
Merged
243 changes: 243 additions & 0 deletions frontend/src/__tests__/app-fanout-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/**
* Issue #757: a partial bulk (fan-out) purchase failure keeps the modal open
* with per-bucket status and a retry that resubmits only the failed buckets.
*/
const showToast = jest.fn();
const closePurchaseModal = jest.fn();
const clearFanOutBuckets = jest.fn();
const openArcheraOfferModal = jest.fn();
let current: Array<Record<string, unknown>> | null = null;

jest.mock('../toast', () => ({ showToast: (...a: unknown[]) => showToast(...a) }));
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn().mockResolvedValue(true) }));
jest.mock('../api', () => ({ executePurchase: jest.fn() }));
jest.mock('../state', () => ({}));
jest.mock('../auth', () => ({}));
jest.mock('../dashboard', () => ({ loadDashboard: jest.fn(), setupDashboardHandlers: jest.fn() }));
jest.mock('../navigation', () => ({}));
jest.mock('../plans', () => ({ closePurchaseModal: () => closePurchaseModal() }));
jest.mock('../settings', () => ({}));
jest.mock('../users', () => ({}));
jest.mock('../apikeys', () => ({}));
jest.mock('../history', () => ({}));
jest.mock('../modules/savings-history', () => ({}));
jest.mock('../riexchange', () => ({}));
jest.mock('../purchases-deeplink', () => ({}));
jest.mock('../archera', () => ({ handleArcheraDeeplink: jest.fn(), openArcheraOfferModal: (k: string) => openArcheraOfferModal(k) }));
jest.mock('../modal', () => ({ closeModal: jest.fn() }));
jest.mock('../recommendations', () => ({
setupRecommendationsHandlers: jest.fn(),
getPurchaseModalRecommendations: () => [],
clearPurchaseModalRecommendations: jest.fn(),
getFanOutBuckets: () => (current ? current.map((b) => ({ ...b })) : null),
clearFanOutBuckets: () => { clearFanOutBuckets(); current = null; },
retainFanOutBuckets: (ids: Set<string>) => { current = (current ?? []).filter((b) => ids.has(b['id'] as string)); },
getExecuteMode: () => '',
clearExecuteMode: jest.fn(),
fanOutBucketAccountLabel: (b: { id?: string }) => `acct-${b.id}`,
getAccountName: () => 'acct',
}));

import { handleExecutePurchase } from '../app';
import * as api from '../api';

const bucket = (id: string, service: string): Record<string, unknown> => ({
id, provider: 'aws', service, term: 1, payment: 'no-upfront', capacityPercent: 100, paymentSource: 'toolbar',
recs: [{ service }],
});

const section = (id: string): HTMLElement =>
document.querySelector<HTMLElement>(`section.fanout-bucket[data-bucket-id="${id}"]`)!;
const btn = (): HTMLButtonElement => document.getElementById('execute-purchase-btn') as HTMLButtonElement;
const postedServices = (): string[] =>
(api.executePurchase as jest.Mock).mock.calls.map((c) => (c[0] as Array<{ service: string }>)[0]!.service);

beforeEach(() => {
jest.clearAllMocks();
current = [bucket('bucket-0', 'ec2'), bucket('bucket-1', 'rds'), bucket('bucket-2', 'elasticache')];
renderModal(current);
});

/** Renders the open purchase modal with one section per bucket. */
function renderModal(buckets: Array<Record<string, unknown>>): void {
document.body.innerHTML = `
<div id="purchase-modal" class="modal">
<div id="purchase-details">
<div id="fanout-summary"></div>
${buckets.map((b) => `<section class="fanout-bucket" data-bucket-id="${b['id']}"><h4>${b['service']}</h4><select class="fanout-bucket-payment"><option>no-upfront</option></select></section>`).join('')}
</div>
<button id="execute-purchase-btn"></button>
</div>`;
}

/** What Escape/Cancel does: hide the modal and clear its buckets (onClose). */
function closeModalMidSubmit(): void {
document.getElementById('purchase-modal')!.classList.add('hidden');
current = null;
}

/** executePurchase calls that stay pending until the test settles them. */
function deferredPosts(): Array<{ resolve: (v: unknown) => void; reject: (e: Error) => void }> {
const posts: Array<{ resolve: (v: unknown) => void; reject: (e: Error) => void }> = [];
(api.executePurchase as jest.Mock).mockImplementation(() => new Promise((resolve, reject) => { posts.push({ resolve, reject }); }));
return posts;
}

const flush = async (): Promise<void> => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
const toastMessages = (): string[] => showToast.mock.calls.map((c) => (c[0] as { message: string }).message);

describe('fan-out partial failure retry (issue #757)', () => {
test('keeps the modal open, marks each bucket, and narrows to the failed ones', async () => {
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true, approval_recipient: 'boss@example.com' })
.mockRejectedValueOnce(new Error('quota exceeded'))
.mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'failed', email_sent: false, email_reason: 'SES throttled' });

await handleExecutePurchase();

expect(closePurchaseModal).not.toHaveBeenCalled();
expect(clearFanOutBuckets).not.toHaveBeenCalled();
expect(openArcheraOfferModal).not.toHaveBeenCalled();

expect(section('bucket-0').classList.contains('fanout-bucket-submitted')).toBe(true);
expect(section('bucket-0').querySelector('.fanout-bucket-result')!.textContent)
.toBe('Submitted: awaiting approval from boss@example.com');
expect(section('bucket-0').querySelector('select')!.disabled).toBe(true);
expect(section('bucket-1').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: quota exceeded');
expect(section('bucket-2').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: SES throttled');
expect(section('bucket-1').querySelector('select')!.disabled).toBe(false);

const banner = document.getElementById('fanout-result')!;
expect(banner.getAttribute('role')).toBe('alert');
expect(banner.textContent).toContain('1 bucket submitted, 2 failed.');
expect(banner.textContent).toContain('Submitted buckets will not be sent again');

expect(current!.map((b) => b['id'])).toEqual(['bucket-1', 'bucket-2']);
expect(btn().textContent).toBe('Retry 2 failed');
expect(btn().disabled).toBe(false);
// The modal carries the details; no sticky failure toast covers it.
expect(showToast.mock.calls.map((c) => (c[0] as { message: string }).message).join('\n')).not.toMatch(/failed/);
});

test('retry resubmits only the failed buckets and closes the modal once all succeed', async () => {
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true })
.mockRejectedValueOnce(new Error('quota exceeded'))
.mockRejectedValueOnce(new Error('timeout'));
await handleExecutePurchase();
expect(postedServices()).toEqual(['ec2', 'rds', 'elasticache']);

(api.executePurchase as jest.Mock).mockClear();
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'bbbb0000-1', status: 'pending', email_sent: true })
.mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true });
await handleExecutePurchase();

expect(postedServices()).toEqual(['rds', 'elasticache']);
expect(closePurchaseModal).toHaveBeenCalledTimes(1);
expect(openArcheraOfferModal).toHaveBeenCalledWith('purchase');
});

test('a second partial failure keeps the earlier successes marked and counted', async () => {
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true })
.mockRejectedValueOnce(new Error('e1'))
.mockRejectedValueOnce(new Error('e2'));
await handleExecutePurchase();
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'bbbb0000-1', status: 'completed', duplicate: true })
.mockRejectedValueOnce(new Error('e3'));
await handleExecutePurchase();

expect(section('bucket-0').classList.contains('fanout-bucket-submitted')).toBe(true);
expect(section('bucket-1').querySelector('.fanout-bucket-result')!.textContent)
.toBe('Submitted: joined existing request bbbb0000; it was already executed');
expect(section('bucket-2').querySelector('.fanout-bucket-result')!.textContent).toBe('Failed: e3');
expect(document.getElementById('fanout-result')!.textContent).toContain('2 buckets submitted, 1 failed.');
expect(btn().textContent).toBe('Retry 1 failed');
});

test('all buckets failing also stays open for a retry', async () => {
(api.executePurchase as jest.Mock).mockRejectedValue(new Error('down'));
await handleExecutePurchase();
expect(closePurchaseModal).not.toHaveBeenCalled();
expect(document.getElementById('fanout-result')!.textContent).toContain('Nothing was submitted.');
expect(btn().textContent).toBe('Retry 3 failed');
});

test('buckets without ids (no rendered fan-out modal) keep the old close behavior', async () => {
current = [{ ...bucket('x', 'ec2'), id: undefined }, { ...bucket('y', 'rds'), id: undefined }];
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true })
.mockRejectedValueOnce(new Error('boom'));
await handleExecutePurchase();
expect(closePurchaseModal).toHaveBeenCalledTimes(1);
expect(document.getElementById('fanout-result')).toBeNull();
});

test('closing the modal mid-submit still reports a failed bucket in a toast, as before', async () => {
const posts = deferredPosts();
const run = handleExecutePurchase();
await flush();
expect(posts).toHaveLength(3);

closeModalMidSubmit();
posts[0]!.resolve({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true });
posts[1]!.reject(new Error('quota exceeded'));
posts[2]!.resolve({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true });
await run;

expect(toastMessages()).toContainEqual(expect.stringMatching(/^2 of 3 submitted · 1 failed: acct-bucket-1: quota exceeded/));
expect(document.getElementById('fanout-result')).toBeNull();
expect(document.querySelector('.fanout-bucket-result')).toBeNull();
expect(openArcheraOfferModal).toHaveBeenCalledWith('purchase');
});

test('results from a closed modal leave a newly opened bulk modal untouched', async () => {
const posts = deferredPosts();
const run = handleExecutePurchase();
await flush();

closeModalMidSubmit();
const next = [bucket('next-0', 'ec2'), bucket('next-1', 'rds')];
current = next;
renderModal(next);

posts[0]!.resolve({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true });
posts[1]!.reject(new Error('quota exceeded'));
posts[2]!.resolve({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true });
await run;

expect(current!.map((b) => b['id'])).toEqual(['next-0', 'next-1']);
expect(document.getElementById('purchase-modal')!.classList.contains('hidden')).toBe(false);
expect(document.getElementById('fanout-result')).toBeNull();
expect(document.querySelector('.fanout-bucket-submitted, .fanout-bucket-failed, .fanout-bucket-result')).toBeNull();
expect([...document.querySelectorAll('select')].some((s) => (s as HTMLSelectElement).disabled)).toBe(false);
expect(closePurchaseModal).not.toHaveBeenCalled();
expect(clearFanOutBuckets).not.toHaveBeenCalled();
expect(toastMessages()).toContainEqual(expect.stringMatching(/^2 of 3 submitted · 1 failed: acct-bucket-1: quota exceeded/));
});

test('the payment-adjustment notice is not sticky while the modal is in retry mode', async () => {
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true, payment_adjustments: [{ provider: 'aws', service: 'ec2', requested_payment_option: 'no-upfront', applied_payment_option: 'all-upfront' }] })
.mockRejectedValueOnce(new Error('quota exceeded'))
.mockRejectedValueOnce(new Error('timeout'));
await handleExecutePurchase();

expect(document.getElementById('fanout-result')).not.toBeNull();
const notice = showToast.mock.calls.map((c) => c[0] as { kind: string; timeout?: number | null }).find((o) => o.kind === 'warning');
expect(notice).toBeDefined();
expect(notice!.timeout).not.toBeNull();
});

test('bucket failure reason text cannot inject markup', async () => {
(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({ execution_id: 'aaaa0000-1', status: 'pending', email_sent: true })
.mockRejectedValueOnce(new Error('<img src=x onerror=alert(1)>'))
.mockResolvedValueOnce({ execution_id: 'cccc0000-1', status: 'pending', email_sent: true });
await handleExecutePurchase();
expect(section('bucket-1').querySelector('img')).toBeNull();
expect(section('bucket-1').textContent).toContain('<img src=x onerror=alert(1)>');
});
});
27 changes: 27 additions & 0 deletions frontend/src/__tests__/purchase-modal-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,33 @@ describe('Issue #331: Escape discards purchase state like the explicit close but
expect(body[0]!['id']).toBe('single-only');
});

test('each fan-out modal open gets fresh bucket ids, so results from an earlier open cannot match it (issue #757)', async () => {
const [ec2Rec, rdsRec] = fanOutEligibleRows();
(api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } });
(api.getRecommendations as jest.Mock).mockResolvedValue({
summary: {}, recommendations: [ec2Rec, rdsRec], regions: [],
});
(state.getRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]);
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['fo-ec2', 'fo-rds']));

await loadRecommendations();
(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await flush();
const first = getFanOutBuckets()!.map((b) => b.id);
lastRegisteredOnClose()();

(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await flush();
const second = getFanOutBuckets()!.map((b) => b.id);

expect(first).toHaveLength(2);
expect(second).toHaveLength(2);
expect(new Set([...first, ...second]).size).toBe(4);
const rendered = [...document.querySelectorAll<HTMLElement>('section.fanout-bucket')].map((s) => s.dataset['bucketId']);
expect(rendered).toEqual(second);
});

test('Escape on a single-bucket modal discards its selection too (reverse direction)', async () => {
const staleRec = buildRows()[0]!; // v-3-all
await openPurchaseModal([staleRec]);
Expand Down
60 changes: 53 additions & 7 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { handlePurchaseDeeplink } from './purchases-deeplink';
import { handleRIExchangeDeeplink } from './riexchange-deeplink';
import { handleArcheraDeeplink, openArcheraOfferModal } from './archera';
import { closeModal } from './modal';
import { showFanOutRetry, fanOutRetryLabel, purchaseModalReplaced, type FanOutBucketOutcome } from './fanout-retry';
import { wireTablistKeyboard } from './lib/tablist';
import { setOpenAccountsHandler } from './first-run-empty-state';
import { setupMobileNav } from './nav/mobile-nav';
Expand Down Expand Up @@ -667,10 +668,36 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
try {
await submitFanOutBuckets(buckets);
} finally {
setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(false));
// Issue #757: after a partial failure the modal stays open in retry mode.
setExecutePurchaseSubmitting(executeBtn, false, fanOutRetryLabel() ?? executeButtonLabel(false));
}
}

/**
* Per-bucket outcome for the retry view (issue #757). Uses the same
* success rule as the toast: a fulfilled POST whose approval email failed
* is a failure.
*/
function fanOutOutcomes(
buckets: FanOutBucket[],
results: PromiseSettledResult<api.PurchaseResult>[],
): FanOutBucketOutcome[] {
return results.map((r, i) => {
const bucket = buckets[i]!;
if (r.status === 'rejected') {
return { bucket, ok: false, detail: r.reason instanceof Error ? r.reason.message : String(r.reason) };
}
if (r.value.email_sent === false || r.value.status === 'failed') {
return { bucket, ok: false, detail: r.value.email_reason || 'approval email did not send' };
}
if (r.value.duplicate) {
return { bucket, ok: true, detail: `joined existing request ${r.value.execution_id.slice(0, 8)}; ${duplicateState(r.value.status)}` };
}
const to = r.value.approval_recipient ? ` from ${r.value.approval_recipient}` : '';
return { bucket, ok: true, detail: `awaiting approval${to}` };
});
}

async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise<void> {
// Fire all POSTs in parallel via allSettled so one failure doesn't
// cascade. Each bucket's recs are already scaled by its capacity %;
Expand Down Expand Up @@ -709,9 +736,20 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise<void> {
);
const succeeded = fulfilled.length - submissionFailures.length;
const failed = results.length - succeeded;
closePurchaseModal();
clearFanOutBuckets();
clearPurchaseModalRecommendations();
// Issue #757: on any failure keep the modal open with per-bucket status
// and a retry limited to the failed buckets, instead of closing it and
// making the user reselect rows (and risk resubmitting the successes).
// If the user closed the modal while the POSTs were in flight, report by
// toast as before. If they already opened another purchase modal, leave
// that one alone: it is not the one these results belong to.
// (Checked before showFanOutRetry, which narrows the modal's buckets.)
const replaced = purchaseModalReplaced(buckets);
const retrying = failed > 0 && showFanOutRetry(fanOutOutcomes(buckets, results));
if (!retrying && !replaced) {
closePurchaseModal();
clearFanOutBuckets();
clearPurchaseModalRecommendations();
}

// Disclose payment-option coercions across every bucket that reached the
// backend (#1503). Collected from all fulfilled responses, not just the
Expand All @@ -722,7 +760,9 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise<void> {
fulfilled.flatMap((r) => r.value.payment_adjustments ?? []),
);
if (fanOutNotice) {
showToast({ message: fanOutNotice, kind: 'warning', timeout: null });
// Sticky as before, except in retry mode, where it would cover the
// modal's Retry button; it then expires on the default timeout.
showToast({ message: fanOutNotice, kind: 'warning', ...(retrying ? {} : { timeout: null }) });
}

if (failed === 0) {
Expand Down Expand Up @@ -766,7 +806,10 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise<void> {
timeout: 15_000,
});
}
} else {
} else if (!retrying) {
// Issue #757: skipped while the modal stays open for a retry. The modal
// then shows every bucket's status and reason in full, and a sticky toast
// would sit on top of its Retry button.
// Prefix each failure with its bucket's account so the user knows what to
// retry when one account's bucket fails and another's succeeds (issue #333).
const failureMsgs = results
Expand Down Expand Up @@ -826,7 +869,10 @@ async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise<void> {
// async email-link execution completes. Skipping the all-fail path keeps
// the modal from layering on top of an error toast for a user who has
// nothing to insure yet.
if (succeeded > 0) {
// Not while a purchase modal is open (issue #757): the retry view, or a
// newer modal the user opened before these POSTs settled. The offer would
// stack on top of it. In retry mode it fires once a retry closes the modal.
if (succeeded > 0 && !retrying && !replaced) {
openArcheraOfferModal('purchase');
}

Expand Down
Loading
Loading