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
14 changes: 14 additions & 0 deletions frontend/src/__tests__/app-submit-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ describe('submit toast', () => {
expect(all).not.toMatch(/Approval request sent/);
});

it('restores the button label from the execute mode at settle time, not submit time (#754)', async () => {
executeMode = 'direct';
let resolvePost: (v: unknown) => void = () => undefined;
(api.executePurchase as jest.Mock).mockReturnValue(new Promise((r) => { resolvePost = r; }));
const pending = handleExecutePurchase();
await Promise.resolve();
await Promise.resolve();
// Modal closed (mode reset) and reopened while the POST is in flight.
executeMode = '';
resolvePost({ execution_id: execId, status: 'pending', email_sent: true });
await pending;
expect(document.getElementById('execute-purchase-btn')?.textContent).toBe('Send for Approval');
});

it('non-duplicate direct keeps the executed-immediately message', async () => {
executeMode = 'direct';
submit({ status: 'completed' });
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/__tests__/execute-mode-toggle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { openPurchaseModal, getExecuteMode } from '../recommendations';
import { closeModal } from '../modal';

jest.mock('../api', () => ({
getRecommendations: jest.fn().mockResolvedValue({ summary: {}, recommendations: [], regions: [] }),
Expand Down Expand Up @@ -188,4 +189,29 @@ describe('Execute-mode toggle (issue #289)', () => {
const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement;
expect(btn.textContent).toBe('Send for Approval');
});
test('closing the modal resets execute mode and the button label (closes #754)', async () => {
mockUser('admin');
const { modal } = setupPurchaseModal();
await openPurchaseModal([minimalRec as never]);

const directRadio = document.getElementById('execute-mode-direct') as HTMLInputElement;
directRadio.click();
directRadio.dispatchEvent(new Event('change', { bubbles: true }));
const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement;
expect(btn.textContent).toBe('Execute Purchase Now');

closeModal(modal);

expect(getExecuteMode()).toBe('');
expect(btn.textContent).toBe('Send for Approval');
});

test('reopening resets a stale Execute Purchase Now label (closes #754)', async () => {
mockUser('admin');
setupPurchaseModal();
const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement;
btn.textContent = 'Execute Purchase Now';
await openPurchaseModal([minimalRec as never]);
expect(btn.textContent).toBe('Send for Approval');
});
});
27 changes: 27 additions & 0 deletions frontend/src/__tests__/history-marketplace-sell-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,33 @@ describe('Marketplace consent modal residual proration (issue #808 follow-up)',
expect(text).not.toContain('$0');
});

test('consent note prints the discount as an integer percent (closes #283)', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
purchase_id: 'ri-note',
offering_class: 'standard',
term: 3,
timestamp: SIX_MONTHS_AGO,
upfront_cost: 3600,
monthly_cost: 0,
}),
],
});
await loadHistory();
document
.getElementById('history-list')!
.querySelector<HTMLButtonElement>('.history-marketplace-sell-btn[data-marketplace-sell-id="ri-note"]')!
.click();
await Promise.resolve();
await Promise.resolve();

const arg = (confirmDialog as jest.Mock).mock.calls[0][0] as { body: HTMLElement };
const note = arg.body.querySelector('.marketplace-pricing-note')?.textContent ?? '';
expect(note).toContain('prices the listing at 5% below remaining value');
});

// Regression for the consent-modal math bug (PR follow-up to #808): the
// modal used to compute totalValue = upfrontRemaining + monthly *
// remainingMonths on the ROW TOTAL, which both (a) includes recurring cost
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/__tests__/history-truncation-notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jest.mock('../state', () => ({
jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) }));

import * as api from '../api';
import { showToast } from '../toast';

function setupDOM(): void {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
Expand Down Expand Up @@ -110,3 +111,30 @@ describe('History truncation banner (issue #248)', () => {
}
});
});

describe('History empty window (issue #282)', () => {
beforeEach(() => {
setupDOM();
const controls = document.createElement('div');
controls.id = 'history-controls';
document.body.appendChild(controls);
jest.clearAllMocks();
});
afterEach(() => {
window.history.replaceState({}, '', window.location.pathname);
});

test('mounts the amortize checkbox even when the list is empty', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [] });
await loadHistory();
expect(document.getElementById('history-amortize-checkbox')).not.toBeNull();
});

