Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
49adc14
fix(frontend): reject past start date in Add Purchases form
cristim Oct 19, 2026
15f87bd
fix(frontend): show stored zero max amount and refuse invalid caps
cristim Oct 19, 2026
72176ed
fix(frontend): show the account id once in active commitments
cristim Oct 19, 2026
8efab35
fix(frontend): add accessible names to planned purchase row actions
cristim Oct 19, 2026
2016c92
fix(frontend): make the profile entry point a button
cristim Oct 19, 2026
6b96751
fix(frontend): highlight the nav from the URL before data loads
cristim Oct 19, 2026
e63c4e8
fix(frontend): focus a rendered element when opening the mobile drawer
cristim Oct 19, 2026
baa42fb
fix(frontend): reject malformed 2xx bodies instead of resolving null
cristim Oct 19, 2026
2a56885
fix(frontend): reload and reselect after a partly failed bulk user de…
cristim Oct 19, 2026
d393638
fix(frontend): stop offering monthly as an AWS payment option
cristim Oct 19, 2026
aa4621d
fix(frontend): fail explicitly on an empty dashboard summary
cristim Oct 19, 2026
cb4e5ce
fix(frontend): show a tooltip for committed services without recommen…
cristim Oct 19, 2026
e7aa230
fix(frontend): match the server start-date rule in Add Purchases
cristim Oct 19, 2026
86c3707
fix(frontend): keep focus in the profile modal opened from the drawer
cristim Oct 19, 2026
2e669d0
fix(frontend): give the Max Amount input error its own message
cristim Oct 19, 2026
65be246
fix(frontend): align the start-date picker and tests with the UTC rule
cristim Oct 19, 2026
0ba55e7
docs(frontend): update the stale apiRequest null comment in the dashb…
cristim Oct 19, 2026
d7a3748
fix(frontend): offer only monthly as a GCP payment option
cristim Oct 19, 2026
fd4e355
docs(frontend): correct the profile-button drawer close comment
cristim Oct 19, 2026
9290310
fix(frontend): refuse unparsable Max Amount input
cristim Oct 19, 2026
c565be0
fix(frontend): keep single GCP recs off the fan-out path
cristim Oct 19, 2026
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/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"jest": {
"preset": "ts-jest",
"testEnvironment": "jsdom",
"globalSetup": "<rootDir>/src/__tests__/globalSetup.ts",
"setupFilesAfterEnv": [
"<rootDir>/src/__tests__/setup.ts"
],
Expand Down
19 changes: 13 additions & 6 deletions frontend/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,25 +378,32 @@ describe('API Requests', () => {
}
});

// Q3: defence-in-depth — even if Q1 somehow misses a nil-body case,
// the frontend tolerates empty / malformed 2xx bodies without
// crashing with SyntaxError.
test('tolerates empty 2xx body and resolves to null', async () => {
test('resolves a 204 No Content response to null', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 204,
json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')),
});
await expect(apiRequest('/test')).resolves.toBeNull();
});

test('resolves a content-length 0 response to null', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-length': '0' }),
json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')),
});
await expect(apiRequest('/test')).resolves.toBeNull();
});

test('tolerates malformed 2xx body and resolves to null', async () => {
test('rejects a malformed 2xx body instead of resolving null (#273)', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('Unexpected token in JSON at position 0')),
});
await expect(apiRequest('/test')).resolves.toBeNull();
await expect(apiRequest('/test')).rejects.toThrow(SyntaxError);
});

