Skip to content
88 changes: 88 additions & 0 deletions frontend/src/__tests__/commitmentSummary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { buildCommitmentSummary, commitmentTotals, paymentLabel, withIntro, type CommitmentLine } from '../commitmentSummary';

const line = (over: Partial<CommitmentLine> = {}): CommitmentLine => ({
service: 'ec2',
resourceType: 'm5.large',
count: 2,
term: 3,
payment: 'no-upfront',
upfront: 0,
monthly: 100,
...over,
});

describe('commitmentTotals', () => {
it('sums upfront, monthly and the term total (3 years = 36 months)', () => {
const t = commitmentTotals([line({ upfront: 500, monthly: 100 }), line({ upfront: 0, monthly: 50, term: 1 })]);
expect(t.upfront).toBe(500);
expect(t.monthly).toBe(150);
expect(t.termTotal).toBe(500 + 100 * 36 + 50 * 12);
});

it('is null for monthly and term total when any line has no monthly cost', () => {
const t = commitmentTotals([line(), line({ monthly: null, upfront: 10 })]);
expect(t.upfront).toBe(10);
expect(t.monthly).toBeNull();
expect(t.termTotal).toBeNull();
});
});

describe('summary options and hints', () => {
it('relabels the upfront stat for the approval path', () => {
const text = buildCommitmentSummary([line({ upfront: 8400 })], { upfrontLabel: 'Upfront on approval' }).textContent;
expect(text).toContain('Upfront on approval$8,400.00');
expect(text).not.toContain('Charged today');
});

it('treats the Azure "upfront" spelling as all upfront (no monthly-fee hint)', () => {
const text = buildCommitmentSummary([line({ monthly: null, payment: 'upfront' })]).textContent;
expect(text).toContain('MonthlyNot reported');
expect(text).not.toContain('fees apply');
expect(text).toContain('All upfront');
});

it('hints that monthly fees apply for non all-upfront lines with no monthly figure', () => {
expect(buildCommitmentSummary([line({ monthly: null, payment: 'partial-upfront' })]).textContent)
.toContain('MonthlyNot reported (monthly fees apply)');
expect(buildCommitmentSummary([line({ monthly: null, payment: 'all-upfront' })]).textContent)
.toContain('MonthlyNot reported');
expect(buildCommitmentSummary([line({ monthly: null, payment: 'all-upfront' })]).textContent)
.not.toContain('fees apply');
});
});

describe('paymentLabel', () => {
it.each([
['all-upfront', 'All upfront'],
['partial_upfront', 'Partial upfront'],
['no-upfront', 'No upfront'],
['upfront', 'All upfront'],
['monthly', 'Monthly'],
['custom', 'custom'],
])('%s -> %s', (raw, label) => expect(paymentLabel(raw)).toBe(label));
});

describe('buildCommitmentSummary', () => {
it('shows charged today, monthly and total over term', () => {
const el = buildCommitmentSummary([line({ upfront: 1200, monthly: 10, term: 1, account: 'prod' })]);
const text = el.textContent ?? '';
expect(text).toContain('Charged today$1,200.00');
expect(text).toContain('Monthly$10.00');
expect(text).toContain('Total over term$1,320.00');
expect(text).toContain('2 × ec2 m5.large · 1 Year, No upfront · prod');
});

it('says so when the monthly cost is not reported, and truncates long lists', () => {
const el = buildCommitmentSummary(Array.from({ length: 7 }, () => line({ monthly: null })));
expect(el.textContent).toContain('MonthlyNot reported');
expect(el.textContent).toContain('Total over termNot available');
expect(el.querySelectorAll('li')).toHaveLength(6);
expect(el.textContent).toContain('and 2 more');
});

it('withIntro puts the sentence before the summary', () => {
const body = withIntro('Intro.', buildCommitmentSummary([line()]));
expect(body.firstElementChild?.textContent).toBe('Intro.');
expect(body.querySelector('.commitment-summary')).not.toBeNull();
});
});
48 changes: 48 additions & 0 deletions frontend/src/__tests__/confirmDialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,52 @@ describe('confirmDialog', () => {
expect(document.querySelector('.btn-primary')?.textContent).toBe('Reset all');
expect(document.querySelector('.btn-secondary')?.textContent).toBe('Keep them');
});