test('toasts and clears the deep link when the execution is outside an empty window', async () => {
window.history.replaceState({}, '', '/purchases#history?execution=exec-123');
(api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [] });
await loadHistory();
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("isn't in the current view") }));
expect(window.location.hash).not.toContain('execution=');
});
});
24 changes: 24 additions & 0 deletions frontend/src/__tests__/purchase-modal-submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ import {
clearPurchaseModalRecommendations,
getFanOutBuckets,
clearFanOutBuckets,
getExecuteMode,
loadRecommendations,
seedGlobalDefaults,
} from '../recommendations';
Expand Down Expand Up @@ -1113,6 +1114,29 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => {
);
});

test('fan-out open and close reset the execute button label and mode (#754)', async () => {
const rows = buildFanOutRows();
(api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } });
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: rows, regions: [] });
(state.getRecommendations as jest.Mock).mockReturnValue(rows);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue(rows);
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['ec2-1', 'rds-3']));
await loadRecommendations();

const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement;
btn.textContent = 'Execute Purchase Now';
(document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click();
await flush();
expect(btn.textContent).toBe('Send for Approval');
expect(getExecuteMode()).toBe('');

btn.textContent = 'Execute Purchase Now';
const onClose = (openModal as jest.Mock).mock.calls.slice(-1)[0][1].onClose as () => void;
onClose();
expect(btn.textContent).toBe('Send for Approval');
expect(getExecuteMode()).toBe('');
});

test('busy fan-out stays disabled while its requests are pending', async () => {
const requests = [
deferred<Awaited<ReturnType<typeof api.executePurchase>>>(),
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,57 @@ describe('Recommendations Module', () => {
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set());
});

test('re-render after a sort click keeps the loaded total in "Showing X of Y" (closes #287)', async () => {
const allRecs = [
{ id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 100, upfront_cost: 0 },
{ id: 'r2', provider: 'aws', cloud_account_id: 'a1', service: 'rds', resource_type: 'db.t3', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 200, upfront_cost: 0 },
];
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: allRecs, regions: [] });
(state.getRecommendations as jest.Mock).mockReturnValue(allRecs);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue([allRecs[0]]);
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ service: { kind: 'set', values: ['ec2'] } });
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set());

await loadRecommendations();
const liveText = (): string =>
document.querySelector('.recommendations-filter-live')?.textContent ?? '';
expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/);

document.querySelector<HTMLElement>('th.sortable')?.click();
expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/);

(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({});
(state.getVisibleRecommendations as jest.Mock).mockReturnValue([]);
});

test.each([
['select-all', '#select-all-recs'],
['row selection', 'input[data-rec-id]'],
])('%s re-render keeps the loaded total in "Showing X of Y" (closes #287)', async (_name, selector) => {
const allRecs = [
{ id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 100, upfront_cost: 0 },
{ id: 'r2', provider: 'aws', cloud_account_id: 'a1', service: 'rds', resource_type: 'db.t3', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 200, upfront_cost: 0 },
];
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: allRecs, regions: [] });
(state.getRecommendations as jest.Mock).mockReturnValue(allRecs);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue([allRecs[0]]);
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ service: { kind: 'set', values: ['ec2'] } });
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set());

await loadRecommendations();
const liveText = (): string =>
document.querySelector('.recommendations-filter-live')?.textContent ?? '';
expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/);

const box = document.querySelector<HTMLInputElement>(selector)!;
box.checked = true;
box.dispatchEvent(new Event('change', { bubbles: true }));
expect(liveText()).toMatch(/Showing 1 of 2 recommendations$/);

(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({});
(state.getVisibleRecommendations as jest.Mock).mockReturnValue([]);
});

