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
2 changes: 1 addition & 1 deletion frontend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ describe('Auth Module', () => {
document.body.innerHTML = `
<div id="user-info" class="hidden">
<span id="user-email-display"></span>
<button id="logout-btn">Logout</button>
<button id="logout-btn">Log out</button>
</div>
<div class="admin-only hidden">Admin content</div>
<a class="requires-purchases" id="purchases-tab-btn">Purchases</a>
Expand Down
28 changes: 26 additions & 2 deletions frontend/src/__tests__/history-cancel-button.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* History inline Cancel button tests (issue #46).
* Approval queue Cancel button tests (issue #46). Cancel renders only in
* the Approval queue card (#1331), not in the History table.
*
* Mirror of the backend RBAC matrix in
* internal/api/handler_purchases.go::authorizeSessionCancel — keep both
Expand Down Expand Up @@ -154,7 +155,7 @@ function makeRow(overrides: Record<string, unknown>) {
};
}

describe('History inline Cancel button (issue #46)', () => {
describe('Approval queue Cancel button (issue #46)', () => {
beforeEach(() => {
setupDOM();
jest.clearAllMocks();
Expand Down Expand Up @@ -252,6 +253,29 @@ describe('History inline Cancel button (issue #46)', () => {
expect(api.cancelPurchase).not.toHaveBeenCalled();
});

test('confirm names the purchase being cancelled (issue #728)', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(confirmDialog as jest.Mock).mockResolvedValue(false);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'exec-1', created_by_user_id: ADMIN_USER.id }),
makeRow({ purchase_id: 'exec-2', resource_type: 'm5.xlarge', count: 3, region: 'eu-west-1', upfront_cost: 4200, created_by_user_id: ADMIN_USER.id }),
],
});

await loadHistory();
document.querySelector<HTMLButtonElement>('.history-cancel-btn[data-cancel-id="exec-2"]')!.click();
await new Promise((r) => setTimeout(r, 0));

const body = (confirmDialog as jest.Mock).mock.calls[0][0].body as HTMLElement;
expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe('AWS ec2 m5.xlarge × 3 in eu-west-1');
// formatCurrency is mocked here; purchases-placeholders-181.test.ts
// pins the real "Upfront: $4,200" output with the real utils.
expect(body.textContent).toContain('Upfront:');
expect(body.textContent).toContain('The pending email approval link will stop working.');
});

test('accepted confirm posts cancel + reloads + toasts', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(confirmDialog as jest.Mock).mockResolvedValue(true);
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/__tests__/history-retry-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,23 @@ describe('History inline Retry button (issue #47)', () => {
expect(api.retryPurchase).not.toHaveBeenCalled();
});

test('confirm names the purchase being retried (issue #728)', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(confirmDialog as jest.Mock).mockResolvedValue(false);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: 'r-1', service: 'rds', resource_type: 'db.r5.large', count: 2, created_by_user_id: ADMIN_USER.id })],
});

await loadHistory();
document.querySelector<HTMLButtonElement>('.history-retry-btn')!.click();
await new Promise((r) => setTimeout(r, 0));

const body = (confirmDialog as jest.Mock).mock.calls[0][0].body as HTMLElement;
expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe('AWS rds db.r5.large × 2 in us-east-1');
expect(body.textContent).toContain('This will create a new purchase execution');
});

test('accepted confirm posts retry + reloads + success toast when email_sent is true', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(confirmDialog as jest.Mock).mockResolvedValue(true);
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/__tests__/ladder-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ jest.mock('../permissions', () => ({ canAccess: jest.fn(() => true) }));

import { listAccountsMinimal } from '../api/accounts';
import { getLadderEvents, getLadderRuns, amendLadderEvent, type LadderEvent, type LadderRun } from '../api/ladder';
import { initLadderTimeline, disposeLadderTimeline } from '../ladder-timeline';
import { initLadderTimeline, refreshLadderTimeline, disposeLadderTimeline } from '../ladder-timeline';
import { canAccess } from '../permissions';
import { Chart } from 'chart.js';

Expand Down Expand Up @@ -361,6 +361,22 @@ test('editor labels each row by its basis and keeps the native amount', async ()
expect(open('m3')).toBe('Planning budget (on-demand-equivalent $/hr)|1.000000');
});

test('refreshLadderTimeline re-evaluates the state after a config save and a kill-switch toggle (issue #728)', async () => {
await initLadderTimeline(document.getElementById('timeline')!, [], false);
const state = (): string => document.querySelector('[data-state]')!.textContent ?? '';
expect(state()).toContain('No ladder configuration');

const cfg = { cloud_account_id: 'account', provider: 'aws', enabled: true } as unknown as Parameters<typeof refreshLadderTimeline>[0][number];
refreshLadderTimeline([cfg]);
await new Promise(resolve => setTimeout(resolve, 0));
expect(state()).toContain('Ladder planning is disabled');

refreshLadderTimeline([cfg], true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(state()).toContain('Timeline loaded');
expect(getLadderEvents).toHaveBeenCalledTimes(3);
});

describe('planned purchases layout and UTC date filters (issue #753)', () => {
const date = (edge: 'from' | 'to'): HTMLInputElement => document.querySelector<HTMLInputElement>(`[data-date="${edge}"]`)!;
const type = (edge: 'from' | 'to', value: string): void => { date(edge).value = value; date(edge).dispatchEvent(new Event('change')); };
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/__tests__/ladder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import type { LadderConfig } from '../api';
import { waitFor } from '@testing-library/dom';
import { listAccountsMinimal, type AccountSummary } from '../api/accounts';
import { canAccess } from '../permissions';
import { refreshLadderTimeline } from '../ladder-timeline';
import { confirmDialog } from '../confirmDialog';

jest.mock('../api/accounts', () => ({ listAccountsMinimal: jest.fn() }));
jest.mock('../ladder-timeline', () => ({ initLadderTimeline: jest.fn() }));
jest.mock('../ladder-timeline', () => ({ initLadderTimeline: jest.fn(), refreshLadderTimeline: jest.fn() }));

jest.mock('../api', () => ({
getLadderConfigs: jest.fn(),
Expand Down Expand Up @@ -126,6 +127,8 @@ describe('ladder.ts', () => {
expect(api.upsertLadderConfig).toHaveBeenCalledTimes(1);
const { updated_at: _updatedAt, ...expected } = config;
expect(api.upsertLadderConfig).toHaveBeenCalledWith({ ...expected, target_coverage: 75 });
// Issue #728: the timeline picks up the saved config without a reload.
expect(refreshLadderTimeline).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ cloud_account_id: config.cloud_account_id })]));
});

describe('renderLadderingSection: toggle label accessibility (issue #1412 row 3.1)', () => {
Expand Down Expand Up @@ -183,6 +186,8 @@ describe('ladder.ts', () => {
expect(api.updateConfig).toHaveBeenCalledWith({ laddering_enabled: !before });
expect(toggle.checked).toBe(!before);
await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' })));
// Issue #728: the timeline re-evaluates with the new kill-switch state.
expect(refreshLadderTimeline).toHaveBeenCalledWith(expect.any(Array), !before);
});

test('a failed PUT reverts the toggle and shows an error toast', async () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/mobile-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function buildDOM(): void {
<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>
<button id="logout-btn">Logout</button>
<button id="logout-btn">Log out</button>
</div>
</header>
<div class="sidebar-overlay" id="sidebar-overlay" aria-hidden="true"></div>
Expand Down
66 changes: 65 additions & 1 deletion frontend/src/__tests__/purchases-placeholders-181.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
*/

import { loadHistory } from '../history';
import { hasNoCommitments, isRealTerm, isPastScheduledDate } from '../lib/purchase-row-guards';
import { hasNoCommitments, isRealTerm, isPastScheduledDate, NO_RECOMMENDATIONS_LABEL } from '../lib/purchase-row-guards';
import { describeHistoryPurchase } from '../history-confirm-body';
import type { HistoryPurchase } from '../types';

jest.mock('../api', () => ({
getHistory: jest.fn(),
Expand Down Expand Up @@ -61,6 +63,7 @@ jest.mock('../recommendations', () => ({
}));

import * as api from '../api';
import { confirmDialog } from '../confirmDialog';
import { getCurrentUser } from '../state';
import { ADMINISTRATORS_GROUP_ID } from '../permissions';

Expand Down Expand Up @@ -167,3 +170,64 @@ describe('Purchases tables never render impossible values (#181)', () => {
expect(row.join(' ')).not.toContain('0 Years');
});
});

// The Cancel and Retry confirms (#728) summarise the row they act on, so
// they must apply the same guards as the row: a no-commitments execution
// must not read "multiple 0 commitment(s) in multiple ... Upfront: $0".
describe('Cancel and Retry confirms apply the no-commitments guards (#181, #728)', () => {
const EMPTY_FAILED = { ...EMPTY_PENDING, purchase_id: 'exec-empty-failed', status: 'failed' };
const REAL_PENDING = {
...EMPTY_PENDING,
purchase_id: 'exec-real',
provider: 'aws',
service: 'ec2',
resource_type: 'm5.xlarge',
region: 'eu-west-1',
count: 3,
term: 1,
upfront_cost: 4200,
};

beforeEach(() => {
setupDOM();
jest.clearAllMocks();
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(confirmDialog as jest.Mock).mockResolvedValue(false);
(api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [EMPTY_PENDING, EMPTY_FAILED, REAL_PENDING] });
});

const confirmBodyFor = async (selector: string): Promise<HTMLElement> => {
await loadHistory();
document.querySelector<HTMLButtonElement>(selector)!.click();
await new Promise((r) => setTimeout(r, 0));
expect(confirmDialog).toHaveBeenCalledTimes(1);
return (confirmDialog as jest.Mock).mock.calls[0][0].body as HTMLElement;
};

const expectNoPlaceholders = (body: HTMLElement): void => {
expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe(NO_RECOMMENDATIONS_LABEL);
const text = body.textContent || '';
expect(text).not.toMatch(/multiple|0 commitment\(s\)|Upfront|\$0|0 Years/i);
expect(text).toContain('Created:');
};

test('Cancel on a pending row with no commitments says so instead of printing placeholders', async () => {
expectNoPlaceholders(await confirmBodyFor('#purchases-approval-queue .history-cancel-btn[data-cancel-id="exec-empty"]'));
});

test('Retry on a failed row with no commitments says so instead of printing placeholders', async () => {
expectNoPlaceholders(await confirmBodyFor('#history-list .history-retry-btn[data-retry-id="exec-empty-failed"]'));
});

test('Cancel on a real row names it and shows upfront in the shipped currency format', async () => {
const body = await confirmBodyFor('#purchases-approval-queue .history-cancel-btn[data-cancel-id="exec-real"]');
expect(body.querySelector('.history-confirm-summary strong')?.textContent).toBe('AWS ec2 m5.xlarge × 3 in eu-west-1');
expect(body.textContent).toContain('Upfront: $4,200');
expect(body.textContent).toContain('Term: 1 Year');
});

test('a "multiple" region is left out like a "multiple" provider', () => {
const p = { ...REAL_PENDING, resource_type: '2 commitment(s)', count: 2, region: 'multiple' } as unknown as HistoryPurchase;
expect(describeHistoryPurchase(p)).toBe('AWS ec2 2 commitment(s) × 2');
});
});
6 changes: 3 additions & 3 deletions frontend/src/__tests__/settings-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1524,8 +1524,8 @@ describe('Account overrides modal', () => {
expect(undoToast!.actions).toBeDefined();
expect(undoToast!.actions!.length).toBe(1);
expect(undoToast!.actions![0]!.label).toBe('Undo');
// 5-second TTL per issue spec.
expect(undoToast!.timeout).toBe(5_000);
// 10-second TTL (issue #728; was 5 s per issue #113).
expect(undoToast!.timeout).toBe(10_000);

// Simulate clicking Undo.
undoToast!.actions![0]!.onClick();
Expand Down Expand Up @@ -1580,7 +1580,7 @@ describe('Account overrides modal', () => {
}>).find(t => t.kind === 'info' && t.message?.includes('aws/rds'));
expect(undoToast).toBeDefined();
expect(undoToast!.actions?.[0]?.label).toBe('Undo');
expect(undoToast!.timeout).toBe(5_000);
expect(undoToast!.timeout).toBe(10_000);

// Let the pending refresh resolve so the async chain completes cleanly.
resolveRefresh();
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/__tests__/toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,59 @@ describe('showToast', () => {
showToast({ message: 'ok', kind: 'success' });
expect(document.querySelector('.toast--success')?.getAttribute('role')).toBe('status');
});

describe('pause while hovered or focused (issue #728, WCAG 2.2.1)', () => {
const toastEl = (): HTMLElement => document.querySelector('.toast') as HTMLElement;

it('hover pauses the countdown and leaving resumes it with the time left', () => {
showToast({ message: 'read me', timeout: 5_000 });
jest.advanceTimersByTime(3_000);
toastEl().dispatchEvent(new MouseEvent('mouseenter'));
jest.advanceTimersByTime(60_000);
expect(toastEl()).not.toBeNull();

toastEl().dispatchEvent(new MouseEvent('mouseleave'));
jest.advanceTimersByTime(1_999);
expect(toastEl().classList.contains('toast--leaving')).toBe(false);
jest.advanceTimersByTime(2 + 200);
expect(document.querySelector('.toast')).toBeNull();
});

it('focus inside the toast pauses it until focus leaves the toast', () => {
const handler = jest.fn();
showToast({ message: 'undo?', timeout: 5_000, actions: [{ label: 'Undo', onClick: handler }] });
const undo = toastEl().querySelector('button') as HTMLButtonElement;
undo.focus();
jest.advanceTimersByTime(60_000);
expect(toastEl()).not.toBeNull();

// Moving focus within the toast keeps it paused.
(toastEl().querySelector('.toast-close') as HTMLButtonElement).focus();
jest.advanceTimersByTime(60_000);
expect(toastEl()).not.toBeNull();

(document.activeElement as HTMLElement).blur();
jest.advanceTimersByTime(5_000 + 200);
expect(document.querySelector('.toast')).toBeNull();
});

it('resumes with at least one second so it does not vanish on mouse-out', () => {
showToast({ message: 'almost gone', timeout: 5_000 });
jest.advanceTimersByTime(4_990);
toastEl().dispatchEvent(new MouseEvent('mouseenter'));
toastEl().dispatchEvent(new MouseEvent('mouseleave'));
jest.advanceTimersByTime(900);
expect(toastEl().classList.contains('toast--leaving')).toBe(false);
});

it('sticky toasts stay sticky after hover', () => {
showToast({ message: 'stay', timeout: null });
toastEl().dispatchEvent(new MouseEvent('mouseenter'));
toastEl().dispatchEvent(new MouseEvent('mouseleave'));
jest.advanceTimersByTime(120_000);
expect(toastEl()).not.toBeNull();
});
});
});

// Issue #742: a toast must not sit on top of an open dialog's Save button,
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/history-confirm-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Confirm-dialog body that names the purchase a History row action will
* act on (issue #728, UX-29). Cancel and Retry used to say only "Cancel
* this pending purchase?", so in a long table the user could not tell
* which row they were confirming. The summary mirrors the row: provider,
* service, resource and count, region, account, upfront cost and date.
* A row with no commitments (#181) shows the same "No recommendations
* attached" label as the row instead of its placeholder values.
*/

import type { HistoryPurchase } from './types';
import { getAccountName } from './recommendations';
import { formatCurrency, formatDate, formatTerm } from './utils';
import { hasNoCommitments, isRealTerm, NO_RECOMMENDATIONS_LABEL } from './lib/purchase-row-guards';

/** The API's "multiple" placeholder for a basket spanning providers or regions. */
const specific = (v: string | undefined): string => (v && v !== 'multiple' ? v : '');

/** One-line description of a History row, e.g. "AWS ec2 m5.large × 2 in us-east-1". */
export function describeHistoryPurchase(p: HistoryPurchase): string {
if (hasNoCommitments(p)) return NO_RECOMMENDATIONS_LABEL;
const provider = specific(p.provider).toUpperCase();
const what = [provider, p.service, p.resource_type].filter(Boolean).join(' ');
const count = p.count > 1 ? ` × ${p.count}` : '';
const region = specific(p.region) ? ` in ${p.region}` : '';
return `${what || 'Purchase'}${count}${region}`;
}

/**
* Body element for a row-action confirm: a definition-style summary of the
* purchase followed by the action's explanation.
*/
export function historyConfirmBody(p: HistoryPurchase | undefined, explanation: string): HTMLElement {
const wrap = document.createElement('div');
wrap.className = 'history-confirm-body';
if (p) {
const summary = document.createElement('p');
summary.className = 'history-confirm-summary';
const name = document.createElement('strong');
name.textContent = describeHistoryPurchase(p);
summary.appendChild(name);
const facts: string[] = [];
if (p.account_id) facts.push(`Account: ${getAccountName(p.account_id)}`);
if (isRealTerm(p.term)) facts.push(`Term: ${formatTerm(p.term)}`);
if (p.upfront_cost != null && !hasNoCommitments(p)) facts.push(`Upfront: ${formatCurrency(p.upfront_cost)}`);
const date = formatDate(p.timestamp);
if (date) facts.push(`Created: ${date}`);
if (facts.length > 0) {
summary.appendChild(document.createElement('br'));
summary.appendChild(document.createTextNode(facts.join(' · ')));
}
wrap.appendChild(summary);
}
const text = document.createElement('p');
text.textContent = explanation;
wrap.appendChild(text);
return wrap;
}
Loading
Loading