it('names the dialog by its title and describes it by its body', () => {
void confirmDialog({ title: 'Delete?', body: 'Permanent action.' });
const backdrop = document.querySelector('.modal-confirm-backdrop')!;
const titleId = backdrop.getAttribute('aria-labelledby')!;
const bodyId = backdrop.getAttribute('aria-describedby')!;
expect(document.getElementById(titleId)?.textContent).toBe('Delete?');
expect(document.getElementById(bodyId)?.textContent).toBe('Permanent action.');
});

it('focuses Cancel for destructive dialogs and Confirm otherwise', () => {
void confirmDialog({ title: 't', body: 'b', destructive: true });
expect(document.activeElement?.textContent).toBe('Cancel');
document.body.replaceChildren();
void confirmDialog({ title: 't', body: 'b' });
expect(document.activeElement?.textContent).toBe('Confirm');
});

it('focuses Cancel when initialFocus is cancel even if not destructive', () => {
void confirmDialog({ title: 't', body: 'b', initialFocus: 'cancel' });
expect(document.activeElement?.textContent).toBe('Cancel');
});

it('focuses the close-X when the cancel button is hidden and focus is cancel', () => {
void confirmDialog({ title: 't', body: 'b', destructive: true, hideCancelButton: true });
expect(document.activeElement?.classList.contains('modal-confirm-close')).toBe(true);
});

it('cycles focus over all focusables in DOM order, including body inputs', () => {
const body = document.createElement('div');
const ta = document.createElement('textarea');
body.appendChild(ta);
void confirmDialog({ title: 't', body, destructive: true });
const tab = (shiftKey = false): void => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey }));
};
// DOM order: close-X, textarea, Cancel, Confirm; focus starts on Cancel.
tab(true);
expect(document.activeElement).toBe(ta);
tab(true);
expect(document.activeElement?.classList.contains('modal-confirm-close')).toBe(true);
tab();
expect(document.activeElement).toBe(ta);
tab(); tab();
expect(document.activeElement?.textContent).toBe('Confirm');
tab();
expect(document.activeElement?.classList.contains('modal-confirm-close')).toBe(true);
});
});
32 changes: 31 additions & 1 deletion frontend/src/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import { loadDashboard } from '../dashboard';
import { loadRecommendations } from '../recommendations';
import { loadPlans } from '../plans';
import { initHistoryDateRange, loadHistory } from '../history';
import { loadGlobalSettings } from '../settings';
import { loadGlobalSettings, isUnsavedChanges } from '../settings';
import { loadAutomationSettings } from '../riexchange';
import { canAccess } from '../permissions';
import { loadInventory } from '../inventory';
Expand Down Expand Up @@ -663,4 +663,34 @@ describe('Navigation Module', () => {
expect(getInventorySubTabFromPath()).toBe('active-commitments');
});
});

describe('unsaved settings guard', () => {
afterEach(() => {
(isUnsavedChanges as jest.Mock).mockReturnValue(false);
});

const dialogButton = (label: string): HTMLButtonElement =>
Array.from(document.querySelectorAll<HTMLButtonElement>('.modal-confirm button')).find(
b => b.textContent === label
)!;

test('asks via the styled dialog, stays put on cancel and leaves on confirm', async () => {
switchTab('admin');
(isUnsavedChanges as jest.Mock).mockReturnValue(true);

switchTab('home');
expect(document.querySelector('.modal-confirm-title')?.textContent).toBe('Leave without saving?');
expect(document.getElementById('admin-tab')?.classList.contains('active')).toBe(true);
dialogButton('Stay on this tab').click();
await Promise.resolve();
await Promise.resolve();
expect(document.getElementById('admin-tab')?.classList.contains('active')).toBe(true);

switchTab('home');
dialogButton('Leave without saving').click();
await Promise.resolve();
await Promise.resolve();
expect(document.getElementById('home-tab')?.classList.contains('active')).toBe(true);
});
});
});
28 changes: 28 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,34 @@ describe('Plans Module', () => {
expect(api.runPlannedPurchase).not.toHaveBeenCalled();
});