test('surfaces a timeout error when the backend hangs past timeoutMs (issue #20)', async () => {
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jest.mock('../dashboard', () => ({

jest.mock('../navigation', () => ({
switchTab: jest.fn(),
setActiveNav: jest.fn(),
applyTabFromPath: jest.fn().mockReturnValue('dashboard'),
initRouter: jest.fn(),
switchSettingsSubTab: jest.fn(),
Expand Down Expand Up @@ -113,6 +114,17 @@ describe('App Module', () => {
expect(auth.updateUserUI).toHaveBeenCalled();
});

test('highlights the nav from the URL before any async fetch resolves (#187)', async () => {
(api.isAuthenticated as jest.Mock).mockReturnValue(true);
(api.getCurrentUser as jest.Mock).mockReturnValue(new Promise(() => {}));
(navigation.applyTabFromPath as jest.Mock).mockReturnValue('opportunities');

void init();
await Promise.resolve();

expect(navigation.setActiveNav).toHaveBeenCalledWith('opportunities');
});

test('seeds the URL from canonicalTabPath before routing', async () => {
(api.isAuthenticated as jest.Mock).mockReturnValue(true);
(api.getCurrentUser as jest.Mock).mockResolvedValue({ id: 'user-1', email: 'test@example.com' });
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,21 @@ describe('Auth Module', () => {
expect((userInfo as HTMLElement).classList.contains('hidden')).toBe(true);
});

test('keeps the profile button hidden until a user is set, then shows it', () => {
const btn = document.getElementById('user-email-display') as HTMLElement;
(state.getCurrentUser as jest.Mock).mockReturnValue(null);
updateUserUI();
expect(btn.hidden).toBe(true);

(state.getCurrentUser as jest.Mock).mockReturnValue({ id: 'u', email: 'a@example.com', groups: [] });
updateUserUI();
expect((document.getElementById('user-email-display') as HTMLElement).hidden).toBe(false);

(state.getCurrentUser as jest.Mock).mockReturnValue(null);
updateUserUI();
expect((document.getElementById('user-email-display') as HTMLElement).hidden).toBe(true);
});

test('shows admin-only elements for admin users', () => {
(state.getCurrentUser as jest.Mock).mockReturnValue({
id: 'admin-1',
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,17 @@ describe('Dashboard Module', () => {
expect(summary?.innerHTML).toContain('Failed to load dashboard');
});

test('names an empty summary response instead of throwing a TypeError (#280)', async () => {
(api.getDashboardSummary as jest.Mock).mockResolvedValue(null);
console.error = jest.fn();

await loadDashboard();

const text = document.getElementById('summary')?.textContent ?? '';
expect(text).toContain('Dashboard summary response was empty');
expect(text).not.toContain('Cannot read properties of null');
});

// Issue #344 T3: skeleton lifecycle — skeleton renders synchronously
// at fetch start, then is replaced by the success render (clean
// handoff) or torn down on error so it never sits next to a stale
Expand Down Expand Up @@ -1640,6 +1651,20 @@ describe('Dashboard Module', () => {
expect(totalLine).toMatch(/\$400/);
});

test('tooltip shows the committed value for a service with a commitment but no recommendation (#752)', () => {
buildDOM();
renderSavingsByService(
[rec('ec2', 100, 1, 'no_upfront')],
{ sqldb: { potential_savings: 0, current_savings: 250 } },
);
const chartCtor = Chart as unknown as jest.Mock;
const lastCall = chartCtor.mock.calls[chartCtor.mock.calls.length - 1];
const label = (lastCall?.[1] as {
options: { plugins: { tooltip: { callbacks: { label: (ctx: { label: string; dataset: { label: string } }) => string } } } };
}).options.plugins.tooltip.callbacks.label;
expect(label({ label: 'sqldb', dataset: { label: 'Current / Committed' } })).toMatch(/Current \/ Committed: \$250/);
});

// Issue #908: merged chart draws a current-savings bottom layer per service.
test('renders Current / Committed layer from byService.current_savings', () => {
buildDOM();
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Run every suite outside UTC so code that mixes local and UTC dates cannot
// pass on a UTC CI runner by coincidence. New York is UTC-4/-5, so its
// calendar day trails the UTC day for several hours each evening.
export default function globalSetup(): void {
process.env['TZ'] = 'America/New_York';
}
70 changes: 44 additions & 26 deletions frontend/src/__tests__/groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
document.body.innerHTML = `
<div id="groups-list"></div>
`;
userState.setAllUsers(mockUsers as any);

Check warning on line 107 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
groupState.setCurrentEditingGroup(null);
});

Expand Down Expand Up @@ -165,7 +165,7 @@
// Set up users so group-1 has exactly 1 member
userState.setAllUsers([
{ id: 'user-1', email: 'admin@test.com', groups: ['00000000-0000-5000-8000-000000000001', 'group-1'], mfa_enabled: true },
] as any);

Check warning on line 168 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type

const group = mockGroups[0];
if (!group) throw new Error('Test data missing');
Expand Down Expand Up @@ -289,7 +289,7 @@
it('should attach click handlers to delete buttons', async () => {
(api.deleteGroup as jest.Mock).mockResolvedValue(undefined);
mockConfirmDialog.mockResolvedValue(true);
userState.setAvailableGroups(mockGroups as any);

Check warning on line 292 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type

groupList.renderGroups(mockGroups);

Expand Down Expand Up @@ -853,7 +853,7 @@
document.body.innerHTML = `
<div id="groups-list"></div>
`;
userState.setAvailableGroups(mockGroups as any);

Check warning on line 856 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
jest.clearAllMocks();
mockConfirmDialog.mockResolvedValue(true);
(api.deleteGroup as jest.Mock).mockResolvedValue(undefined);
Expand Down Expand Up @@ -926,22 +926,22 @@
</form>
`;
// Clear window properties
delete (window as any).openCreateGroupModal;

Check warning on line 929 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
delete (window as any).closeGroupModal;

Check warning on line 930 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
delete (window as any).addPermission;

Check warning on line 931 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
jest.clearAllMocks();
});

describe('setupGroupHandlers', () => {
it('should expose openCreateGroupModal on window', () => {
groupHandlers.setupGroupHandlers();
expect((window as any).openCreateGroupModal).toBeDefined();

Check warning on line 938 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
expect(typeof (window as any).openCreateGroupModal).toBe('function');

Check warning on line 939 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
});

it('should expose closeGroupModal on window', () => {
groupHandlers.setupGroupHandlers();
expect((window as any).closeGroupModal).toBeDefined();

Check warning on line 944 in frontend/src/__tests__/groups.test.ts

View workflow job for this annotation

GitHub Actions / Build frontend

Unexpected any. Specify a different type
expect(typeof (window as any).closeGroupModal).toBe('function');
});

Expand Down Expand Up @@ -1260,7 +1260,8 @@
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 () => {
it('accepts a valid positive max_amount', async () => {
document.body.innerHTML = '';
const modal = document.createElement('div');
modal.id = 'group-modal';
modal.innerHTML = `
Expand All @@ -1274,7 +1275,7 @@
<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">
<input class="perm-max-amount" type="number" min="0" value="5000">
</div>
</div>
`;
Expand All @@ -1285,14 +1286,11 @@
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();
expect(call.permissions[0]?.constraints?.max_amount).toBe(5000);
});

it('accepts a valid positive max_amount', async () => {
it('rejects negative max_amount (does not forward it to API)', async () => {
document.body.innerHTML = '';
const modal = document.createElement('div');
modal.id = 'group-modal';
Expand All @@ -1307,7 +1305,7 @@
<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">
<input class="perm-max-amount" type="number" value="-100">
</div>
</div>
`;
Expand All @@ -1318,38 +1316,58 @@
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);
// Negative value must be refused, not silently dropped (#133).
expect(api.createGroup).not.toHaveBeenCalled();
expect(document.querySelector('.toast-error')?.textContent).toContain(
'Max Amount must be greater than 0; leave it blank for no limit (permission 0).'
);
expect(document.querySelector('.toast-error')?.textContent).not.toContain('Repair');
});

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 = `
const maxAmountForm = (attrs: string): string => `
<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">
<input class="perm-max-amount" type="number" min="0" ${attrs}>
</div>
</div>
`;
document.body.appendChild(modal);
const MAX_AMOUNT_MESSAGE = 'Max Amount must be greater than 0; leave it blank for no limit (permission 0).';

it.each(['0', '-100'])('refuses max_amount %s instead of saving without the cap (#133)', async raw => {
document.body.innerHTML = maxAmountForm(`value="${raw}"`);
await groupModals.saveGroup(new Event('submit', { cancelable: true }));
expect(api.createGroup).not.toHaveBeenCalled();
expect(document.querySelector('.toast-error')?.textContent).toContain(MAX_AMOUNT_MESSAGE);
expect(document.querySelector('.toast-error')?.textContent).not.toContain('Repair');
});

(api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] });
// Browsers report unparsable text ("abc", "1e", "1e999") as value "" with
// validity.badInput set; without that check the cap would be silently dropped.
it('refuses unparsable number-input text (badInput) instead of saving without the cap (#133)', async () => {
document.body.innerHTML = maxAmountForm('');
const input = document.querySelector('.perm-max-amount') as HTMLInputElement;
Object.defineProperty(input, 'validity', { value: { badInput: true } });
expect(input.value).toBe('');
await groupModals.saveGroup(new Event('submit', { cancelable: true }));
expect(api.createGroup).not.toHaveBeenCalled();
expect(document.querySelector('.toast-error')?.textContent).toContain(MAX_AMOUNT_MESSAGE);
});

const event = new Event('submit', { cancelable: true });
await groupModals.saveGroup(event);
it('still saves a blank max_amount as no cap', async () => {
document.body.innerHTML = maxAmountForm('');
(api.createGroup as jest.Mock).mockResolvedValue({ id: 'x', name: 'Test', description: '', permissions: [] });
await groupModals.saveGroup(new Event('submit', { cancelable: true }));
expect(api.createGroup).toHaveBeenCalled();
});

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();
it('shows a stored max_amount of 0 in the edit form (#133)', () => {
document.body.innerHTML = '<div id="permissions-list"></div>';
groupModals.addPermission({ action: 'execute', resource: 'purchases', constraints: { max_amount: 0 } });
expect((document.querySelector('.perm-max-amount') as HTMLInputElement).value).toBe('0');
});
});
8 changes: 8 additions & 0 deletions frontend/src/__tests__/html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,5 +783,13 @@ describe('HTML Structure', () => {
// Link target must be the AWS console role-creation wizard.
expect(link?.getAttribute('href')).toBe('https://console.aws.amazon.com/iam/home#/roles$new');
});

test('profile entry point is a keyboard-focusable button (#715)', () => {
const el = document.getElementById('user-email-display');
expect(el?.tagName.toLowerCase()).toBe('button');
expect(el?.getAttribute('type')).toBe('button');
// Inert until updateUserUI shows it with the signed-in email.
expect(el?.hasAttribute('hidden')).toBe(true);
});
});
});
11 changes: 11 additions & 0 deletions frontend/src/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,17 @@ describe('loadActiveCommitments — fetch + render flow', () => {
expect(api.listActiveCommitments).toHaveBeenCalledTimes(2);
});

test('does not repeat the account id when the name already contains it (#179)', async () => {
(api.listActiveCommitments as jest.Mock).mockResolvedValue([
makeCommitment({ account_name: 'CUDly host (acct-1234)', account_id: 'acct-1234' }),
]);

await loadActiveCommitments();

const accountCell = document.querySelector('#active-commitments-list tbody tr td:nth-child(2)');
expect(accountCell?.textContent).toBe('CUDly host (acct-1234)');
});

test('falls back to monospaced account_id when account_name is missing', async () => {
(api.listActiveCommitments as jest.Mock).mockResolvedValue([
makeCommitment({ account_name: undefined, account_id: 'acc-no-name' }),
Expand Down
28 changes: 24 additions & 4 deletions frontend/src/__tests__/mobile-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function buildDOM(): void {
<div class="app-topbar-brand"><h1>CUDly</h1></div>
<div id="topbar-filters" class="app-topbar-filters" aria-label="Global filters"></div>
<div id="user-info">
<span id="user-email-display"></span>
<button type="button" id="user-email-display">me@example.com</button>
<span id="user-role-display" class="role-badge"></span>
<a href="/docs/" target="_blank" class="header-link">API Docs</a>
<a id="feedback-link" href="#" class="header-link feedback-link">Feedback</a>
Expand All @@ -27,6 +27,7 @@ function buildDOM(): void {
</header>
<div class="sidebar-overlay" id="sidebar-overlay" aria-hidden="true"></div>
<aside id="sidebar" aria-label="Primary navigation" aria-hidden="true">
<button type="button" class="app-sidebar-toggle" aria-label="Toggle sidebar"></button>
<a class="tab-btn" href="/home" data-tab="home">Home</a>
<a class="tab-btn" href="/plans" data-tab="plans">Plans</a>
<div class="app-sidebar-extras" id="sidebar-extras"></div>
Expand Down Expand Up @@ -151,10 +152,8 @@ describe('setupMobileNav', () => {
setupMobileNav();
hamburger().click();
const firstLink = sidebar().querySelector<HTMLElement>('.tab-btn');
// jsdom doesn't implement layout so focus() is a no-op, but we can
// verify the call was attempted by checking that no error was thrown
// and the structure is correct
expect(firstLink).not.toBeNull();
expect(document.activeElement).toBe(firstLink);
});

test('no-op when hamburger element is missing from DOM', () => {
Expand Down Expand Up @@ -224,6 +223,27 @@ describe('syncHeaderPlacement', () => {
expect(projected().every(el => el.parentElement === topbar)).toBe(true);
});

test('opening the profile from the drawer leaves focus in the modal (#715)', () => {
setupMobileNav();
syncHeaderPlacement(true);
const modal = document.createElement('div');
modal.innerHTML = '<input id="profile-email-stub">';
document.body.appendChild(modal);
let trigger: Element | null = null;
// Stand-in for openProfileModal -> openModal: record the trigger, focus in.
document.getElementById('user-email-display')!.addEventListener('click', () => {
trigger = document.activeElement;
document.getElementById('profile-email-stub')!.focus();
});
hamburgerBtn().click();
document.getElementById('user-email-display')!.dispatchEvent(
new MouseEvent('click', { bubbles: true, button: 0 }),
);
expect(document.body.classList.contains('sidebar-open')).toBe(false);
expect(trigger).toBe(hamburgerBtn());
expect(document.activeElement?.id).toBe('profile-email-stub');
});

test('clicking an account action in the drawer closes it', () => {
setupMobileNav();
syncHeaderPlacement(true);
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/__tests__/plans-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ describe('Plans page permission gating (issue #365)', () => {
expect(html).toContain('data-action="pause"');
expect(html).toContain('data-action="edit"');
expect(html).toContain('data-action="disable"');
const buttons = Array.from(pp.querySelectorAll('td.actions button'));
expect(buttons.length).toBeGreaterThanOrEqual(4);
for (const b of buttons) expect(b.getAttribute('aria-label')).toBeTruthy();
});
});

Expand Down
Loading
Loading