diff --git a/frontend/src/__tests__/history-approve-button.test.ts b/frontend/src/__tests__/history-approve-button.test.ts index 2123b8f0..295b4d53 100644 --- a/frontend/src/__tests__/history-approve-button.test.ts +++ b/frontend/src/__tests__/history-approve-button.test.ts @@ -18,13 +18,16 @@ * session qualifies for both verbs. */ -import { loadHistory } from '../history'; +import { loadHistory, setupHistoryHandlers, viewPlanHistory } from '../history'; jest.mock('../api', () => ({ getHistory: jest.fn(), getConfig: jest.fn().mockResolvedValue({ global: {} }), approvePurchase: jest.fn(), cancelPurchase: jest.fn(), + getPurchaseDetails: jest.fn().mockResolvedValue({ recommendations: [] }), + listAccounts: jest.fn().mockResolvedValue([]), + getDeploymentInfo: jest.fn().mockResolvedValue({}), })); jest.mock('../navigation', () => ({ @@ -76,7 +79,7 @@ jest.mock('../state', () => ({ import * as api from '../api'; import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; -import { getCurrentUser } from '../state'; +import { getCurrentUser, subscribeAmortizeUpfront } from '../state'; import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; // Admin user includes Purchaser membership (mirrors the auto-migration for @@ -110,6 +113,139 @@ const REG_USER = { }; const OTHER_UUID = 'other-uuid'; +function deferred(): { promise: Promise; resolve(value: T): void; reject(error: Error): void } { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +const settleActions = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); + +describe('History action ownership and refresh recovery (#249)', () => { + const data = { summary: {}, purchases: [makeRow({ purchase_id: 'guarded' })] }; + beforeEach(() => { + setupDOM(); + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockReset().mockResolvedValue(data); + (api.getPurchaseDetails as jest.Mock).mockReset().mockResolvedValue({ recommendations: [] }); + (api.approvePurchase as jest.Mock).mockReset().mockResolvedValue(undefined); + (confirmDialog as jest.Mock).mockReset().mockResolvedValue(true); + (showToast as jest.Mock).mockReset(); + }); + + test('details fetch owns both actions and same-ID projections while other IDs remain independent', async () => { + const details = deferred<{ recommendations: never[] }>(); + (api.getPurchaseDetails as jest.Mock).mockReturnValue(details.promise); + (api.getHistory as jest.Mock).mockResolvedValue({ ...data, purchases: [...data.purchases, makeRow({ purchase_id: 'other' })] }); + (confirmDialog as jest.Mock).mockResolvedValue(false); + await loadHistory(); + const btn = document.querySelector('#history-list [data-approve-id="guarded"]')!; + btn.click(); + btn.dispatchEvent(new MouseEvent('click')); + document.querySelector('#history-list [data-cancel-id="guarded"]')!.click(); + document.querySelector('#purchases-approval-queue [data-approve-id="guarded"]')!.click(); + document.querySelector('#history-list [data-approve-id="other"]')!.click(); + const disabled = btn.disabled; + details.resolve({ recommendations: [] }); + await settleActions(); + expect(disabled).toBe(true); + expect(api.getPurchaseDetails).toHaveBeenCalledTimes(2); + expect(confirmDialog).toHaveBeenCalledTimes(2); + expect(api.approvePurchase).not.toHaveBeenCalled(); + expect(api.cancelPurchase).not.toHaveBeenCalled(); + expect(btn.disabled).toBe(false); + }); + + test.each(['cancel', 'reject', 'throw', 'api-error'] as const)('%s releases ownership for a later explicit attempt', async outcome => { + await loadHistory(); + const btn = document.querySelector('#history-list .history-approve-btn')!; + if (outcome === 'cancel') (confirmDialog as jest.Mock).mockResolvedValueOnce(false); + if (outcome === 'reject') (confirmDialog as jest.Mock).mockRejectedValueOnce(new Error('dialog failed')); + if (outcome === 'throw') (confirmDialog as jest.Mock).mockImplementationOnce(() => { throw new Error('dialog failed'); }); + if (outcome === 'api-error') (api.approvePurchase as jest.Mock).mockRejectedValueOnce(new Error('mutation failed')); + btn.click(); + await settleActions(); + expect(btn.disabled).toBe(false); + const count = outcome === 'api-error' ? 1 : 0; + expect(api.approvePurchase).toHaveBeenCalledTimes(count); + btn.click(); + await settleActions(); + expect(api.approvePurchase).toHaveBeenCalledTimes(count + 1); + }); + + test('API remains guarded across a cached re-render and direct disabled dispatch', async () => { + const mutation = deferred(); + (api.approvePurchase as jest.Mock).mockReturnValue(mutation.promise); + setupHistoryHandlers(); + await loadHistory(); + const btn = document.querySelector('#history-list .history-approve-btn')!; + btn.click(); + await settleActions(); + const redraw = (subscribeAmortizeUpfront as jest.Mock).mock.calls[0]![0] as () => void; + redraw(); + document.querySelector('#history-list .history-approve-btn')!.click(); + document.querySelector('#purchases-approval-queue .history-cancel-btn')!.click(); + btn.dispatchEvent(new MouseEvent('click')); + mutation.resolve(); + await settleActions(); + expect(api.approvePurchase).toHaveBeenCalledTimes(1); + expect(api.cancelPurchase).not.toHaveBeenCalled(); + }); + + test.each([false, true])('success survives failed refresh and cached redraw (toast throws=%s)', async toastThrows => { + setupHistoryHandlers(); + await loadHistory(); + (api.getHistory as jest.Mock).mockRejectedValueOnce(new Error('refresh failed')); + if (toastThrows) (showToast as jest.Mock).mockImplementationOnce(() => { throw new Error('toast failed'); }); + document.querySelector('#history-list .history-approve-btn')!.click(); + await settleActions(); + const redraw = (subscribeAmortizeUpfront as jest.Mock).mock.calls[0]![0] as () => void; + redraw(); + expect(document.querySelectorAll('[data-approve-id="guarded"]')).toHaveLength(0); + expect(api.approvePurchase).toHaveBeenCalledTimes(1); + expect(api.getHistory).toHaveBeenCalledTimes(2); + expect((showToast as jest.Mock).mock.calls.some(([opts]) => opts.message?.startsWith('Failed to approve'))).toBe(false); + await loadHistory(); + expect(document.querySelectorAll('[data-approve-id="guarded"]')).toHaveLength(2); + }); + + test.each(['history', 'plan'] as const)('%s loader cannot publish responses started before a successful mutation', async loader => { + const old = deferred(); + setupHistoryHandlers(); + await loadHistory(); + (api.getHistory as jest.Mock).mockReturnValueOnce(old.promise); + const oldLoad = loader === 'history' ? loadHistory() : viewPlanHistory('plan-1'); + const redraw = (subscribeAmortizeUpfront as jest.Mock).mock.calls[0]![0] as () => void; + redraw(); + (api.getHistory as jest.Mock).mockRejectedValueOnce(new Error('refresh failed')); + document.querySelector('#history-list .history-approve-btn')!.click(); + await settleActions(); + old.resolve(data); + await oldLoad; + redraw(); + expect(document.querySelectorAll('[data-approve-id="guarded"]')).toHaveLength(0); + await loadHistory(); + expect(document.querySelectorAll('[data-approve-id="guarded"]')).toHaveLength(2); + }); + + test.each(['history', 'plan'] as const)('%s loader ignores old errors after a successful mutation refresh', async loader => { + const old = deferred(); + setupHistoryHandlers(); + await loadHistory(); + (api.getHistory as jest.Mock).mockReturnValueOnce(old.promise); + const oldLoad = loader === 'history' ? loadHistory() : viewPlanHistory('plan-1'); + ((subscribeAmortizeUpfront as jest.Mock).mock.calls[0]![0] as () => void)(); + document.querySelector('#history-list .history-approve-btn')!.click(); + await settleActions(); + old.reject(new Error('old failure')); + await oldLoad; + expect(document.querySelectorAll('[data-approve-id="guarded"]')).toHaveLength(2); + expect(document.getElementById('history-list')!.textContent).not.toContain('old failure'); + }); +}); + function setupDOM(): void { while (document.body.firstChild) document.body.removeChild(document.body.firstChild); diff --git a/frontend/src/__tests__/history-cancel-button.test.ts b/frontend/src/__tests__/history-cancel-button.test.ts index 9f3ce5dc..461894fc 100644 --- a/frontend/src/__tests__/history-cancel-button.test.ts +++ b/frontend/src/__tests__/history-cancel-button.test.ts @@ -329,3 +329,28 @@ describe('History inline Cancel button (issue #46)', () => { expect(btn?.disabled).toBe(false); }); }); + + test('cancel owns its row while confirmation is deferred and permits an explicit retry (#249)', async () => { + setupDOM(); + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [makeRow({})] }); + (api.cancelPurchase as jest.Mock).mockResolvedValue({ status: 'pending', email_sent: true }); + let resolveConfirm!: (value: boolean) => void; + (confirmDialog as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveConfirm = resolve; })); + await loadHistory(); + const btn = document.querySelector('#history-list .history-cancel-btn')!; + btn.click(); + btn.dispatchEvent(new MouseEvent('click')); + const disabledDuringConfirmation = btn.disabled; + resolveConfirm(false); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(disabledDuringConfirmation).toBe(true); + expect(confirmDialog).toHaveBeenCalledTimes(1); + expect(api.cancelPurchase).not.toHaveBeenCalled(); + expect(btn.disabled).toBe(false); + (confirmDialog as jest.Mock).mockResolvedValue(true); + btn.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(api.cancelPurchase).toHaveBeenCalledTimes(1); + }); diff --git a/frontend/src/__tests__/history-marketplace-sell-button.test.ts b/frontend/src/__tests__/history-marketplace-sell-button.test.ts index a946a750..53cf1c1c 100644 --- a/frontend/src/__tests__/history-marketplace-sell-button.test.ts +++ b/frontend/src/__tests__/history-marketplace-sell-button.test.ts @@ -465,3 +465,53 @@ describe('Marketplace consent modal residual proration (issue #808 follow-up)', expect(text).not.toContain('$0'); }); }); + + test('marketplace-sell owns its row while confirmation is deferred and permits an explicit retry (#249)', async () => { + setupDOM(); + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [makeRow({})] }); + (api.createMarketplaceListing as jest.Mock).mockResolvedValue({ status: 'pending', email_sent: true }); + let resolveConfirm!: (value: boolean) => void; + (confirmDialog as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveConfirm = resolve; })); + await loadHistory(); + const btn = document.querySelector('#history-list .history-marketplace-sell-btn')!; + btn.click(); + btn.dispatchEvent(new MouseEvent('click')); + const disabledDuringConfirmation = btn.disabled; + resolveConfirm(false); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(disabledDuringConfirmation).toBe(true); + expect(confirmDialog).toHaveBeenCalledTimes(1); + expect(api.createMarketplaceListing).not.toHaveBeenCalled(); + expect(btn.disabled).toBe(false); + (confirmDialog as jest.Mock).mockResolvedValue(true); + btn.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(api.createMarketplaceListing).toHaveBeenCalledTimes(1); + }); + + test('marketplace-cancel owns its row while confirmation is deferred and permits an explicit retry (#249)', async () => { + setupDOM(); + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [makeRow({ listing_state: 'active' })] }); + (api.cancelMarketplaceListing as jest.Mock).mockResolvedValue({ status: 'pending', email_sent: true }); + let resolveConfirm!: (value: boolean) => void; + (confirmDialog as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveConfirm = resolve; })); + await loadHistory(); + const btn = document.querySelector('#history-list .history-marketplace-cancel-btn')!; + btn.click(); + btn.dispatchEvent(new MouseEvent('click')); + const disabledDuringConfirmation = btn.disabled; + resolveConfirm(false); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(disabledDuringConfirmation).toBe(true); + expect(confirmDialog).toHaveBeenCalledTimes(1); + expect(api.cancelMarketplaceListing).not.toHaveBeenCalled(); + expect(btn.disabled).toBe(false); + (confirmDialog as jest.Mock).mockResolvedValue(true); + btn.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(api.cancelMarketplaceListing).toHaveBeenCalledTimes(1); + }); diff --git a/frontend/src/__tests__/history-retry-button.test.ts b/frontend/src/__tests__/history-retry-button.test.ts index d24d1339..86ab1893 100644 --- a/frontend/src/__tests__/history-retry-button.test.ts +++ b/frontend/src/__tests__/history-retry-button.test.ts @@ -545,3 +545,28 @@ describe('History retry-own permission gate (issue #1418)', () => { expect(ids).toEqual(['own-fail']); }); }); + + test('retry owns its row while confirmation is deferred and permits an explicit retry (#249)', async () => { + setupDOM(); + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [makeRow({ retry_attempt_n: 5 })] }); + (api.retryPurchase as jest.Mock).mockResolvedValue({ status: 'pending', email_sent: true }); + let resolveConfirm!: (value: boolean) => void; + (confirmDialog as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveConfirm = resolve; })); + await loadHistory(); + const btn = document.querySelector('#history-list .history-retry-btn')!; + btn.click(); + btn.dispatchEvent(new MouseEvent('click')); + const disabledDuringConfirmation = btn.disabled; + resolveConfirm(false); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(disabledDuringConfirmation).toBe(true); + expect(confirmDialog).toHaveBeenCalledTimes(1); + expect(api.retryPurchase).not.toHaveBeenCalled(); + expect(btn.disabled).toBe(false); + (confirmDialog as jest.Mock).mockResolvedValue(true); + btn.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(api.retryPurchase).toHaveBeenCalledTimes(1); + }); diff --git a/frontend/src/__tests__/history-revoke-button.test.ts b/frontend/src/__tests__/history-revoke-button.test.ts index 470dd1c7..96f2e542 100644 --- a/frontend/src/__tests__/history-revoke-button.test.ts +++ b/frontend/src/__tests__/history-revoke-button.test.ts @@ -361,3 +361,28 @@ describe('History Revoke click flow (platform#96)', () => { expect(api.revokePurchase).toHaveBeenCalledWith('commit-azure', undefined); }); }); + + test('revoke owns its row while confirmation is deferred and permits an explicit retry (#249)', async () => { + setupDOM(); + jest.clearAllMocks(); + (getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [makeRow({ status: 'scheduled', revocation_window_closes_at: new Date(Date.now() + 86400000).toISOString() })] }); + (api.revokePurchase as jest.Mock).mockResolvedValue({ status: 'pending', email_sent: true }); + let resolveConfirm!: (value: boolean) => void; + (confirmDialog as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveConfirm = resolve; })); + await loadHistory(); + const btn = document.querySelector('#history-list .history-revoke-btn')!; + btn.click(); + btn.dispatchEvent(new MouseEvent('click')); + const disabledDuringConfirmation = btn.disabled; + resolveConfirm(false); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(disabledDuringConfirmation).toBe(true); + expect(confirmDialog).toHaveBeenCalledTimes(1); + expect(api.revokePurchase).not.toHaveBeenCalled(); + expect(btn.disabled).toBe(false); + (confirmDialog as jest.Mock).mockResolvedValue(true); + btn.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(api.revokePurchase).toHaveBeenCalledTimes(1); + }); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 8ba3cd39..42ac0ef5 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -41,6 +41,8 @@ type StatusFilter = 'all' | 'pending' | 'completed' | 'failed' | 'expired' | 'ca // Cache of the last-rendered purchase list so the status-chip click handler // can re-render without re-fetching. Cleared on each loadHistory / viewPlanHistory. let lastPurchases: HistoryPurchase[] = []; +const busyHistoryExecutions = new Set(); +let historyMutationEpoch = 0; let activeStatusFilter: StatusFilter = 'all'; // _fourEyesMode mirrors GlobalConfig.require_different_approver (issue #1005). @@ -252,6 +254,7 @@ export function initHistoryDateRange(): void { * would be misleading. */ export async function viewPlanHistory(planId: string): Promise { + const requestEpoch = historyMutationEpoch; // skipDefaultLoad: the tab's own unscoped 7-day fetch would land after the // plan-scoped one below and overwrite it, and its date-range seeding is // exactly what the doc comment above says not to do here. @@ -265,12 +268,14 @@ export async function viewPlanHistory(planId: string): Promise { api.getHistory({ planId }) as unknown as Promise, refreshFourEyesMode(), ]); + if (requestEpoch !== historyMutationEpoch) return; renderHistorySummary(data.summary ?? null); const purchases = data.purchases || []; renderApprovalQueue(purchases); renderHistoryList(purchases); snapDateInputsToPurchases(purchases); } catch (error) { + if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load plan history:', error); const err = error as Error; const list = document.getElementById('history-list'); @@ -311,6 +316,7 @@ function snapDateInputsToPurchases(purchases: HistoryPurchase[]): void { * Load history with filters */ export async function loadHistory(): Promise { + const requestEpoch = historyMutationEpoch; // Issue #344 T3: skeleton rows for the purchase-history table. 8 // rows matches the typical first-page row count so the skeleton // doesn't shrink dramatically when real data arrives. Column count @@ -354,11 +360,13 @@ export async function loadHistory(): Promise { api.getHistory(filters) as unknown as Promise, refreshFourEyesMode(), ]); + if (requestEpoch !== historyMutationEpoch) return; renderHistorySummary(data.summary ?? null); const purchases = data.purchases || []; renderApprovalQueue(purchases); renderHistoryList(purchases); } catch (error) { + if (requestEpoch !== historyMutationEpoch) return; console.error('Failed to load history:', error); const err = error as Error; const list = document.getElementById('history-list'); @@ -805,11 +813,50 @@ function sameRowActions(btn: HTMLButtonElement): HTMLButtonElement[] { if (!cell) return [btn]; return Array.from( cell.querySelectorAll( - '.history-approve-btn, .history-cancel-btn, .history-revoke-btn, .history-marketplace-sell-btn, .history-marketplace-cancel-btn', + '.history-approve-btn, .history-cancel-btn, .history-retry-btn, .history-revoke-btn, .history-marketplace-sell-btn, .history-marketplace-cancel-btn', ), ); } +async function runHistoryRowAction( + btn: HTMLButtonElement, + id: string, + action: (markMutationSucceeded: () => void) => Promise, +): Promise { + if (btn.disabled || busyHistoryExecutions.has(id)) return; + const buttons = sameRowActions(btn).filter(button => !button.disabled); + busyHistoryExecutions.add(id); + buttons.forEach(button => { button.disabled = true; }); + let mutationSucceeded = false; + try { + await action(() => { + mutationSucceeded = true; + historyMutationEpoch++; + lastPurchases = lastPurchases.filter(purchase => purchase.purchase_id !== id); + lastPendingForQueue = lastPendingForQueue.filter(purchase => purchase.purchase_id !== id); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast({ + kind: mutationSucceeded ? 'warning' : 'error', + message: mutationSucceeded + ? `Purchase action succeeded, but the display could not update: ${message}` + : `Failed to complete purchase action: ${message}`, + }); + } finally { + try { + if (mutationSucceeded) { + await loadHistory(); + } else { + buttons.forEach(button => { button.disabled = false; }); + if (btn.isConnected && document.activeElement === document.body) btn.focus(); + } + } finally { + busyHistoryExecutions.delete(id); + } + } +} + // renderPendingActionButtons returns the inline Approve / Cancel // button HTML for a pending|notified row, or "" when neither verb is // available to the current session. Extracted from renderActionCell @@ -1242,9 +1289,9 @@ function wireRowActionHandlers(container: HTMLElement): void { // Backend may still 409 on a status race (concurrent cancel landed // first); the catch surfaces the structured detail. container.querySelectorAll('.history-approve-btn[data-approve-id]').forEach(btn => { - btn.addEventListener('click', async () => { - const id = btn.dataset['approveId']; - if (!id) return; + const id = btn.dataset['approveId']; + if (!id) return; + btn.addEventListener('click', () => runHistoryRowAction(btn, id, async markMutationSucceeded => { // Issue #374: show the per-rec details (service / engine / // resource / region / count / term + payment / costs) in the // modal so the user has informed consent before authorising a @@ -1258,31 +1305,17 @@ function wireRowActionHandlers(container: HTMLElement): void { destructive: false, }); if (!ok) return; - // Issue #286 + CR pass: Approve and Cancel can render together on - // the same row, so disabling only the clicked button leaves the - // sibling clickable while we await the API. Disable BOTH on - // either click and re-enable both on failure — a successful - // approve triggers a full history reload that re-renders the - // row, so the row-action sibling state doesn't matter on the - // happy path. - const rowActions = sameRowActions(btn); - rowActions.forEach((b) => { b.disabled = true; }); try { await api.approvePurchase(id); + markMutationSucceeded(); } catch (approveError) { console.error('Failed to approve pending purchase:', approveError); const err = approveError as Error; showToast({ message: `Failed to approve: ${err.message || 'unknown error'}`, kind: 'error' }); - rowActions.forEach((b) => { b.disabled = false; }); return; } showToast({ message: 'Purchase approved', kind: 'success', timeout: 5_000 }); - try { - await loadHistory(); - } catch (reloadError) { - console.error('Failed to reload history after approve:', reloadError); - } - }); + })); }); // Wire the inline Cancel button on pending/notified rows the current @@ -1298,9 +1331,9 @@ function wireRowActionHandlers(container: HTMLElement): void { // user should see success-toast first so they don't think their // click was lost while we re-fetch the table. container.querySelectorAll('.history-cancel-btn[data-cancel-id]').forEach(btn => { - btn.addEventListener('click', async () => { - const id = btn.dataset['cancelId']; - if (!id) return; + const id = btn.dataset['cancelId']; + if (!id) return; + btn.addEventListener('click', () => runHistoryRowAction(btn, id, async markMutationSucceeded => { const ok = await confirmDialog({ title: 'Cancel this pending purchase?', body: 'This will permanently abort the approval flow. The pending email approval link will stop working. This action cannot be undone.', @@ -1308,32 +1341,17 @@ function wireRowActionHandlers(container: HTMLElement): void { destructive: true, }); if (!ok) return; - // Symmetric with the Approve handler above: disable both row - // actions while the API is in flight (CR pass on PR #299). - const rowActions = sameRowActions(btn); - rowActions.forEach((b) => { b.disabled = true; }); try { await api.cancelPurchase(id); + markMutationSucceeded(); } catch (cancelError) { console.error('Failed to cancel pending purchase:', cancelError); const err = cancelError as Error; showToast({ message: `Failed to cancel: ${err.message || 'unknown error'}`, kind: 'error' }); - rowActions.forEach((b) => { b.disabled = false; }); return; } - // Cancel succeeded — surface success regardless of whether the - // refresh works. A reload failure leaves the row in its previous - // pending state on screen (stale-but-correct: the next manual - // reload corrects it). showToast({ message: 'Purchase cancelled', kind: 'success', timeout: 5_000 }); - try { - await loadHistory(); - } catch (reloadError) { - console.error('Failed to reload history after cancel:', reloadError); - // Don't downgrade the success toast; loadHistory's own catch - // already paints an error message into the list area. - } - }); + })); }); // Wire the inline Retry button on failed rows the current session @@ -1344,9 +1362,9 @@ function wireRowActionHandlers(container: HTMLElement): void { // The backend may still 409 with an ops_hint or threshold response; // the catch block surfaces the structured detail when present. container.querySelectorAll('.history-retry-btn[data-retry-id]').forEach(btn => { - btn.addEventListener('click', async () => { - const id = btn.dataset['retryId']; - if (!id) return; + const id = btn.dataset['retryId']; + if (!id) return; + btn.addEventListener('click', () => runHistoryRowAction(btn, id, async markMutationSucceeded => { const overThreshold = btn.classList.contains('history-retry-over-threshold'); const ok = await confirmDialog({ title: overThreshold ? 'Retry past threshold?' : 'Retry this failed purchase?', @@ -1357,10 +1375,10 @@ function wireRowActionHandlers(container: HTMLElement): void { destructive: false, }); if (!ok) return; - btn.disabled = true; let retryResult: Awaited>; try { retryResult = await api.retryPurchase(id, overThreshold ? { force: true } : undefined); + markMutationSucceeded(); } catch (retryError) { console.error('Failed to retry purchase:', retryError); // Surface structured retry hints from the backend (issue #47): @@ -1379,7 +1397,6 @@ function wireRowActionHandlers(container: HTMLElement): void { } const finalMessage = detailMessage || err.message || 'unknown error'; showToast({ message: `Failed to retry: ${finalMessage}`, kind: 'error' }); - btn.disabled = false; return; } // Gate the toast on the approval email outcome reported by the backend. @@ -1398,12 +1415,7 @@ function wireRowActionHandlers(container: HTMLElement): void { } else { showToast({ message: 'Retry created but approval email failed - check your notification settings', kind: 'warning', timeout: 8_000 }); } - try { - await loadHistory(); - } catch (reloadError) { - console.error('Failed to reload history after retry:', reloadError); - } - }); + })); }); // Wire the inline Revoke button on completed Azure rows within the @@ -1414,9 +1426,9 @@ function wireRowActionHandlers(container: HTMLElement): void { // UX gate that hides the button when the call would fail, but a stale // cache can still surface a 4xx -- handle it like any other failure. container.querySelectorAll('.history-revoke-btn[data-revoke-id]').forEach(btn => { - btn.addEventListener('click', async () => { - const id = btn.dataset['revokeId']; - if (!id) return; + const id = btn.dataset['revokeId']; + if (!id) return; + btn.addEventListener('click', () => runHistoryRowAction(btn, id, async markMutationSucceeded => { let quote: RevokeQuote | undefined; if (btn.dataset['revokeScheduled'] !== 'true') { try { @@ -1438,24 +1450,17 @@ function wireRowActionHandlers(container: HTMLElement): void { destructive: true, }); if (!ok) return; - const rowActions = sameRowActions(btn); - rowActions.forEach((b) => { b.disabled = true; }); try { await api.revokePurchase(id, quote); + markMutationSucceeded(); } catch (revokeError) { console.error('Failed to revoke purchase:', revokeError); const err = revokeError as Error; showToast({ message: `Failed to revoke: ${err.message || 'unknown error'}`, kind: 'error' }); - rowActions.forEach((b) => { b.disabled = false; }); return; } showToast({ message: 'Purchase revocation submitted', kind: 'success', timeout: 5_000 }); - try { - await loadHistory(); - } catch (reloadError) { - console.error('Failed to reload history after revoke:', reloadError); - } - }); + })); }); // Wire Sell on Marketplace button (issue #292). @@ -1477,116 +1482,111 @@ function wireRowActionHandlers(container: HTMLElement): void { return; } - // Build a pricing modal body with RI summary and fee breakdown. - const bodyEl = document.createElement('div'); - bodyEl.className = 'marketplace-pricing-modal-body'; - - if (purchase) { - // purchase_history.term is stored in YEARS (1 or 3); convert to months - // before computing the remaining term and residual so the price summary - // shown to the user reflects real remaining value rather than ~1/3 of it - // (a 3-year RI was previously treated as 3 months). Mirrors the - // row.Term * 12 conversion in internal/api/handler_marketplace.go. - const termYears = typeof purchase.term === 'number' ? purchase.term : Number(purchase.term) || 0; - const termMonths = termYears > 0 ? termYears * 12 : 0; - const purchaseMs = new Date(purchase.timestamp).getTime(); - const elapsedMonths = Number.isFinite(purchaseMs) - ? (Date.now() - purchaseMs) / (1000 * 60 * 60 * 24 * 30.4375) - : 0; - // Must match computeRemainingMonths in internal/api/handler_marketplace.go - // EXACTLY: floor (not round), floored at 1 (not 0), so the number - // shown here is the same number the backend actually lists at. - const remainingMonths = Math.max(1, Math.floor(termMonths - elapsedMonths)); - const upfront = purchase.upfront_cost; - const count = purchase.count > 0 ? purchase.count : 1; - // Mirror marketplaceResidualPerUnit + resolveMarketplacePriceSchedule's - // default branch in internal/api/handler_marketplace.go EXACTLY, so - // this preview can never diverge from what the backend actually lists: - // - upfront-only: recurring (monthly) cost is deliberately excluded - // because the buyer assumes the recurring obligation post-transfer; - // - per instance: upfront_cost is the row total for `count` instances, - // but the AWS Marketplace price is per instance, so divide by count; - // - prorated: the upfront residual is scaled by remaining/original - // term (a 36-month RI at month 6 retains only 30/36 of its value); - // - zero when unpriceable: a no-upfront RI (upfront <= 0) or an - // unknown term (termMonths <= 0) has no residual to prorate, which - // is exactly when the backend now rejects the default schedule - // with an error instead of silently listing at $0. - const perUnitResidual = termMonths > 0 && upfront > 0 - ? (upfront * (remainingMonths / termMonths)) / count - : 0; - const listPricePerUnit = perUnitResidual * AWS_MARKETPLACE_BUYER_DISCOUNT; - const listPriceTotal = listPricePerUnit * count; - const netProceedsTotal = listPriceTotal * AWS_MARKETPLACE_NET_FACTOR; - - const summaryEl = document.createElement('dl'); - summaryEl.className = 'marketplace-pricing-summary'; - const addRow = (label: string, value: string): void => { - const dt = document.createElement('dt'); - dt.textContent = label; - const dd = document.createElement('dd'); - dd.textContent = value; - summaryEl.appendChild(dt); - summaryEl.appendChild(dd); - }; - addRow('RI ID', id); - addRow('Region', purchase.region || '-'); - addRow('Resource type', purchase.resource_type || '-'); - addRow('Remaining term', remainingMonths === 1 ? '1 month' : `${remainingMonths} months`); - if (listPricePerUnit > 0) { - addRow('Default list price', count > 1 - ? `${formatCurrency(listPricePerUnit)}/unit (${formatCurrency(listPriceTotal)} total for ${count} units)` - : formatCurrency(listPriceTotal)); - addRow(`AWS fee (${AWS_MARKETPLACE_FEE_PERCENT}%)`, formatCurrency(listPriceTotal * (AWS_MARKETPLACE_FEE_PERCENT / 100))); - addRow('Estimated net proceeds', formatCurrency(netProceedsTotal)); - } else { - // No default price can be computed (no upfront cost or unknown - // term) -- listing will be rejected server-side unless a custom - // price_schedule is supplied. Say so instead of showing a - // misleading $0 or fabricated price. - addRow('Default list price', 'unavailable (no upfront cost or unknown term)'); + const upfront = purchase.upfront_cost; + return runHistoryRowAction(btn, id, async markMutationSucceeded => { + // Build a pricing modal body with RI summary and fee breakdown. + const bodyEl = document.createElement('div'); + bodyEl.className = 'marketplace-pricing-modal-body'; + + if (purchase) { + // purchase_history.term is stored in YEARS (1 or 3); convert to months + // before computing the remaining term and residual so the price summary + // shown to the user reflects real remaining value rather than ~1/3 of it + // (a 3-year RI was previously treated as 3 months). Mirrors the + // row.Term * 12 conversion in internal/api/handler_marketplace.go. + const termYears = typeof purchase.term === 'number' ? purchase.term : Number(purchase.term) || 0; + const termMonths = termYears > 0 ? termYears * 12 : 0; + const purchaseMs = new Date(purchase.timestamp).getTime(); + const elapsedMonths = Number.isFinite(purchaseMs) + ? (Date.now() - purchaseMs) / (1000 * 60 * 60 * 24 * 30.4375) + : 0; + // Must match computeRemainingMonths in internal/api/handler_marketplace.go + // EXACTLY: floor (not round), floored at 1 (not 0), so the number + // shown here is the same number the backend actually lists at. + const remainingMonths = Math.max(1, Math.floor(termMonths - elapsedMonths)); + const count = purchase.count > 0 ? purchase.count : 1; + // Mirror marketplaceResidualPerUnit + resolveMarketplacePriceSchedule's + // default branch in internal/api/handler_marketplace.go EXACTLY, so + // this preview can never diverge from what the backend actually lists: + // - upfront-only: recurring (monthly) cost is deliberately excluded + // because the buyer assumes the recurring obligation post-transfer; + // - per instance: upfront_cost is the row total for `count` instances, + // but the AWS Marketplace price is per instance, so divide by count; + // - prorated: the upfront residual is scaled by remaining/original + // term (a 36-month RI at month 6 retains only 30/36 of its value); + // - zero when unpriceable: a no-upfront RI (upfront <= 0) or an + // unknown term (termMonths <= 0) has no residual to prorate, which + // is exactly when the backend now rejects the default schedule + // with an error instead of silently listing at $0. + const perUnitResidual = termMonths > 0 && upfront > 0 + ? (upfront * (remainingMonths / termMonths)) / count + : 0; + const listPricePerUnit = perUnitResidual * AWS_MARKETPLACE_BUYER_DISCOUNT; + const listPriceTotal = listPricePerUnit * count; + const netProceedsTotal = listPriceTotal * AWS_MARKETPLACE_NET_FACTOR; + + const summaryEl = document.createElement('dl'); + summaryEl.className = 'marketplace-pricing-summary'; + const addRow = (label: string, value: string): void => { + const dt = document.createElement('dt'); + dt.textContent = label; + const dd = document.createElement('dd'); + dd.textContent = value; + summaryEl.appendChild(dt); + summaryEl.appendChild(dd); + }; + addRow('RI ID', id); + addRow('Region', purchase.region || '-'); + addRow('Resource type', purchase.resource_type || '-'); + addRow('Remaining term', remainingMonths === 1 ? '1 month' : `${remainingMonths} months`); + if (listPricePerUnit > 0) { + addRow('Default list price', count > 1 + ? `${formatCurrency(listPricePerUnit)}/unit (${formatCurrency(listPriceTotal)} total for ${count} units)` + : formatCurrency(listPriceTotal)); + addRow(`AWS fee (${AWS_MARKETPLACE_FEE_PERCENT}%)`, formatCurrency(listPriceTotal * (AWS_MARKETPLACE_FEE_PERCENT / 100))); + addRow('Estimated net proceeds', formatCurrency(netProceedsTotal)); + } else { + // No default price can be computed (no upfront cost or unknown + // term) -- listing will be rejected server-side unless a custom + // price_schedule is supplied. Say so instead of showing a + // misleading $0 or fabricated price. + addRow('Default list price', 'unavailable (no upfront cost or unknown term)'); + } + bodyEl.appendChild(summaryEl); } - bodyEl.appendChild(summaryEl); - } - const noteEl = document.createElement('p'); - noteEl.className = 'marketplace-pricing-note'; - noteEl.textContent = `AWS charges a ${AWS_MARKETPLACE_FEE_PERCENT}% transaction fee on proceeds. The default schedule prices the listing at ${(1 - AWS_MARKETPLACE_BUYER_DISCOUNT) * 100}% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.`; - bodyEl.appendChild(noteEl); + const noteEl = document.createElement('p'); + noteEl.className = 'marketplace-pricing-note'; + noteEl.textContent = `AWS charges a ${AWS_MARKETPLACE_FEE_PERCENT}% transaction fee on proceeds. The default schedule prices the listing at ${(1 - AWS_MARKETPLACE_BUYER_DISCOUNT) * 100}% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.`; + bodyEl.appendChild(noteEl); - const ok = await confirmDialog({ - title: 'List this RI on the AWS Marketplace?', - body: bodyEl, - confirmLabel: 'Confirm listing', - destructive: false, - }); - if (!ok) return; + const ok = await confirmDialog({ + title: 'List this RI on the AWS Marketplace?', + body: bodyEl, + confirmLabel: 'Confirm listing', + destructive: false, + }); + if (!ok) return; - const rowActions = sameRowActions(btn); - rowActions.forEach(b => { b.disabled = true; }); - try { - await api.createMarketplaceListing(id); - } catch (sellError) { - console.error('Failed to list RI on Marketplace:', sellError); - const err = sellError as Error; - showToast({ message: `Failed to list on Marketplace: ${err.message || 'unknown error'}`, kind: 'error' }); - rowActions.forEach(b => { b.disabled = false; }); - return; - } - showToast({ message: 'RI listed on Marketplace successfully', kind: 'success', timeout: 5_000 }); - try { - await loadHistory(); - } catch (reloadError) { - console.error('Failed to reload history after Marketplace listing:', reloadError); - } + try { + await api.createMarketplaceListing(id); + markMutationSucceeded(); + } catch (sellError) { + console.error('Failed to list RI on Marketplace:', sellError); + const err = sellError as Error; + showToast({ message: `Failed to list on Marketplace: ${err.message || 'unknown error'}`, kind: 'error' }); + return; + } + showToast({ message: 'RI listed on Marketplace successfully', kind: 'success', timeout: 5_000 }); + }); }); }); // Wire Cancel listing button (issue #292) container.querySelectorAll('.history-marketplace-cancel-btn[data-marketplace-cancel-id]').forEach(btn => { - btn.addEventListener('click', async () => { - const id = btn.dataset['marketplaceCancelId']; - if (!id) return; + const id = btn.dataset['marketplaceCancelId']; + if (!id) return; + btn.addEventListener('click', () => runHistoryRowAction(btn, id, async markMutationSucceeded => { const ok = await confirmDialog({ title: 'Cancel this Marketplace listing?', body: 'This will remove the listing from the AWS Marketplace. Any existing buyer negotiations will be cancelled. You can relist the RI at any time.', @@ -1594,24 +1594,17 @@ function wireRowActionHandlers(container: HTMLElement): void { destructive: true, }); if (!ok) return; - const rowActions = sameRowActions(btn); - rowActions.forEach(b => { b.disabled = true; }); try { await api.cancelMarketplaceListing(id); + markMutationSucceeded(); } catch (cancelError) { console.error('Failed to cancel Marketplace listing:', cancelError); const err = cancelError as Error; showToast({ message: `Failed to cancel listing: ${err.message || 'unknown error'}`, kind: 'error' }); - rowActions.forEach(b => { b.disabled = false; }); return; } showToast({ message: 'Marketplace listing cancelled', kind: 'success', timeout: 5_000 }); - try { - await loadHistory(); - } catch (reloadError) { - console.error('Failed to reload history after Marketplace cancel:', reloadError); - } - }); + })); }); } diff --git a/frontend/tests-e2e/history-action-confirmation.spec.ts b/frontend/tests-e2e/history-action-confirmation.spec.ts new file mode 100644 index 00000000..e5e212a4 --- /dev/null +++ b/frontend/tests-e2e/history-action-confirmation.spec.ts @@ -0,0 +1,204 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const ID = '11111111-1111-1111-1111-111111111111'; +const actions = ['approve', 'cancel', 'retry', 'revoke', 'marketplace-sell', 'marketplace-cancel'] as const; +type Action = typeof actions[number]; + +function gate(): { promise: Promise; release(): void } { + let release!: () => void; + const promise = new Promise(resolve => { release = resolve; }); + return { promise, release }; +} + +async function historyFixture(page: Page, action: Action) { + await seedAuth(page); + await mockApi(page); + const row = { + purchase_id: ID, timestamp: new Date().toISOString(), provider: action === 'revoke' ? 'azure' : 'aws', + service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 1, + upfront_cost: 1200, monthly_cost: 50, estimated_savings: 10, offering_class: 'standard', + status: action === 'retry' ? 'failed' : ['approve', 'cancel'].includes(action) ? 'pending' : 'completed', + account_id: 'acct-001', created_by_user_id: 'user-smoke', retry_attempt_n: 5, + revocation_window_closes_at: new Date(Date.now() + 86400000).toISOString(), + listing_state: action === 'marketplace-cancel' ? 'active' : '', + }; + const posts: { url: string; body: string | null }[] = []; + let failNext = false; + await page.route('**/api/auth/me/permissions', route => route.fulfill({ json: { permissions: + ['admin', 'approve-any', 'retry-any', 'cancel-any', 'revoke-any', 'sell-any'].map(verb => ({ action: verb, resource: verb === 'admin' ? '*' : 'purchases' })), + } })); + await page.route('**/api/info/deployment', route => route.fulfill({ json: {} })); + await page.route('**/api/history**', route => route.fulfill({ json: { summary: {}, purchases: [row] } })); + await page.route('**/api/purchases/**', async route => { + if (route.request().method() === 'POST') { + posts.push({ url: route.request().url(), body: route.request().postData() }); + const failed = failNext; + failNext = false; + if (!failed) { row.status = 'cancelled'; row.listing_state = ''; } + await route.fulfill({ status: failed ? 500 : 200, json: failed ? { error: 'synthetic mutation failure' } : { status: 'pending', email_sent: true, execution_id: 'new-execution' } }); + } else { + await route.fulfill({ json: route.request().url().includes('/calculate') + ? { refund_amount: 100, refund_currency: 'USD' } + : { execution_id: ID, status: 'pending', recommendations: [] } }); + } + }); + return { row, posts, failNextMutation: () => { failNext = true; } }; +} + +for (const action of actions) { + test(`${action}: asynchronous confirmation, dismissal, API failure and explicit retry`, async ({ page }) => { + const fixture = await historyFixture(page, action); + const pageErrors: string[] = []; + page.on('pageerror', error => pageErrors.push(error.message)); + await page.goto('/purchases/history'); + const btn = page.locator(`#history-list .history-${action}-btn`); + await btn.click(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + await expect(btn).toBeDisabled(); + // DOM re-entry exercises the handler even though the modal blocks pointer clicks. + await btn.dispatchEvent('click'); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect(btn).toBeEnabled(); + await expect(btn).toBeFocused(); + expect(fixture.posts).toHaveLength(0); + for (const dismiss of ['.modal-confirm-close', '.modal-confirm-actions .btn-secondary', '.modal-confirm-backdrop']) { + await btn.click(); + await page.locator(dismiss).click({ position: { x: 5, y: 5 } }); + await expect(btn).toBeEnabled(); + await expect(btn).toBeFocused(); + expect(fixture.posts).toHaveLength(0); + } + fixture.failNextMutation(); + await btn.click(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + await page.keyboard.press('Enter'); + await expect.poll(() => fixture.posts.length).toBe(1); + await expect(btn).toBeEnabled(); + await btn.click(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + await page.keyboard.press('Enter'); + await expect.poll(() => fixture.posts.length).toBe(2); + await expect(btn).toHaveCount(0); + expect(fixture.posts.every(post => post.url.includes(ID))).toBe(true); + if (action === 'retry') expect(fixture.posts[1]!.url).toContain('force=true'); + if (action === 'revoke') expect(JSON.parse(fixture.posts[1]!.body!)).toMatchObject({ expected_refund_amount: 100, expected_refund_currency: 'USD' }); + expect(pageErrors).toEqual([]); + }); +} + +test('actual double-click and second projection cannot duplicate pending approval details', async ({ page }) => { + const fixture = await historyFixture(page, 'approve'); + const details = gate(); + let detailGets = 0; + await page.route(`**/api/purchases/${ID}`, async route => { + detailGets++; + await details.promise; + await route.fulfill({ json: { execution_id: ID, status: 'pending', recommendations: [] } }); + }); + await page.goto('/purchases/history'); + const btn = page.locator('#history-list .history-approve-btn'); + await btn.dblclick(); + await expect(btn).toBeDisabled(); + await expect(page.locator('#history-list .history-cancel-btn')).toBeDisabled(); + await page.locator('#purchases-approval-queue .history-approve-btn').click(); + details.release(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + expect(detailGets).toBe(1); + await page.keyboard.press('Enter'); + await expect.poll(() => fixture.posts.length).toBe(1); +}); + +test('canceling an independent row does not steal focus from another confirmation', async ({ page }) => { + const fixture = await historyFixture(page, 'approve'); + const details = gate(); + await page.route('**/api/history**', route => route.fulfill({ json: { summary: {}, purchases: [fixture.row, { ...fixture.row, purchase_id: 'other-execution' }] } })); + await page.route('**/api/purchases/*', async route => { + await details.promise; + await route.fulfill({ json: { status: 'pending', recommendations: [] } }); + }); + await page.goto('/purchases/history'); + const buttons = page.locator('#history-list .history-approve-btn'); + await buttons.nth(0).click(); + await buttons.nth(1).click(); + details.release(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(2); + await page.locator('.modal-confirm-close').last().click(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + await expect(page.locator('.modal-confirm-actions .btn-primary')).toBeFocused(); + await page.locator('.modal-confirm-close').click(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(0); + expect(fixture.posts).toHaveLength(0); +}); + +test('success survives failed refresh, cached redraw and old GET; fresh same-ID revoke remains usable', async ({ page }) => { + const fixture = await historyFixture(page, 'approve'); + const oldGet = gate(); + let holdNext = false; + let held = false; + let failHistory = false; + await page.route('**/api/history**', async route => { + const snapshot = { ...fixture.row }; + if (holdNext) { + holdNext = false; + held = true; + await oldGet.promise; + await route.fulfill({ json: { summary: {}, purchases: [snapshot] } }); + } else if (failHistory) { + await route.fulfill({ status: 500, json: { error: 'synthetic history failure' } }); + } else await route.fulfill({ json: { summary: {}, purchases: [fixture.row] } }); + }); + await page.goto('/purchases/history'); + await expect(page.locator('.history-approve-btn')).toHaveCount(2); + holdNext = true; + await page.getByRole('button', { name: 'Load History', exact: true }).click(); + await expect.poll(() => held).toBe(true); + await page.locator('#history-amortize-checkbox').check(); + await page.locator('#history-list .history-approve-btn').click(); + failHistory = true; + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + await page.keyboard.press('Enter'); + await expect(page.locator('#history-list .error')).toBeVisible(); + const oldResponse = page.waitForResponse(response => response.url().includes('/api/history') && response.status() === 200); + oldGet.release(); + await (await oldResponse).finished(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve()))); + await page.locator('#history-amortize-checkbox').uncheck(); + await expect(page.locator('.history-approve-btn')).toHaveCount(0); + expect(fixture.posts).toHaveLength(1); + failHistory = false; + fixture.row.provider = 'azure'; + fixture.row.status = 'scheduled'; + await page.getByRole('button', { name: 'Load History', exact: true }).click(); + await page.locator('#history-list .history-revoke-btn').click(); + await expect(page.locator('.modal-confirm-backdrop')).toHaveCount(1); + await page.keyboard.press('Enter'); + await expect.poll(() => fixture.posts.length).toBe(2); + expect(fixture.posts[1]!.url).toContain(`/purchases/${ID}/revoke`); +}); + +test('completed revoke owns its row before refund quote and recovers from quote failure', async ({ page }) => { + const fixture = await historyFixture(page, 'revoke'); + const quote = gate(); + let quoteGets = 0; + await page.route(`**/api/purchases/${ID}/revoke/calculate`, async route => { + quoteGets++; + if (quoteGets === 1) { + await quote.promise; + await route.fulfill({ status: 500, json: { error: 'synthetic quote failure' } }); + } else await route.fulfill({ json: { refund_amount: 100, refund_currency: 'USD' } }); + }); + await page.goto('/purchases/history'); + const btn = page.locator('#history-list .history-revoke-btn'); + await btn.dblclick(); + await expect(btn).toBeDisabled(); + quote.release(); + await expect(btn).toBeEnabled(); + expect(quoteGets).toBe(1); + expect(fixture.posts).toHaveLength(0); + await btn.click(); + await expect(page.locator('.modal-confirm-body')).toContainText('100.00 USD'); + await page.keyboard.press('Enter'); + await expect.poll(() => fixture.posts.length).toBe(1); +});