test('run confirm names the step and shows the amount charged today', async () => {
mockConfirmDialog.mockClear();
mockConfirmDialog.mockResolvedValue(false);

(document.querySelector('[data-action="run"]') as HTMLButtonElement).click();
await new Promise(resolve => setTimeout(resolve, 50));

const opts = mockConfirmDialog.mock.calls[0]![0] as { title: string; body: HTMLElement };
expect(opts.title).toBe('Run step 1/4 of "Test Plan" now?');
expect(opts.body.textContent).toMatch(/Charged today\$1,?000/);
expect(opts.body.textContent).toContain('5 \u00d7 ec2 t3.medium');
});

test('a second click while the run confirm is open does not stack a second dialog', async () => {
mockConfirmDialog.mockClear();
let resolveDialog: (v: boolean) => void = () => undefined;
mockConfirmDialog.mockReturnValue(new Promise<boolean>(r => { resolveDialog = r; }));

const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement;
runBtn.click();
runBtn.click();
await new Promise(resolve => setTimeout(resolve, 20));
expect(mockConfirmDialog).toHaveBeenCalledTimes(1);

resolveDialog(false);
await new Promise(resolve => setTimeout(resolve, 20));
});

test('pause action pauses purchase', async () => {
(api.pausePlannedPurchase as jest.Mock).mockResolvedValue({});
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });
Expand Down
23 changes: 20 additions & 3 deletions frontend/src/__tests__/purchase-modal-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,20 @@ describe('Issue #251: Execute Now makes no cancellation promise', () => {
expect(api.executePurchase).not.toHaveBeenCalled();
});

test('approval confirm labels the amount as upfront on approval, not charged today', async () => {
const rec: LocalRecommendation = { ...buildRows()[0]!, provider: 'aws', service: 'ec2', term: 1 };
(state.getRecommendations as jest.Mock).mockReturnValue([rec]);
(confirmDialog as jest.Mock).mockClear();
await openPurchaseModal([rec]);
(document.getElementById('execute-purchase-btn') as HTMLButtonElement).click();
await flush();
const opts = (confirmDialog as jest.Mock).mock.calls[0]![0] as { body: HTMLElement };
const text = opts.body.textContent ?? '';
expect(text).toContain('Nothing is charged until the approver');
expect(text).toContain(`Upfront on approval${formatCurrency(rec.upfront_cost, '$', 2)}`);
expect(text).not.toContain('Charged today');
});

test('direct-execute confirm dialog makes no cancellation promise', async () => {
const rec: LocalRecommendation = { ...buildRows()[0]!, provider: 'aws', service: 'ec2', term: 1 };
(state.getRecommendations as jest.Mock).mockReturnValue([rec]);
Expand All @@ -308,9 +322,12 @@ describe('Issue #251: Execute Now makes no cancellation promise', () => {
(document.getElementById('execute-purchase-btn') as HTMLButtonElement).click();
await flush();
expect(confirmDialog).toHaveBeenCalledTimes(1);
const opts = (confirmDialog as jest.Mock).mock.calls[0]![0] as { title: string; body: string };
expect(opts.body).toMatch(/charge the full upfront amount immediately/);
expect(`${opts.title} ${opts.body}`).not.toMatch(/AWS|cancell|24 hours/i);
const opts = (confirmDialog as jest.Mock).mock.calls[0]![0] as { title: string; body: HTMLElement };
const bodyText = opts.body.textContent ?? '';
expect(bodyText).toMatch(/charges the amount shown under "Charged today" immediately/);
expect(bodyText).toContain('Charged today');
expect(bodyText).toContain(formatCurrency(rec.upfront_cost, '$', 2));
expect(`${opts.title} ${bodyText}`).not.toMatch(/AWS|cancell|24 hours/i);
});
});

Expand Down
33 changes: 33 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,39 @@ describe('openExchangeModal', () => {
expect(execReq.region).toBe('us-west-2');
});

