From a30aca96825c0fbc8a11b485a8d614a36f705b8d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 1 Oct 2026 21:52:06 +0200 Subject: [PATCH] fix(history): guard purchase actions before confirmation Lock each execution before details loading and confirmation so repeated clicks cannot submit duplicate or conflicting purchase actions. Invalidate completed action caches and ignore older history responses while preserving later authoritative actions and dismissal focus. Closes #249 --- .../__tests__/history-approve-button.test.ts | 140 +++++++- .../__tests__/history-cancel-button.test.ts | 25 ++ .../history-marketplace-sell-button.test.ts | 50 +++ .../__tests__/history-retry-button.test.ts | 25 ++ .../__tests__/history-revoke-button.test.ts | 25 ++ frontend/src/history.ts | 339 +++++++++--------- .../history-action-confirmation.spec.ts | 204 +++++++++++ 7 files changed, 633 insertions(+), 175 deletions(-) create mode 100644 frontend/tests-e2e/history-action-confirmation.spec.ts 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); +});