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
16 changes: 13 additions & 3 deletions frontend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ jest.mock('../state', () => ({
import * as api from '../api';
import * as state from '../state';

// Mock toast so alert() -> showToast() migrations (finding 11-L3) don't
// require a real DOM toast implementation in jsdom.
const mockShowToast = jest.fn();
jest.mock('../toast', () => ({
showToast: (...args: unknown[]) => mockShowToast(...args),
}));

// Module-level mock time that persists across tests to ensure
// each test gets a time later than any previous rate limit timestamp
let globalMockTime = 1000000000000;
Expand Down Expand Up @@ -639,7 +646,8 @@ describe('Auth Module', () => {

await new Promise(resolve => setTimeout(resolve, 10));

expect(window.alert).toHaveBeenCalledWith('Please enter your current password to save changes');
// alert() replaced by showToast() (finding 11-L3).
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Please enter your current password to save changes', kind: 'error' }));
expect(api.apiRequest).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -815,7 +823,8 @@ describe('Auth Module', () => {
expect(state.setCurrentUser).toHaveBeenCalledWith(expect.objectContaining({
email: 'newemail@example.com'
}));
expect(window.alert).toHaveBeenCalledWith('Profile updated successfully');
// alert() replaced by showToast() (finding 11-L3).
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Profile updated successfully', kind: 'success' }));
});

test('save profile handles API errors', async () => {
Expand All @@ -839,7 +848,8 @@ describe('Auth Module', () => {

await new Promise(resolve => setTimeout(resolve, 100));

expect(window.alert).toHaveBeenCalledWith('Failed to update profile: Update failed');
// alert() replaced by showToast() (finding 11-L3).
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to update profile: Update failed', kind: 'error' }));
});

test('save profile includes new password when provided', async () => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/__tests__/dashboard-ownership-950.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jest.mock('../utils', () => ({
getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })),
escapeHtml: jest.fn((str) => str || ''),
populateAccountFilter: jest.fn(() => Promise.resolve()),
providerBadgeClass: jest.fn((p) => ['aws', 'azure', 'gcp'].includes((p || '').toLowerCase()) ? (p || '').toLowerCase() : ''),
}));

import { loadDashboard } from '../dashboard';
Expand Down
134 changes: 125 additions & 9 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,16 @@ jest.mock('../state', () => ({

// Mock utils
jest.mock('../utils', () => ({
formatCurrency: jest.fn((val) => `$${val || 0}`),
formatCurrency: jest.fn((val) => {
const n = typeof val === 'number' ? val : Number(val);
if (val == null || !Number.isFinite(n)) return '--';
return `$${n}`;
}),
getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })),
escapeHtml: jest.fn((str) => str || ''),
populateAccountFilter: jest.fn(() => Promise.resolve())
populateAccountFilter: jest.fn(() => Promise.resolve()),
// providerBadgeClass added for L4/D1 fix: whitelist-based CSS class helper.
providerBadgeClass: jest.fn((p) => ['aws', 'azure', 'gcp'].includes((p || '').toLowerCase()) ? (p || '').toLowerCase() : ''),
}));

import * as api from '../api';
Expand Down Expand Up @@ -655,11 +661,12 @@ describe('Dashboard Module', () => {
expect(summary?.innerHTML).toContain('Active Commitments');
expect(summary?.innerHTML).toContain('Current Coverage');
expect(summary?.innerHTML).toContain('YTD Savings');
// Savings card must fall back to $0 (not throw or go blank).
// Savings card must fall back to '--' (not throw, go blank, or fabricate $0).
const savingsCard = summary?.querySelector('.card');
expect(savingsCard?.textContent).toContain('Potential Monthly Savings');
// mockPageLevelRange returns cellCount=0 for empty groups, so formatCurrency(0) = '$0'
expect(savingsCard?.innerHTML).toContain('$0');
// When recs fail cellCount=0, the sentinel '--' is shown instead of fabricated $0.
expect(savingsCard?.innerHTML).toContain('--');
expect(savingsCard?.innerHTML).not.toContain('$0');
});

// #293: summary.potential_monthly_savings is no longer the source for
Expand Down Expand Up @@ -719,10 +726,11 @@ describe('Dashboard Module', () => {
expect(summary?.innerHTML).toContain('Current Coverage');
expect(summary?.innerHTML).toContain('YTD Savings');

// Savings card falls back to $0 when recs are absent.
// Savings card shows '--' when recs are absent (no fabricated $0).
const savingsCard = summary?.querySelector('.card');
expect(savingsCard?.textContent).toContain('Potential Monthly Savings');
expect(savingsCard?.innerHTML).toContain('$0');
expect(savingsCard?.innerHTML).toContain('--');
expect(savingsCard?.innerHTML).not.toContain('$0');
});

// #304: getRecommendations returns a non-array object (e.g. a wrapped
Expand All @@ -747,9 +755,10 @@ describe('Dashboard Module', () => {
const summary = document.getElementById('summary');
expect(summary?.innerHTML).toContain('Active Commitments');

// Savings card falls back to $0.
// Savings card shows '--' (no fabricated $0 when recs are absent).
const savingsCard = summary?.querySelector('.card');
expect(savingsCard?.innerHTML).toContain('$0');
expect(savingsCard?.innerHTML).toContain('--');
expect(savingsCard?.innerHTML).not.toContain('$0');
});

// #749: the real backend always returns the envelope shape
Expand Down Expand Up @@ -1807,4 +1816,111 @@ describe('Dashboard Module', () => {
});
});
});

// H-3 regression: absent API coverage/count fields must render '--', not '$0'/'80%'/'0'.
// Pre-fix: `data.current_coverage || 0` fabricated '0%'; `data.target_coverage || 80`
// fabricated '80%'; `data.active_commitments || 0` fabricated '0'; the savings catch
// block fell back to formatCurrency(0). These tests must FAIL on pre-fix code.
describe('H-3: absent dashboard KPI fields render -- sentinel, not fabricated values', () => {
test('absent target_coverage shows -- not hardcoded 80%', async () => {
(api.getDashboardSummary as jest.Mock).mockResolvedValue({
// target_coverage deliberately absent
total_recommendations: 1,
active_commitments: 1,
committed_monthly: 100,
current_coverage: 60,
ytd_savings: 0,
by_service: {}
});
(api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] });

await loadDashboard();

const summary = document.getElementById('summary');
// Must not show the hardcoded 80% fallback
expect(summary?.textContent).not.toContain('Target: 80.0%');
expect(summary?.textContent).not.toContain('Target: 80%');
// Must show the '--' sentinel for absent target
expect(summary?.textContent).toContain('Target: --');
});

test('absent current_coverage shows -- not fabricated 0%', async () => {
(api.getDashboardSummary as jest.Mock).mockResolvedValue({
// current_coverage deliberately absent
total_recommendations: 2,
active_commitments: 1,
committed_monthly: 100,
target_coverage: 80,
ytd_savings: 0,
by_service: {}
});
(api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] });

await loadDashboard();

const summaryEl = document.getElementById('summary');
// Find the Coverage tile specifically to check its value
const tiles = summaryEl?.querySelectorAll('.kpi-tile');
const coverageTile = Array.from(tiles ?? []).find(t =>
t.textContent?.includes('Current Coverage')
);
expect(coverageTile).toBeDefined();
const valueEl = coverageTile?.querySelector('.kpi-tile-value');
// Must show '--' (not a fabricated '0.0%') when current_coverage is absent
expect(valueEl?.textContent).toBe('--');
});