it('names instance types and the hourly price change in the execute confirm', async () => {
const mockGetQuote = api.getExchangeQuote as jest.Mock;
mockGetQuote.mockResolvedValueOnce({
IsValidExchange: true,
ValidationFailureReason: '',
CurrencyCode: 'USD',
PaymentDueRaw: '12.50',
SourceHourlyPriceRaw: '0.1000',
SourceRemainingUpfrontRaw: '',
SourceRemainingTotalRaw: '',
TargetHourlyPriceRaw: '0.1250',
TargetRemainingUpfrontRaw: '',
TargetRemainingTotalRaw: '',
Region: 'us-west-2',
});
const offeringUUID = '4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91';
openExchangeModal('ri-abc', 3, 'm5.large', [
{ instance_type: 'm5.large', offering_id: offeringUUID, effective_monthly_cost: 42.5 },
]);
Array.from(modal.querySelectorAll('button')).find((b) => b.textContent === 'Get Quote')?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
Array.from(modal.querySelectorAll('button')).find((b) => b.textContent === 'Execute Exchange')?.click();

const text = document.querySelector('.modal-confirm-body')?.textContent ?? '';
expect(text).toContain('Pay now: USD 12.50');
expect(text).toContain('3 \u00d7 m5.large');
// The exact offering id stays visible next to the instance type.
expect(text).toContain(`3 \u00d7 m5.large (offering ${offeringUUID})`);
expect(text).toContain('USD 0.1000 \u2192 0.1250 (+0.0250 USD/hr, about +18.25 USD/month)');
expect(document.activeElement?.textContent).toBe('Cancel');
document.querySelector<HTMLButtonElement>('.modal-confirm-close')?.click();
});

it('invalidates a fetched quote when the count changes, hiding Execute (issue #244)', async () => {
const mockGetQuote = api.getExchangeQuote as jest.Mock;
mockGetQuote.mockResolvedValueOnce({
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/__tests__/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1305,13 +1305,13 @@ describe('users/userActions', () => {

await userActions.bulkAddToGroup('admins');

expect(global.confirm).toHaveBeenCalledWith(
expect.stringContaining('Admins')
expect(mockConfirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringContaining('Admins') })
);
});

it('should not add when cancelled', async () => {
(global.confirm as jest.Mock).mockReturnValue(false);
mockConfirmDialog.mockResolvedValueOnce(false);
userState.addSelectedUserId('1');

await userActions.bulkAddToGroup('admins');
Expand Down
22 changes: 19 additions & 3 deletions 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, type FanOutBucket } from './recommendations';
import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, fanOutBucketAccountLabel, getAccountName, type FanOutBucket } from './recommendations';
import { switchTab, applyTabFromPath, initRouter, switchSettingsSubTab, canonicalTabPath } from './navigation';
import { savePlan, setupPlanHandlers, closePlanModal, openNewPlanModal, closePurchaseModal } from './plans';
import { saveGlobalSettings, setupSettingsHandlers, resetSettings } from './settings';
Expand All @@ -18,6 +18,7 @@ import { setupRIExchangeHandlers, saveAutomationSettings } from './riexchange';
import { showToast } from './toast';
import { formatPaymentAdjustmentNotice } from './commitmentOptions';
import { confirmDialog } from './confirmDialog';
import { buildCommitmentSummary, withIntro } from './commitmentSummary';
import { handlePurchaseDeeplink } from './purchases-deeplink';
import { handleArcheraDeeplink, openArcheraOfferModal } from './archera';
import { closeModal } from './modal';
Expand Down Expand Up @@ -398,16 +399,31 @@ export async function handleExecutePurchase(): Promise<void> {
// - Approval path: low-friction, non-destructive.
// - Direct-execute path: red destructive dialog with cost callout and
// immediate-charge callout; no cancellation promise (issue #251).
const summary = buildCommitmentSummary(
recsWithPayment.map((r) => ({
service: r.service,
provider: r.provider,
region: r.region,
resourceType: r.resource_type,
count: r.count,
term: r.term,
payment: r.payment,
upfront: r.upfront_cost,
monthly: r.monthly_cost ?? null,
...(r.cloud_account_id ? { account: getAccountName(r.cloud_account_id) } : {}),
})),
isDirect ? {} : { upfrontLabel: 'Upfront on approval' },
);
const ok = isDirect
? await confirmDialog({
title: `Execute ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} now?`,
body: 'This will charge the full upfront amount immediately. This bypasses the approval step.',
body: withIntro('This charges the amount shown under "Charged today" immediately and bypasses the approval step. The monthly amount, if any, is billed for the whole term.', summary),
confirmLabel: 'Execute Purchase Now',
destructive: true,
})
: await confirmDialog({
title: `Send ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} for approval?`,
body: 'This will email an approval request to the configured approver. Cloud commitments are charged only after the approver clicks the link in that email.',
body: withIntro('This emails an approval request to the configured approver. Nothing is charged until the approver clicks the link in that email.', summary),
confirmLabel: 'Send for approval',
destructive: false,
});
Expand Down
Loading