diff --git a/frontend/src/__tests__/app-fanout-retry.test.ts b/frontend/src/__tests__/app-fanout-retry.test.ts index 3300e23c8..de1b094f8 100644 --- a/frontend/src/__tests__/app-fanout-retry.test.ts +++ b/frontend/src/__tests__/app-fanout-retry.test.ts @@ -27,6 +27,7 @@ jest.mock('../archera', () => ({ handleArcheraDeeplink: jest.fn(), openArcheraOf jest.mock('../modal', () => ({ closeModal: jest.fn() })); jest.mock('../recommendations', () => ({ setupRecommendationsHandlers: jest.fn(), + getPurchaseModalSession: () => 1, getPurchaseModalRecommendations: () => [], clearPurchaseModalRecommendations: jest.fn(), getFanOutBuckets: () => (current ? current.map((b) => ({ ...b })) : null), diff --git a/frontend/src/__tests__/app-submit-toast.test.ts b/frontend/src/__tests__/app-submit-toast.test.ts index 2d4aec884..58054972a 100644 --- a/frontend/src/__tests__/app-submit-toast.test.ts +++ b/frontend/src/__tests__/app-submit-toast.test.ts @@ -26,6 +26,7 @@ jest.mock('../archera', () => ({ handleArcheraDeeplink: jest.fn(), openArcheraOf jest.mock('../modal', () => ({ closeModal: jest.fn() })); jest.mock('../recommendations', () => ({ setupRecommendationsHandlers: jest.fn(), + getPurchaseModalSession: () => 1, getPurchaseModalRecommendations: () => [ { service: 'ec2', provider: 'aws', region: 'us-east-1', resource_type: 'm5.large', count: 1, term: 1, payment: 'no-upfront', upfront_cost: 0 }, ], diff --git a/frontend/src/__tests__/history-refused-purchase.test.ts b/frontend/src/__tests__/history-refused-purchase.test.ts new file mode 100644 index 000000000..a975de809 --- /dev/null +++ b/frontend/src/__tests__/history-refused-purchase.test.ts @@ -0,0 +1,144 @@ +/** + * Refused-purchase presentation in Purchase History (issue #1694): + * clamped status text, muted savings on rows that bought nothing, and the + * Total Purchases card counting completed purchases only. + */ +import { loadHistory, purchaseHistoryNumericCellValue } from '../history'; + +jest.mock('../api', () => ({ + getHistory: jest.fn(), + getConfig: jest.fn().mockResolvedValue({ global: {} }), +})); +jest.mock('../navigation', () => ({ switchTab: jest.fn() })); +jest.mock('../permissions', () => ({ canAccess: jest.fn().mockReturnValue(true) })); +jest.mock('../utils', () => ({ + NO_VALUE: '\u2014', + formatCurrency: jest.fn((v) => (v === null || v === undefined || isNaN(v)) ? '—' : `$${v}`), + formatDate: jest.fn((v) => (v ? new Date(v).toLocaleDateString() : '')), + formatTerm: jest.fn((y) => (y == null ? '' : `${y} Year${y === 1 ? '' : 's'}`)), + escapeHtml: jest.fn((s) => s || ''), + escapeHtmlAttr: jest.fn((s: string | null | undefined) => (s || '').replace(/"/g, '"')), + amortizedMonthly: jest.requireActual('../utils').amortizedMonthly, + populateAccountFilter: jest.fn(() => Promise.resolve()), +})); +jest.mock('../state', () => ({ + getCurrentUser: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue('all'), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), + getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}), + setPurchaseHistoryColumnFilter: jest.fn(), + clearAllPurchaseHistoryColumnFilters: jest.fn(), + getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}), + setApprovalQueueColumnFilter: jest.fn(), + clearAllApprovalQueueColumnFilters: jest.fn(), +})); + +import * as api from '../api'; + +const row = (o: Record) => ({ + purchase_id: 'p1', timestamp: '2026-11-04T00:00:00Z', provider: 'aws', service: 'ec2', + resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 1, + upfront_cost: 0, monthly_cost: 50, estimated_savings: 10, status: 'completed', ...o, +}); + +beforeEach(() => { + document.body.innerHTML = ` + +
+
`; + jest.clearAllMocks(); +}); + +describe('status description (issue #1694)', () => { + const long = 'AuthFailure: '.repeat(80); + test('is clamped and the full text stays reachable via title', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [ + row({ status: 'failed', status_description: long }), + ] }); + await loadHistory(); + const el = document.querySelector('#history-list td[data-label="Status"] .history-status-desc'); + expect(el).not.toBeNull(); + expect(el?.getAttribute('title')).toBe(long); + }); + + test('a long description is also reachable by keyboard and touch via a disclosure', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [ + row({ status: 'failed', status_description: long }), + ] }); + await loadHistory(); + const details = document.querySelector('#history-list td[data-label="Status"] details.history-status-full'); + expect(details?.querySelector('summary')).not.toBeNull(); + expect(details?.textContent).toContain(long); + }); + + test('a description clamped only in the phone layout (~100 chars) also has a disclosure', async () => { + const medium = 'purchase failed: '.repeat(6); + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [ + row({ status: 'failed', status_description: medium }), + ] }); + await loadHistory(); + expect(document.querySelector('#history-list details.history-status-full')?.textContent).toContain(medium); + }); + + test('a short description has no disclosure', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [ + row({ status: 'failed', status_description: 'the provider returned an error' }), + ] }); + await loadHistory(); + expect(document.querySelector('#history-list details.history-status-full')).toBeNull(); + }); +}); + +describe('savings value used by sort and filter (issue #1694)', () => { + test.each([ + ['failed', { status: 'failed' }], + ['revoked', { status: 'completed', revoked_at: '2026-11-04T01:00:00Z' }], + ])('%s row counts as no saving', (_name, o) => { + expect(purchaseHistoryNumericCellValue(row(o) as never, 'savings')).toBe(0); + expect(purchaseHistoryNumericCellValue(row({}) as never, 'savings')).toBe(10); + }); +}); + +describe('Monthly Savings on rows that bought nothing (issue #1694)', () => { + test.each(['failed', 'cancelled', 'expired'])('%s row is not styled as a saving', async (status) => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [ + row({ purchase_id: 'x', status }), + row({ purchase_id: 'ok', status: 'completed' }), + ] }); + await loadHistory(); + const cell = (id: string) => document.querySelector(`#history-list tr[data-execution-id="${id}"] td[data-label="Monthly Savings"]`); + expect(cell('x')?.classList.contains('savings')).toBe(false); + expect(cell('x')?.textContent).toBe('—'); + expect(cell('ok')?.classList.contains('savings')).toBe(true); + expect(cell('ok')?.textContent).toBe('$10'); + }); + + test('a revoked completed row is not styled as a saving', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [ + row({ purchase_id: 'rv', status: 'completed', revoked_at: '2026-11-04T01:00:00Z' }), + ] }); + await loadHistory(); + const cell = document.querySelector('#history-list tr[data-execution-id="rv"] td[data-label="Monthly Savings"]'); + expect(cell?.classList.contains('savings')).toBe(false); + }); +}); + +describe('Total Purchases card (issue #1694)', () => { + test('counts completed purchases, not failed or cancelled rows', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: { total_purchases: 4, total_completed: 0, total_failed: 1, total_pending: 0 }, + purchases: [], + }); + await loadHistory(); + const card = Array.from(document.querySelectorAll('#history-summary .card')) + .find((c) => c.querySelector('h3')?.textContent === 'Total Purchases'); + expect(card?.querySelector('.value')?.textContent).toBe('0'); + }); +}); diff --git a/frontend/src/__tests__/purchase-execution-toast.test.ts b/frontend/src/__tests__/purchase-execution-toast.test.ts index d60e94e96..acfa21f44 100644 --- a/frontend/src/__tests__/purchase-execution-toast.test.ts +++ b/frontend/src/__tests__/purchase-execution-toast.test.ts @@ -43,6 +43,7 @@ jest.mock('../navigation', () => ({ jest.mock('../recommendations', () => ({ setupRecommendationsHandlers: jest.fn(), getPurchaseModalRecommendations: jest.fn(), + getPurchaseModalSession: jest.fn().mockReturnValue(1), clearPurchaseModalRecommendations: jest.fn(), getFanOutBuckets: jest.fn(), clearFanOutBuckets: jest.fn(), @@ -285,6 +286,79 @@ describe('handleExecutePurchase — single-record path', () => { expect(archera.openArcheraOfferModal).toHaveBeenCalledWith('purchase'); }); + // Issue #1694: a recorded failed execution must not leave Execute armed. + test('definite failure with an execution_id closes the modal', async () => { + const err = Object.assign(new Error('AWS refused the purchase'), { + status: 403, details: { execution_id: 'exec-failed' }, + }); + (api.executePurchase as jest.Mock).mockRejectedValue(err); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(plans.closePurchaseModal).toHaveBeenCalledTimes(1); + expect(recs.clearPurchaseModalRecommendations).toHaveBeenCalled(); + expect(lastToastKind()).toBe('error'); + }); + + // A 5xx (e.g. a run whose final status could not be saved) or a 409 after the + // row was claimed may follow a real purchase: never leave Execute armed. + test.each([ + ['audit-loss 500', 500], + ['stale-pricing 409', 409], + ])('%s without an execution_id closes the modal', async (_name, status) => { + (api.executePurchase as jest.Mock).mockRejectedValue( + Object.assign(new Error('execution ran but its final status could not be saved'), { status }), + ); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(plans.closePurchaseModal).toHaveBeenCalledTimes(1); + expect(recs.clearPurchaseModalRecommendations).toHaveBeenCalled(); + }); + + test('a 400 validation refusal keeps the modal open so the user can fix the input', async () => { + (api.executePurchase as jest.Mock).mockRejectedValue( + Object.assign(new Error('capacity must be between 1 and 100'), { status: 400 }), + ); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(plans.closePurchaseModal).not.toHaveBeenCalled(); + }); + + test('a late failure does not close a purchase modal opened after the request was sent', async () => { + let reject: (e: unknown) => void = () => {}; + (api.executePurchase as jest.Mock).mockReturnValue(new Promise((_res, rej) => { reject = rej; })); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + // The user closed the modal and opened a new purchase while the first was in flight. + (recs.getPurchaseModalSession as jest.Mock).mockReturnValue(2); + reject(Object.assign(new Error('refused'), { status: 403, details: { execution_id: 'exec-old' } })); + await new Promise((r) => setTimeout(r, 0)); + + expect(plans.closePurchaseModal).not.toHaveBeenCalled(); + expect(recs.clearPurchaseModalRecommendations).not.toHaveBeenCalled(); + expect(lastToastKind()).toBe('error'); + }); + + test('failure without an execution_id keeps the modal open for retry', async () => { + (api.executePurchase as jest.Mock).mockRejectedValue(new Error('network down')); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(plans.closePurchaseModal).not.toHaveBeenCalled(); + }); + test('does NOT open Archera offer modal when the approval submission throws', async () => { (api.executePurchase as jest.Mock).mockRejectedValue(new Error('boom')); diff --git a/frontend/src/__tests__/refused-purchase-css.test.ts b/frontend/src/__tests__/refused-purchase-css.test.ts new file mode 100644 index 000000000..de069ee5a --- /dev/null +++ b/frontend/src/__tests__/refused-purchase-css.test.ts @@ -0,0 +1,21 @@ +/** Height caps for long failure text (issue #1694); jsdom has no layout, so assert the CSS rules. */ +import * as fs from 'fs'; +import * as path from 'path'; + +const css = fs.readFileSync(path.join(__dirname, '../styles/components.css'), 'utf8'); +const rule = (sel: string): string => { + const m = css.match(new RegExp(`(?:^|\\n)${sel.replace('.', '\\.')}\\s*\\{([^}]*)\\}`)); + return m?.[1] ?? ''; +}; + +test('toast message is height-capped and scrolls', () => { + const r = rule('.toast-message'); + expect(r).toMatch(/max-height:\s*\d/); + expect(r).toMatch(/overflow-y:\s*auto/); +}); + +test('history status description is line-clamped', () => { + const r = rule('.history-status-desc'); + expect(r).toMatch(/-webkit-line-clamp:\s*\d/); + expect(r).toMatch(/overflow:\s*hidden/); +}); diff --git a/frontend/src/__tests__/sidebar-anchors.test.ts b/frontend/src/__tests__/sidebar-anchors.test.ts index d1b2cd720..1257a60d4 100644 --- a/frontend/src/__tests__/sidebar-anchors.test.ts +++ b/frontend/src/__tests__/sidebar-anchors.test.ts @@ -30,6 +30,7 @@ jest.mock('../dashboard', () => ({ })); jest.mock('../recommendations', () => ({ setupRecommendationsHandlers: jest.fn(), + getPurchaseModalSession: () => 1, getPurchaseModalRecommendations: jest.fn(), clearPurchaseModalRecommendations: jest.fn(), getFanOutBuckets: jest.fn(), diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 9d68b7409..8419b4a73 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -6,7 +6,7 @@ import * as api from './api'; import * as state from './state'; import { showLoginModal, showAdminSetupModal, showResetPasswordModal, updateUserUI } from './auth'; import { loadDashboard, setupDashboardHandlers } from './dashboard'; -import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, fanOutBucketAccountLabel, getAccountName, type FanOutBucket } from './recommendations'; +import { setupRecommendationsHandlers, getPurchaseModalRecommendations, getPurchaseModalSession, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, fanOutBucketAccountLabel, getAccountName, type FanOutBucket } from './recommendations'; import { switchTab, setActiveNav, applyTabFromPath, initRouter, switchSettingsSubTab, canonicalTabPath } from './navigation'; import { savePlan, setupPlanHandlers, closePlanModal, openNewPlanModal, closePurchaseModal } from './plans'; import { saveGlobalSettings, setupSettingsHandlers, resetSettings } from './settings'; @@ -452,6 +452,7 @@ export async function handleExecutePurchase(): Promise { } const localRecs = getPurchaseModalRecommendations(); + const modalSession = getPurchaseModalSession(); if (localRecs.length === 0) { showToast({ message: 'No recommendations selected for purchase.', kind: 'warning' }); return; @@ -610,11 +611,27 @@ export async function handleExecutePurchase(): Promise { const err = error as Error; const verb = isDirect ? 'execute' : 'send for approval'; showToast({ message: `Failed to ${verb} purchase: ${err.message}`, kind: 'error' }); + // A recorded execution, a 409 after the claim or any 5xx may follow a real + // purchase: close the modal so one more click can't start another. Only + // act on the modal session that sent this request. + if (attemptMayHaveRun(err) && getPurchaseModalSession() === modalSession) { + closePurchaseModal(); + clearPurchaseModalRecommendations(); + clearExecuteMode(); + } } finally { setExecutePurchaseSubmitting(executeBtn, false, executeButtonLabel(getExecuteMode() === 'direct')); } } +// attemptMayHaveRun is false only for transport failures and 4xx refusals +// other than 409, which the server answers before any execution. +function attemptMayHaveRun(err: unknown): boolean { + const e = err as { status?: number; details?: { execution_id?: unknown } }; + const status = e.status ?? 0; + return Boolean(e.details?.execution_id) || status >= 500 || status === 409; +} + /** * fanOutBucketLabel renders a short human identifier for a fan-out bucket so a * partial-failure toast can name which buckets created pending executions diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 99b00219a..c6e82a354 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -98,6 +98,31 @@ async function refreshFourEyesMode(): Promise { if (banner) banner.classList.toggle('hidden', !_fourEyesMode); } +// Rows that bought nothing must not show their estimate as a realized saving. +const NO_SAVINGS_STATUSES = new Set(['failed', 'cancelled', 'canceled', 'expired']); + +// A revoked purchase keeps its "completed" status but no longer saves anything. +function boughtNothing(p: HistoryPurchase): boolean { + return Boolean(p.revoked_at) || NO_SAVINGS_STATUSES.has(normalizeStatus(p).toLowerCase()); +} + +function noSavingsCell(p: HistoryPurchase, empty: boolean): string { + if (empty || boughtNothing(p)) { + return `${MUTED_DASH}`; + } + return `${formatCurrency(p.estimated_savings)}`; +} + +// About what the 4-line clamp shows in the narrowest (phone card) layout; +// longer text also gets a disclosure, since a title tooltip is unreachable by +// keyboard and touch. +const STATUS_CLAMP_CHARS = 80; + +function statusFullTextHtml(text: string): string { + if (text.length <= STATUS_CLAMP_CHARS) return ''; + return `
Full message
${escapeHtml(text)}
`; +} + function normalizeStatus(p: HistoryPurchase): string { // Absent status → legacy DB row → counts as completed for filtering. return p.status || 'completed'; @@ -537,17 +562,19 @@ function renderHistorySummary(summary: HistorySummary | null): void { // summary renders sensibly against an older API deploy that hasn't shipped // the new counters yet. const total = summary.total_purchases ?? null; - const totalDisplay = total !== null ? String(total) : NO_VALUE; + // Failed, cancelled and expired rows bought nothing, so the card counts + // completed purchases only (matches Total Upfront Spent). const completed = summary.total_completed ?? total; + const completedDisplay = completed !== null ? String(completed) : NO_VALUE; const pending = summary.total_pending ?? 0; const detail = (total !== null && pending > 0) - ? `

${completed} completed · ${pending} pending

` + ? `

${pending} pending

` : ''; container.innerHTML = `

Total Purchases

-

${totalDisplay}

+

${completedDisplay}

${detail}
@@ -1209,14 +1236,14 @@ function purchaseHistoryCategoricalCellValue( } } -function purchaseHistoryNumericCellValue( +export function purchaseHistoryNumericCellValue( p: HistoryPurchase, col: PurchaseHistoryColumnId, ): number { switch (col) { case 'count': return p.count ?? 0; case 'upfront_cost': return p.upfront_cost ?? Number.NaN; - case 'savings': return p.estimated_savings ?? 0; + case 'savings': return boughtNothing(p) ? 0 : p.estimated_savings ?? 0; case 'provider': case 'service': case 'resource_type': @@ -1406,7 +1433,7 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { return `${badge}
awaiting approval from ${escapeHtml(p.approver)}
`; } if (p.status_description) { - return `${badge}
${escapeHtml(p.status_description)}
${opsHintHtml(p)}`; + return `${badge}
${escapeHtml(p.status_description)}
${statusFullTextHtml(p.status_description)}${opsHintHtml(p)}`; } return `${badge}${opsHintHtml(p)}`; })(); @@ -1433,7 +1460,7 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { ${termCell(p)} ${empty ? `${MUTED_DASH}` : upfrontCell(p, amortize)} ${dashOr(monthlyCostCell)} - ${dashOr(formatCurrency(p.estimated_savings))} + ${noSavingsCell(p, empty)} ${planCellContent} `; diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 6452527f3..83f1f981e 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -69,6 +69,11 @@ let checkedPurchaseModalInitialised = false; // it after its override fetch so only the newest open renders and sets // submit state (issue #430). let purchaseModalOpenToken = 0; + +/** Identifies the current purchase-modal session; changes on every open. */ +export function getPurchaseModalSession(): number { + return purchaseModalOpenToken; +} // Cache of account ID → name for column display let accountNamesCache: Map = new Map(); // Whether the last account-list fetch returned any accounts; null when it diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index b38adc2cd..6588d6191 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -410,6 +410,9 @@ button.secondary:hover { .toast-message { line-height: 1.35; word-break: break-word; + /* A provider error body can be huge; scroll instead of covering the page. */ + max-height: 8rem; + overflow-y: auto; } .toast-actions { @@ -1513,6 +1516,29 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { overflow-wrap: anywhere; } +/* Clamp long failure text; the full text is in the title and, when clamped, + in a keyboard- and touch-reachable disclosure below it. */ +.history-status-desc { + max-width: 18rem; + overflow: hidden; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; +} + +.history-status-full { + max-width: 18rem; + font-size: 0.85em; + overflow-wrap: anywhere; +} + +.history-status-full > div { + max-height: 12rem; + overflow-y: auto; +} + .history-approver strong { color: var(--cudly-text); font-weight: var(--cudly-fw-semibold); diff --git a/frontend/tests-e2e/purchase-failure-closes-modal.spec.ts b/frontend/tests-e2e/purchase-failure-closes-modal.spec.ts new file mode 100644 index 000000000..298565ea6 --- /dev/null +++ b/frontend/tests-e2e/purchase-failure-closes-modal.spec.ts @@ -0,0 +1,49 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +// Issue #1694: after a failed execute that a purchase may have followed, the +// review modal must close so one more click cannot start a second attempt. +// Runs the real recommendations module, whose selection getter returns a new +// array on every call. +const row = { + id: 'refused-ec2', provider: 'aws', service: 'ec2', cloud_account_id: 'acct-001', + resource_type: 'm5.large', region: 'us-east-1', payment: 'no-upfront', term: 1, count: 1, + upfront_cost: 0, monthly_cost: 40, savings: 20, on_demand_monthly: 60, effective_savings_pct: 33, +}; + +async function executeWith(page: Page, status: number, body: Record) { + await mockApi(page); + await seedAuth(page); + await page.route(/\/api\/recommendations(?:\?.*)?$/, route => route.fulfill({ json: { + recommendations: [row], summary: { total_recommendations: 1, total_upfront_cost: 0, potential_monthly_savings: 20, avg_payback_months: 0 }, regions: [row.region], + } })); + await page.route('**/api/accounts/*/service-overrides', route => route.fulfill({ json: [] })); + await page.route('**/api/purchases/execute', route => route.fulfill({ status, json: body })); + await page.goto('/opportunities'); + const selected = page.locator(`tr.recommendation-row[data-rec-id="${row.id}"]`); + await selected.getByRole('checkbox', { name: /^Select / }).check(); + await page.locator('#bulk-purchase-btn').click(); + await page.locator('#execute-mode-direct').check(); + await page.locator('#execute-purchase-btn').click(); + const confirm = page.locator('.confirm-dialog, [role="alertdialog"], [role="dialog"]').filter({ hasText: 'bypasses the approval step' }).last(); + await confirm.getByRole('button').filter({ hasNotText: /cancel/i }).last().click(); +} + +for (const [name, status, body] of [ + ['a recorded failure (502 with execution_id)', 502, { error: 'execution x failed', execution_id: 'exec-1', status: 'failed' }], + ['a refusal recorded as an execution (403 with execution_id)', 403, { error: 'execution x failed', execution_id: 'exec-2', status: 'failed' }], + ['an audit-loss 500 without execution_id', 500, { error: 'execution x ran but its final status could not be saved' }], + ['a 409 after the claim', 409, { error: 'pricing changed' }], +] as const) { + test(`${name} closes the review modal`, async ({ page }) => { + await executeWith(page, status, body); + await expect(page.locator('.toast').filter({ hasText: 'Failed to execute purchase' })).toBeVisible(); + await expect(page.locator('#purchase-modal')).toBeHidden(); + }); +} + +test('a 400 validation refusal keeps the review modal open', async ({ page }) => { + await executeWith(page, 400, { error: 'capacity must be between 1 and 100' }); + await expect(page.locator('.toast').filter({ hasText: 'Failed to execute purchase' })).toBeVisible(); + await expect(page.locator('#purchase-modal')).toBeVisible(); +});