test('absent active_commitments shows -- not fabricated 0', async () => {
(api.getDashboardSummary as jest.Mock).mockResolvedValue({
total_recommendations: 1,
// active_commitments deliberately absent
committed_monthly: 0,
current_coverage: 70,
target_coverage: 80,
ytd_savings: 0,
by_service: {}
});
(api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] });

await loadDashboard();

const summaryEl = document.getElementById('summary');
// Find the Active Commitments tile specifically
const tiles = summaryEl?.querySelectorAll('.kpi-tile');
const commitmentsTile = Array.from(tiles ?? []).find(t =>
t.textContent?.includes('Active Commitments')
);
expect(commitmentsTile).toBeDefined();
// Must not show fabricated '0'; must show '--'
const valueEl = commitmentsTile?.querySelector('.kpi-tile-value');
expect(valueEl?.textContent).toBe('--');
});

test('savings range catch block shows -- not $0 when computation throws', async () => {
// Force the groupRecsByCell mock to throw so the catch block fires.
mockGroupRecsByCell.mockImplementationOnce(() => { throw new Error('compute error'); });
(api.getDashboardSummary as jest.Mock).mockResolvedValue({
total_recommendations: 1,
active_commitments: 1,
committed_monthly: 100,
current_coverage: 70,
target_coverage: 80,
ytd_savings: 0,
by_service: {}
});
(api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] });

