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
3 changes: 3 additions & 0 deletions frontend/src/__tests__/allowed-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

// ---------------------------------------------------------------------------
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ jest.mock('../state', () => ({
subscribeProvider: jest.fn(),
subscribeAccount: jest.fn(),
getCurrentProvider: jest.fn(() => ''),
getCurrentAccountIDs: jest.fn(() => [])
getCurrentAccountIDs: jest.fn(() => []),
getAmortizeUpfront: jest.fn(() => false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

jest.mock('../auth', () => ({
Expand Down
19 changes: 14 additions & 5 deletions frontend/src/__tests__/approval-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
* network helpers (those are covered by api-* tests).
*/

// approval-details.ts now reads getAmortizeUpfront() from state at render
// time. Mock it to return false (default: non-amortized) so existing tests
// continue to verify the base column layout.
jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn(() => false),
}));

import {
computeEffectiveSavingsPct,
formatAccountLabel,
Expand Down Expand Up @@ -82,13 +89,13 @@ describe('renderApprovalDetailsBody', () => {
expect(text).toContain('Accounts');
});

it('renders the per-rec table with all 12 columns', () => {
it('renders the per-rec table with all 13 columns (Monthly cost added by issue #1112)', () => {
const rec = makeRec({});
const body = renderApprovalDetailsBody(makeDetails([rec]), new Map());
const headers = Array.from(body.querySelectorAll('.approval-details-table thead th')).map(th => th.textContent);
expect(headers).toEqual([
'Account', 'Provider', 'Service', 'Resource', 'Engine', 'Region',
'Count', 'Term', 'Payment', 'Upfront', 'Monthly savings', 'Eff. savings %',
'Count', 'Term', 'Payment', 'Upfront', 'Monthly cost', 'Monthly savings', 'Eff. savings %',
]);
});

Expand Down Expand Up @@ -149,17 +156,19 @@ describe('renderApprovalDetailsBody', () => {
const rec = makeRec({ upfront_cost: 4567.89, savings: 12.5 });
const body = renderApprovalDetailsBody(makeDetails([rec]), new Map());
const cells = body.querySelectorAll('.approval-details-table tbody td');
// col 9 = Upfront, col 10 = Monthly cost (issue #1112), col 11 = Monthly savings
expect(cells[9]?.textContent).toBe('$4,568');
expect(cells[10]?.textContent).toBe('$13');
expect(cells[11]?.textContent).toBe('$13');
});

it('computes effective savings % when on_demand_cost is set, "—" otherwise', () => {
const withBaseline = makeRec({ savings: 30, on_demand_cost: 100 });
const withoutBaseline = makeRec({ id: 'rec-2', savings: 30, on_demand_cost: null, monthly_cost: null });
const body = renderApprovalDetailsBody(makeDetails([withBaseline, withoutBaseline]), new Map());
const rows = body.querySelectorAll('.approval-details-table tbody tr');
expect(rows[0]?.querySelectorAll('td')[11]?.textContent).toBe('30.0%');
expect(rows[1]?.querySelectorAll('td')[11]?.textContent).toBe('—');
// col 12 = Eff. savings % (shifted by +1 due to the new Monthly cost col at index 10)
expect(rows[0]?.querySelectorAll('td')[12]?.textContent).toBe('30.0%');
expect(rows[1]?.querySelectorAll('td')[12]?.textContent).toBe('—');
});

it('annual-savings tooltip does NOT fire on floating-point rounding noise', () => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/history-approval-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

jest.mock('../recommendations', () => ({
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/history-approve-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

import * as api from '../api';
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/history-cancel-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

import * as api from '../api';
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/history-cancel-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

// Mock permissions so we can inject arbitrary permission sets, including
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/history-retry-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

import * as api from '../api';
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

import * as api from '../api';
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jest.mock('../state', () => ({
subscribeAccount: jest.fn(() => jest.fn()),
getCurrentProvider: jest.fn(() => ''),
getCurrentAccountIDs: jest.fn(() => []),
getAmortizeUpfront: jest.fn(() => false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn(() => jest.fn()),
}));

// inventory.ts routes sub-nav clicks through navigation.switchInventorySubTab
Expand Down
48 changes: 47 additions & 1 deletion frontend/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
getStatusBadge,
calculatePaybackMonths,
providerBadgeClass,
providerBadgeHtml
providerBadgeHtml,
amortizedMonthly,
} from '../utils';

describe('formatCurrency', () => {
Expand Down Expand Up @@ -539,3 +540,48 @@ describe('formatCurrency (11-N2: absent vs real zero)', () => {
expect(formatCurrency(0)).toBe('$0');
});
});

describe('amortizedMonthly', () => {
test('All Upfront: zero recurring cost produces positive amortized value', () => {
// $0/mo recurring + $1200 upfront over 1 year = $100/mo amortized
expect(amortizedMonthly(0, 1200, 1)).toBeCloseTo(100, 5);
});

test('All Upfront: 3-year term spreads upfront over 36 months', () => {
// $0/mo recurring + $3600 upfront over 3 years = $100/mo amortized
expect(amortizedMonthly(0, 3600, 3)).toBeCloseTo(100, 5);
});

test('Partial Upfront: recurring + amortized-upfront slice', () => {
// $50/mo recurring + $600 upfront over 1 year = $50 + $50 = $100/mo
expect(amortizedMonthly(50, 600, 1)).toBeCloseTo(100, 5);
});

test('No Upfront (upfront === 0): result equals monthlyCost unchanged', () => {
expect(amortizedMonthly(80, 0, 1)).toBeCloseTo(80, 5);
expect(amortizedMonthly(80, 0, 3)).toBeCloseTo(80, 5);
});

test('term <= 0: returns monthlyCost unchanged (guard against divide-by-zero)', () => {
expect(amortizedMonthly(50, 600, 0)).toBe(50);
expect(amortizedMonthly(50, 600, -1)).toBe(50);
});

test('non-finite term: returns monthlyCost unchanged', () => {
expect(amortizedMonthly(50, 600, Infinity)).toBe(50);
expect(amortizedMonthly(50, 600, NaN)).toBe(50);
});

test('null upfrontCost: returns monthlyCost unchanged', () => {
expect(amortizedMonthly(80, null, 1)).toBe(80);
});

test('undefined upfrontCost: returns monthlyCost unchanged', () => {
expect(amortizedMonthly(80, undefined, 1)).toBe(80);
});

test('non-finite upfrontCost: returns monthlyCost unchanged', () => {
expect(amortizedMonthly(80, Infinity, 1)).toBe(80);
expect(amortizedMonthly(80, NaN, 1)).toBe(80);
});
});
3 changes: 3 additions & 0 deletions frontend/src/__tests__/xss-provider-class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ jest.mock('../state', () => ({
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

import * as api from '../api';
Expand Down
60 changes: 43 additions & 17 deletions frontend/src/approval-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
import * as api from './api';
import type { CloudAccount } from './api/accounts';
import type { PurchaseDetails, Recommendation } from './api/types';
import { escapeHtml, formatCurrency, formatTerm } from './utils';
import { escapeHtml, formatCurrency, formatTerm, amortizedMonthly } from './utils';
import { getAmortizeUpfront } from './state';

/**
* accountsById maps the internal CloudAccount UUID (the value carried
Expand Down Expand Up @@ -169,22 +170,31 @@ function renderApprovalDetailsTable(recs: Recommendation[], accountsById: Accoun
const table = document.createElement('table');
table.className = 'approval-details-table';

const amortize = getAmortizeUpfront();
const thead = document.createElement('thead');
thead.innerHTML = `
<tr>
<th>Account</th>
<th>Provider</th>
<th>Service</th>
<th>Resource</th>
<th>Engine</th>
<th>Region</th>
<th class="num">Count</th>
<th>Term</th>
<th>Payment</th>
<th class="num">Upfront</th>
<th class="num">Monthly savings</th>
<th class="num">Eff. savings %</th>
</tr>`;
const headerRow = document.createElement('tr');
const headerCols: Array<{ label: string; numeric?: true }> = [
{ label: 'Account' },
{ label: 'Provider' },
{ label: 'Service' },
{ label: 'Resource' },
{ label: 'Engine' },
{ label: 'Region' },
{ label: 'Count', numeric: true },
{ label: 'Term' },
{ label: 'Payment' },
{ label: 'Upfront', numeric: true },
{ label: amortize ? 'Monthly cost (amortized)' : 'Monthly cost', numeric: true },
{ label: 'Monthly savings', numeric: true },
{ label: 'Eff. savings %', numeric: true },
];
for (const col of headerCols) {
const th = document.createElement('th');
th.textContent = col.label;
if (col.numeric) th.className = 'num';
headerRow.appendChild(th);
}
thead.appendChild(headerRow);
table.appendChild(thead);

const tbody = document.createElement('tbody');
Expand Down Expand Up @@ -214,9 +224,24 @@ function renderRecRow(rec: Recommendation, accountsById: AccountsById, hostAWSAc
// both `undefined` and "" are falsy so the single check is enough.
const engineLabel = rec.engine ? rec.engine : '—';
const effSavings = computeEffectiveSavingsPct(rec);

// Compute the monthly cost cell value (issue #1112). When monthly_cost
// is null the provider API did not return a breakdown; render "—".
// When amortize is on, fold the upfront slice in.
const amortize = getAmortizeUpfront();
let monthlyCostDisplay: string;
if (rec.monthly_cost == null) {
monthlyCostDisplay = '—';
} else {
const displayVal = amortize
? amortizedMonthly(rec.monthly_cost, rec.upfront_cost ?? 0, rec.term)
: rec.monthly_cost;
monthlyCostDisplay = formatCurrency(displayVal);
}

// innerHTML is safe here because every interpolated value goes
// through escapeHtml or is a numeric/preformatted constant. Using
// innerHTML rather than 12 createElement calls per row keeps the
// innerHTML rather than 13 createElement calls per row keeps the
// render code readable for the table layout, mirroring the
// recommendations.ts pattern.
row.innerHTML = `
Expand All @@ -230,6 +255,7 @@ function renderRecRow(rec: Recommendation, accountsById: AccountsById, hostAWSAc
<td>${escapeHtml(formatTerm(rec.term))}</td>
<td>${escapeHtml(rec.payment ?? '')}</td>
<td class="num">${escapeHtml(formatCurrency(rec.upfront_cost ?? null))}</td>
<td class="num">${escapeHtml(monthlyCostDisplay)}</td>
<td class="num">${escapeHtml(formatCurrency(rec.savings ?? null))}</td>
<td class="num">${effSavings === null ? '—' : escapeHtml(`${effSavings.toFixed(1)}%`)}</td>`;
return row;
Expand Down
Loading
Loading