Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/__tests__/app-fanout-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/__tests__/app-submit-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
Expand Down
144 changes: 144 additions & 0 deletions frontend/src/__tests__/history-refused-purchase.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
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 = `
<input type="date" id="history-start"><input type="date" id="history-end">
<div id="history-summary"></div><div id="history-list"></div>
<div id="purchases-approval-queue"></div>`;
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');
});
});
74 changes: 74 additions & 0 deletions frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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'));

Expand Down
21 changes: 21 additions & 0 deletions frontend/src/__tests__/refused-purchase-css.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
1 change: 1 addition & 0 deletions frontend/src/__tests__/sidebar-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jest.mock('../dashboard', () => ({
}));
jest.mock('../recommendations', () => ({
setupRecommendationsHandlers: jest.fn(),
getPurchaseModalSession: () => 1,
getPurchaseModalRecommendations: jest.fn(),
clearPurchaseModalRecommendations: jest.fn(),
getFanOutBuckets: jest.fn(),
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -452,6 +452,7 @@ export async function handleExecutePurchase(): Promise<void> {
}

const localRecs = getPurchaseModalRecommendations();
const modalSession = getPurchaseModalSession();
if (localRecs.length === 0) {
showToast({ message: 'No recommendations selected for purchase.', kind: 'warning' });
return;
Expand Down Expand Up @@ -610,11 +611,27 @@ export async function handleExecutePurchase(): Promise<void> {
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
Expand Down
Loading
Loading