test('renders recommendations list', async () => {
(api.getRecommendations as jest.Mock).mockResolvedValue({
summary: {},
Expand Down Expand Up @@ -7752,6 +7803,17 @@ describe('Column filters localStorage persistence (issue #163)', () => {
expect(Object.keys(result)).toHaveLength(0);
});

test('restores a filter for every filterable column, including capacity, and drops visual columns', () => {
const filterable = COLUMN_DEFS.filter((c) => c.kind !== 'visual').map((c) => c.key);
const visual = COLUMN_DEFS.find((c) => c.kind === 'visual')!.key;
const filters: Record<string, unknown> = {};
for (const key of [...filterable, visual]) filters[key] = { kind: 'set', values: ['x'] };
localStorageMock.getItem.mockReturnValue(JSON.stringify({ schemaVersion: 1, filters }));
const result = loadColumnFilters();
expect(Object.keys(result).sort()).toEqual([...filterable].sort());
expect(result).not.toHaveProperty(visual);
});

test('restores a categorical (set) filter correctly', () => {
localStorageMock.getItem.mockReturnValue(
JSON.stringify({
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,32 @@ describe('RI Exchange global filter scoping (issue #871)', () => {
expect(api.listConvertibleRIs).toHaveBeenCalledWith('123456789012');
});

it('keeps the account scope and refreshes reshape recs after a successful exchange (closes #285)', async () => {
stateMod().getCurrentAccountIDs.mockReturnValue(['uuid-1']);
(api.listTargetOfferings as jest.Mock).mockResolvedValue([]);
const modal = createModal();
(api.getExchangeQuote as jest.Mock).mockResolvedValueOnce({
IsValidExchange: true, ValidationFailureReason: '', CurrencyCode: 'USD', PaymentDueRaw: '12.50',
SourceHourlyPriceRaw: '', SourceRemainingUpfrontRaw: '', SourceRemainingTotalRaw: '',
TargetHourlyPriceRaw: '', TargetRemainingUpfrontRaw: '', TargetRemainingTotalRaw: '', Region: 'us-west-2',
});
(api.executeExchange as jest.Mock).mockResolvedValueOnce({ exchange_id: 'exch-1', quote: {} });
openExchangeModal('ri-abc', 3, 'm5.large', [
{ instance_type: 'm5.large', offering_id: '4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91', 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();
document.querySelector<HTMLButtonElement>('.modal-confirm-actions .btn-destructive')?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(api.executeExchange).toHaveBeenCalledTimes(1);
expect(api.listConvertibleRIs).toHaveBeenCalledWith('123456789012');
expect(api.listConvertibleRIs).not.toHaveBeenCalledWith(undefined);
expect(api.getReshapeRecommendations).toHaveBeenCalled();
});

it('forwards the Azure subscription GUID (external_id), not the account UUID', async () => {
stateMod().getCurrentProvider.mockReturnValue('azure');
stateMod().getCurrentAccountIDs.mockReturnValue(['uuid-az']);
Expand Down Expand Up @@ -1246,3 +1272,44 @@ describe('RI Exchange global filter scoping (issue #871)', () => {
expect(modal.classList.contains('hidden')).toBe(true);
});
});

describe('exchange history table counts (issue #286)', () => {
beforeEach(() => {
for (const id of ['ri-exchange-instances-list', 'ri-exchange-recommendations-list', 'ri-exchange-history-list']) {
const el = document.createElement('div');
el.id = id;
document.body.appendChild(el);
}
const s = jest.requireMock('../state') as { getCurrentProvider: jest.Mock; getCurrentAccountIDs: jest.Mock; getRiExchangeColumnFilters: jest.Mock; getActiveRiColumnFilters: jest.Mock };
s.getCurrentProvider.mockReturnValue('aws');
s.getCurrentAccountIDs.mockReturnValue([]);
s.getRiExchangeColumnFilters.mockReturnValue({});
s.getActiveRiColumnFilters.mockReturnValue({});
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([]);
(api.getRIUtilization as jest.Mock).mockResolvedValue([]);
(api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [], recs_staleness: '', recs_collected_at: null });
});

afterEach(() => {
document.body.innerHTML = '';
jest.clearAllMocks();
});

it('shows each count once, inside the type cells, with no duplicate Count column', async () => {
(api.getRIExchangeHistory as jest.Mock).mockResolvedValue([{
id: 'h1', account_id: 'a', exchange_id: 'exch-1', region: 'us-east-1', source_ri_ids: ['ri-1'],
source_instance_type: 'm5.large', source_count: 4, target_offering_id: 'o', target_instance_type: 'm5.4xlarge',
target_count: 1, payment_due: '1.00', status: 'completed', mode: 'manual',
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}]);
await loadRIExchange();
const table = document.querySelector('#ri-exchange-history-list table')!;
const headers = Array.from(table.querySelectorAll('th')).map((h) => h.textContent);
expect(headers).not.toContain('Count');
const cells = Array.from(table.querySelectorAll('tbody tr td')).map((c) => c.textContent);
expect(cells).toHaveLength(headers.length);
expect(cells).toContain('4x m5.large');
expect(cells).toContain('1x m5.4xlarge');
expect(cells).not.toContain('4');
});
});
6 changes: 2 additions & 4 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,6 @@ export async function handleExecutePurchase(): Promise<void> {
const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null;
setExecutePurchaseSubmitting(executeBtn, true, 'Sending...');

const defaultBtnLabel = isDirect ? 'Execute Purchase Now' : 'Send for Approval';

// Confirmation dialog varies by mode:
// - Approval path: low-friction, non-destructive.
// - Direct-execute path: red destructive dialog with cost callout and
Expand Down Expand Up @@ -460,7 +458,7 @@ export async function handleExecutePurchase(): Promise<void> {
});

if (!ok) {
setExecutePurchaseSubmitting(executeBtn, false, defaultBtnLabel);
setExecutePurchaseSubmitting(executeBtn, false, getExecuteMode() === 'direct' ? 'Execute Purchase Now' : 'Send for Approval');
return;
}

Expand Down Expand Up @@ -558,7 +556,7 @@ export async function handleExecutePurchase(): Promise<void> {
const verb = isDirect ? 'execute' : 'send for approval';
showToast({ message: `Failed to ${verb} purchase: ${err.message}`, kind: 'error' });
} finally {
setExecutePurchaseSubmitting(executeBtn, false, defaultBtnLabel);
setExecutePurchaseSubmitting(executeBtn, false, getExecuteMode() === 'direct' ? 'Execute Purchase Now' : 'Send for Approval');
}
}

Expand Down
10 changes: 7 additions & 3 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ const VALID_PROVIDERS: api.Provider[] = ['aws', 'azure', 'gcp'];
const AWS_MARKETPLACE_FEE_PERCENT = 12;
// awsMarketplaceNetFactor: fraction of list price the seller receives after the fee.
const AWS_MARKETPLACE_NET_FACTOR = 1 - AWS_MARKETPLACE_FEE_PERCENT / 100;
// awsMarketplaceBuyerDiscountFactor: applied to residual RI value to compute the default list price.
const AWS_MARKETPLACE_BUYER_DISCOUNT = 0.95;
// Discount off residual RI value for the default list price; 1 - 5/100 mirrors
// awsMarketplaceBuyerDiscountFactor (0.95) in internal/api/handler_marketplace.go.
const AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT = 5;
const AWS_MARKETPLACE_BUYER_DISCOUNT = 1 - AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT / 100;

type StatusFilter = 'all' | 'pending' | 'completed' | 'failed' | 'expired' | 'cancelled';

Expand Down Expand Up @@ -1190,6 +1192,8 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {

if (!purchases || purchases.length === 0) {
container.innerHTML = '<p class="empty">No purchase history found for the selected period.</p>';
mountAmortizeCheckbox('history-controls', 'history-amortize-checkbox');
applyExecutionDeepLink();
return;
}

Expand Down Expand Up @@ -1595,7 +1599,7 @@ function wireRowActionHandlers(container: HTMLElement): void {

const noteEl = document.createElement('p');
noteEl.className = 'marketplace-pricing-note';
noteEl.textContent = `AWS charges a ${AWS_MARKETPLACE_FEE_PERCENT}% transaction fee on proceeds. The default schedule prices the listing at ${(1 - AWS_MARKETPLACE_BUYER_DISCOUNT) * 100}% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.`;
noteEl.textContent = `AWS charges a ${AWS_MARKETPLACE_FEE_PERCENT}% transaction fee on proceeds. The default schedule prices the listing at ${AWS_MARKETPLACE_BUYER_DISCOUNT_PERCENT}% below remaining value. You can adjust pricing by contacting your administrator or modifying the schedule via the API. This action cannot be undone without cancelling the listing.`;
bodyEl.appendChild(noteEl);

const ok = await confirmDialog({
Expand Down
Loading
Loading