await loadDashboard();

const summaryEl = document.getElementById('summary');
const tiles = summaryEl?.querySelectorAll('.kpi-tile');
const savingsTile = Array.from(tiles ?? []).find(t =>
t.textContent?.includes('Potential Monthly Savings')
);
expect(savingsTile).toBeDefined();
const valueEl = savingsTile?.querySelector('.kpi-tile-value');
// Must show '--' (not '$0') when the savings computation fails
expect(valueEl?.textContent).toBe('--');
});
});
});
101 changes: 101 additions & 0 deletions frontend/src/__tests__/groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,3 +1248,104 @@ describe('groups/groupModals — duplicate', () => {
});
});
});

// ---------------------------------------------------------------------------
// Regression tests for finding 11-M3: max_amount must be validated before
// parseFloat assignment in collectPermissions.
// ---------------------------------------------------------------------------
describe('11-M3 max_amount validation in collectPermissions', () => {
// Access collectPermissions indirectly via saveGroup and observe API call.

it('rejects Infinity max_amount (does not forward it to API)', async () => {
const modal = document.createElement('div');
modal.id = 'group-modal';
modal.innerHTML = `
<input id="group-id" value="">
<input id="group-name" value="Test">
<textarea id="group-description"></textarea>
<div id="permissions-list">
<div class="permission-item">
<select class="perm-action"><option value="view" selected>View</option></select>
<select class="perm-resource"><option value="plans" selected>Plans</option></select>
<input class="perm-providers" value="">
<input class="perm-services" value="">
<input class="perm-regions" value="">
<input class="perm-max-amount" type="number" min="0" value="Infinity">
</div>
</div>
`;
document.body.appendChild(modal);

(api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] });

const event = new Event('submit', { cancelable: true });
await groupModals.saveGroup(event);

expect(api.createGroup).toHaveBeenCalled();
const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> };
// max_amount must not be Infinity (non-finite rejected).
const maxAmount = call.permissions[0]?.constraints?.max_amount;
expect(maxAmount).toBeUndefined();
});

it('accepts a valid positive max_amount', async () => {
document.body.innerHTML = '';
const modal = document.createElement('div');
modal.id = 'group-modal';
modal.innerHTML = `
<input id="group-id" value="">
<input id="group-name" value="Test">
<textarea id="group-description"></textarea>
<div id="permissions-list">
<div class="permission-item">
<select class="perm-action"><option value="view" selected>View</option></select>
<select class="perm-resource"><option value="plans" selected>Plans</option></select>
<input class="perm-providers" value="">
<input class="perm-services" value="">
<input class="perm-regions" value="">
<input class="perm-max-amount" type="number" min="0" value="5000">
</div>
</div>
`;
document.body.appendChild(modal);

(api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] });

const event = new Event('submit', { cancelable: true });
await groupModals.saveGroup(event);

const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> };
expect(call.permissions[0]?.constraints?.max_amount).toBe(5000);
});

it('rejects negative max_amount (does not forward it to API)', async () => {
document.body.innerHTML = '';
const modal = document.createElement('div');
modal.id = 'group-modal';
modal.innerHTML = `
<input id="group-id" value="">
<input id="group-name" value="Test">
<textarea id="group-description"></textarea>
<div id="permissions-list">
<div class="permission-item">
<select class="perm-action"><option value="view" selected>View</option></select>
<select class="perm-resource"><option value="plans" selected>Plans</option></select>
<input class="perm-providers" value="">
<input class="perm-services" value="">
<input class="perm-regions" value="">
<input class="perm-max-amount" type="number" value="-100">
</div>
</div>
`;
document.body.appendChild(modal);

(api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] });

const event = new Event('submit', { cancelable: true });
await groupModals.saveGroup(event);

const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> };
// Negative value must be silently skipped.
expect(call.permissions[0]?.constraints?.max_amount).toBeUndefined();
});
});
Loading
Loading