diff --git a/frontend/src/__tests__/purchase-execution-toast.test.ts b/frontend/src/__tests__/purchase-execution-toast.test.ts index f35f6dd5d..633847f37 100644 --- a/frontend/src/__tests__/purchase-execution-toast.test.ts +++ b/frontend/src/__tests__/purchase-execution-toast.test.ts @@ -804,7 +804,7 @@ describe('handleFanOutExecute — fan-out path', () => { describe('handleExecutePurchase — double-submit guard (#644)', () => { beforeEach(() => { jest.clearAllMocks(); - (recs.getFanOutBuckets as jest.Mock).mockReturnValue([]); + (recs.getFanOutBuckets as jest.Mock).mockReturnValue(null); (recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([buildMinimalRec()]); (plans.closePurchaseModal as jest.Mock).mockImplementation(() => undefined); }); diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts new file mode 100644 index 000000000..2d9a43a39 --- /dev/null +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -0,0 +1,1112 @@ +/** + * Issue #1903 / #1904: the purchase modal's Term and Payment selects must + * re-price the row (not just relabel it), and the fan-out modal must never + * submit a bucket it told the user would be skipped. + * + * These tests drive app.ts and recommendations.ts and assert that the + * displayed variant matches the body passed to api.executePurchase. + * The backend independently resolves identity and pricing from stored + * recommendations (internal/api/purchase_pricing.go). + */ + +// Mocks must precede imports. + +jest.mock('../api', () => ({ + initAuth: jest.fn(), + isAuthenticated: jest.fn(), + getCurrentUser: jest.fn(), + executePurchase: jest.fn(), + getRecommendations: jest.fn(), + getConfig: jest.fn().mockResolvedValue({ global: {} }), + listAccountsMinimal: jest.fn().mockResolvedValue([]), + listAccountServiceOverrides: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../api/recommendations', () => ({ + getRecommendationsFreshness: jest.fn().mockResolvedValue({ + last_collected_at: new Date().toISOString(), + last_collection_error: null, + }), + refreshRecommendations: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../state', () => ({ + getCurrentProvider: jest.fn().mockReturnValue('all'), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + getRecommendations: jest.fn().mockReturnValue([]), + getRecommendationByID: jest.fn().mockReturnValue(undefined), + setRecommendations: jest.fn(), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + clearSelectedRecommendations: jest.fn(), + addSelectedRecommendation: jest.fn(), + removeSelectedRecommendation: jest.fn(), + getRecommendationsSort: jest.fn().mockReturnValue({ column: 'savings', direction: 'desc' }), + setRecommendationsSort: jest.fn(), + getRecommendationsColumnFilters: jest.fn().mockReturnValue({}), + setRecommendationsColumnFilter: jest.fn(), + clearAllRecommendationsColumnFilters: jest.fn(), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCostPeriod: jest.fn().mockReturnValue('monthly'), + setCostPeriod: jest.fn(), + getHiddenColumns: jest.fn().mockReturnValue(new Set()), + setHiddenColumns: jest.fn(), + getCurrentUser: jest.fn(), + // setupRecommendationsHandlers (real — ../recommendations is NOT mocked in + // this file) subscribes to both on module init via app.ts's + // setupEventListeners(). + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), +})); + +jest.mock('../auth', () => ({ + showLoginModal: jest.fn(), + updateUserUI: jest.fn(), +})); + +jest.mock('../dashboard', () => ({ + loadDashboard: jest.fn().mockResolvedValue(undefined), + setupDashboardHandlers: jest.fn(), +})); + +jest.mock('../navigation', () => ({ + switchTab: jest.fn(), + applyTabFromPath: jest.fn().mockReturnValue('dashboard'), + initRouter: jest.fn(), + switchSettingsSubTab: jest.fn(), + getSettingsSubTabFromPath: jest.fn().mockReturnValue('general'), +})); + +// NOTE: ../recommendations is intentionally NOT mocked — this file exercises +// the real openPurchaseModal / getFanOutBuckets / loadRecommendations. + +jest.mock('../plans', () => ({ + savePlan: jest.fn(), + setupPlanHandlers: jest.fn(), + closePlanModal: jest.fn(), + openNewPlanModal: jest.fn(), + closePurchaseModal: jest.fn(), +})); + +jest.mock('../settings', () => ({ + saveGlobalSettings: jest.fn(), + setupSettingsHandlers: jest.fn(), + resetSettings: jest.fn(), +})); + +jest.mock('../riexchange', () => ({ + setupRIExchangeHandlers: jest.fn(), + saveAutomationSettings: jest.fn(), +})); + +jest.mock('../users', () => ({ + setupUserHandlers: jest.fn(), +})); + +jest.mock('../apikeys', () => ({ + initApiKeys: jest.fn(), +})); + +jest.mock('../history', () => ({ + loadHistory: jest.fn(), + setupHistoryHandlers: jest.fn(), +})); + +jest.mock('../modules/savings-history', () => ({ + initSavingsHistory: jest.fn(), +})); + +jest.mock('../purchases-deeplink', () => ({ + handlePurchaseDeeplink: jest.fn(), +})); + +jest.mock('../modal', () => ({ + openModal: jest.fn(), + closeModal: jest.fn(), +})); + +jest.mock('../confirmDialog', () => ({ + confirmDialog: jest.fn().mockResolvedValue(true), +})); + +jest.mock('../archera', () => ({ + handleArcheraDeeplink: jest.fn(), + openArcheraOfferModal: jest.fn(), +})); + +jest.mock('../toast', () => ({ + showToast: jest.fn(), +})); + +// Imports + +import { handleExecutePurchase, setupEventListeners } from '../app'; +import * as api from '../api'; +import * as state from '../state'; +import { showToast } from '../toast'; +import { confirmDialog } from '../confirmDialog'; +import { openModal } from '../modal'; +import { + openPurchaseModal, + getPurchaseModalRecommendations, + clearPurchaseModalRecommendations, + getFanOutBuckets, + clearFanOutBuckets, + loadRecommendations, + seedGlobalDefaults, +} from '../recommendations'; +import { formatCurrency } from '../utils'; +import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; +import type { LocalRecommendation } from '../types'; + +// Fixtures + +// One AWS EC2 cell fanned out into its four (term, payment) variants — the +// same shape providers/aws/recommendations/client.go produces for a single +// physical resource. Every #1903 test reads from a fresh copy of this list +// via buildRows() so no test can leak a mutation into another. +function buildRows(): LocalRecommendation[] { + return [ + { + id: 'v-3-all', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', count: 2, term: 3, + payment: 'all-upfront', upfront_cost: 36000, monthly_cost: 0, savings: 900, + }, + { + id: 'v-3-partial', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', count: 2, term: 3, + payment: 'partial-upfront', upfront_cost: 18000, monthly_cost: 300, savings: 850, + }, + { + id: 'v-1-all', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', count: 2, term: 1, + payment: 'all-upfront', upfront_cost: 12000, monthly_cost: 0, savings: 700, + }, + { + id: 'v-1-no', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', count: 1, term: 1, + payment: 'no-upfront', upfront_cost: 0, monthly_cost: 800, savings: 500, + }, + ]; +} + +/** Drains the microtask queue enough for the async handlers under test to settle. */ +async function flush(): Promise { + for (let i = 0; i < 6; i++) await Promise.resolve(); +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +// DOM / mock scaffolding + +beforeEach(() => { + document.body.replaceChildren(); + + const opportunitiesTab = document.createElement('div'); + opportunitiesTab.id = 'opportunities-tab'; + opportunitiesTab.className = 'tab-content active'; + const summaryEl = document.createElement('div'); + summaryEl.id = 'recommendations-summary'; + const listEl = document.createElement('div'); + listEl.id = 'recommendations-list'; + opportunitiesTab.appendChild(summaryEl); + opportunitiesTab.appendChild(listEl); + document.body.appendChild(opportunitiesTab); + + const purchaseModal = document.createElement('div'); + purchaseModal.id = 'purchase-modal'; + purchaseModal.className = 'hidden'; + const purchaseDetails = document.createElement('div'); + purchaseDetails.id = 'purchase-details'; + purchaseModal.appendChild(purchaseDetails); + document.body.appendChild(purchaseModal); + + const executeBtn = document.createElement('button'); + executeBtn.id = 'execute-purchase-btn'; + document.body.appendChild(executeBtn); + + const closeBtn = document.createElement('button'); + closeBtn.id = 'close-purchase-modal-btn'; + purchaseModal.appendChild(closeBtn); + + setupEventListeners(); + + jest.clearAllMocks(); + clearPurchaseModalRecommendations(); + clearFanOutBuckets(); + seedGlobalDefaults(3, 'all-upfront'); + + // loadBulkPurchaseState() (setup.ts's localStorage mock defaults getItem to + // null) only reads cachedGlobalDefaultPayment when a raw value is present — + // otherwise it falls back to the hardcoded 'all-upfront' default and never + // consults GlobalConfig. Seed a truthy (capacity-only) value so the + // bulk-purchase toolbar picks up the mocked getConfig() default_payment; + // tests that need a specific capacity override this per-test. + (localStorage.getItem as jest.Mock).mockReturnValue('{}'); + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + (api.getConfig as jest.Mock).mockResolvedValue({ global: {} }); + (api.executePurchase as jest.Mock).mockResolvedValue({ + execution_id: 'exec-aaaaaaaa', + email_sent: true, + approval_recipient: 'approver@example.com', + }); + (state.getCurrentUser as jest.Mock).mockReturnValue({ + id: 'u-admin', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID], + }); + // Every #1903 test needs the full loaded cell so pricedCellVariant / + // cellTermOptions / cellPaymentOptions can find the sibling rows. + (state.getRecommendations as jest.Mock).mockReturnValue(buildRows()); +}); + +// #1903: purchase modal re-prices on Term/Payment change + +describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { + test.each(['active', 'closed', 'executed'])('legacy payment delayed open cannot restore rows after newer modal is %s', async (action) => { + const firstFetch = deferred>>(); + (api.listAccountServiceOverrides as jest.Mock) + .mockReturnValueOnce(firstFetch.promise) + .mockResolvedValueOnce([]); + const first = { ...buildRows()[0]!, id: 'first', resource_type: 'c5.large' }; + const second = { ...buildRows()[1]!, id: 'second', resource_type: 'm6i.large' }; + (state.getRecommendations as jest.Mock).mockReturnValue([first, second]); + + const pendingFirst = openPurchaseModal([first]); + await openPurchaseModal([second]); + expect(getPurchaseModalRecommendations()).toEqual([second]); + if (action === 'closed') (document.getElementById('close-purchase-modal-btn') as HTMLButtonElement).click(); + if (action === 'executed') await handleExecutePurchase(); + const rendered = document.getElementById('purchase-details')!.innerHTML; + const openCount = (openModal as jest.Mock).mock.calls.length; + + firstFetch.resolve([]); + await pendingFirst; + + expect(getPurchaseModalRecommendations()).toEqual(action === 'active' ? [second] : []); + expect(document.getElementById('purchase-details')!.innerHTML).toBe(rendered); + expect(openModal).toHaveBeenCalledTimes(openCount); + await handleExecutePurchase(); + if (action === 'closed') { + expect(api.executePurchase).not.toHaveBeenCalled(); + } else { + expect(api.executePurchase).toHaveBeenCalledTimes(1); + expect(api.executePurchase).toHaveBeenCalledWith([expect.objectContaining(second)], 100, undefined); + } + }); + + test.each([undefined, '', 'unrecognized'])('legacy payment %j resolves a complete priced variant before submission', async (payment) => { + const details = { platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }; + const legacy = { ...buildRows()[0]!, id: 'legacy', payment, upfront_cost: 17, monthly_cost: 29, details }; + const priced = { ...buildRows()[0]!, count: 4, details: { ...details, vcpu: 2 } }; + (state.getRecommendations as jest.Mock).mockReturnValue([legacy, priced]); + + await openPurchaseModal([legacy]); + + const row = document.querySelector('.purchase-modal-table tbody tr')!; + expect(row.cells[4]!.textContent).toBe('4'); + expect(row.cells[5]!.textContent).toBe(formatCurrency(priced.upfront_cost)); + expect(row.cells[6]!.textContent).toBe(formatCurrency(priced.monthly_cost!)); + expect(getPurchaseModalRecommendations()).toEqual([expect.objectContaining(priced)]); + await handleExecutePurchase(); + expect(api.executePurchase).toHaveBeenCalledWith([expect.objectContaining(priced)], 100, undefined); + }); + + test.each([true, false])('legacy payment uses configured preference only when priced (available: %s)', async (available) => { + const legacy = { ...buildRows()[0]!, id: 'legacy', payment: '' }; + const all = buildRows()[0]!; + const partial = buildRows()[1]!; + seedGlobalDefaults(3, 'partial-upfront'); + (state.getRecommendations as jest.Mock).mockReturnValue(available ? [legacy, all, partial] : [legacy, all]); + + await openPurchaseModal([legacy]); + + const expected = available ? partial : all; + expect(getPurchaseModalRecommendations()).toEqual([expect.objectContaining(expected)]); + expect(document.querySelector('.purchase-row-payment')!.value).toBe(expected.payment); + }); + + test.each([false, true])('legacy payment resolves with account override fetch failure: %s', async (failed) => { + const legacy = { ...buildRows()[0]!, id: 'legacy', payment: '' }; + (state.getRecommendations as jest.Mock).mockReturnValue([legacy, ...buildRows()]); + if (failed) { + (api.listAccountServiceOverrides as jest.Mock).mockRejectedValue(new Error('offline')); + } else { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'ovr', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }, + ]); + } + + await openPurchaseModal([legacy]); + + expect(getPurchaseModalRecommendations()[0]).toMatchObject(buildRows()[failed ? 0 : 1]!); + expect(document.querySelector('.purchase-row-payment-source') !== null).toBe(!failed); + }); + + test.each([false, true])('legacy payment excludes an unavailable row (zero at capacity: %s)', async (zero) => { + const legacy = { ...buildRows()[0]!, id: 'legacy', payment: '' }; + const priced = { ...buildRows()[0]!, count: 1 }; + (state.getRecommendations as jest.Mock).mockReturnValue(zero ? [legacy, priced] : [legacy]); + + await openPurchaseModal([legacy], zero ? 50 : 100); + + const notice = document.querySelector('.purchase-modal-unavailable'); + expect(notice?.getAttribute('role')).toBe('alert'); + for (const label of ['a1', 'ec2', 'm5.large', 'us-east-1', 'excluded']) expect(notice?.textContent).toContain(label); + expect(getPurchaseModalRecommendations()).toEqual([]); + expect(document.querySelectorAll('.purchase-modal-table tbody tr')).toHaveLength(0); + expect((document.getElementById('execute-purchase-btn') as HTMLButtonElement).disabled).toBe(true); + await handleExecutePurchase(); + expect(api.executePurchase).not.toHaveBeenCalled(); + + clearPurchaseModalRecommendations(); + await openPurchaseModal([priced]); + expect(document.querySelector('.purchase-modal-unavailable')).toBeNull(); + expect(getPurchaseModalRecommendations()).toEqual([expect.objectContaining(priced)]); + }); + + test('legacy payment skips a zero-unit preferred variant and scales the viable fallback once', async () => { + const legacy = { ...buildRows()[0]!, id: 'legacy', payment: '', count: 1, recommended_count: 2 }; + const all = { ...buildRows()[0]!, count: 1 }; + const partial = buildRows()[1]!; + (state.getRecommendations as jest.Mock).mockReturnValue([legacy, all, partial]); + + await openPurchaseModal([legacy], 50); + + expect(getPurchaseModalRecommendations()).toEqual([expect.objectContaining({ + ...partial, count: 1, recommended_count: 2, upfront_cost: 9000, monthly_cost: 150, savings: 425, + })]); + }); + + test('legacy payment mixed bulk selection excludes unpriced rows from totals, selection and POST', async () => { + const legacy = { ...buildRows()[0]!, id: 'legacy', payment: '' }; + const unavailable = { ...legacy, id: 'unavailable', resource_type: 'm6i.large' }; + const priced = buildRows()[0]!; + const rows = [legacy, unavailable, priced]; + (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([legacy.id, unavailable.id])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(getFanOutBuckets()).toBeNull(); + expect(document.querySelector('.purchase-modal-unavailable')?.textContent).toContain('m6i.large'); + expect(document.querySelectorAll('.purchase-modal-table tbody tr')).toHaveLength(1); + expect(document.getElementById('purchase-modal-totals-row')?.textContent).toContain(formatCurrency(priced.upfront_cost)); + const selectAll = document.getElementById('purchase-modal-select-all') as HTMLInputElement; + selectAll.click(); + expect(getPurchaseModalRecommendations()).toEqual([]); + selectAll.click(); + expect(getPurchaseModalRecommendations()).toEqual([expect.objectContaining(priced)]); + (document.getElementById('execute-mode-direct') as HTMLInputElement).click(); + expect(document.querySelector('.direct-execute-warning')?.textContent).toContain('36,000.00'); + await handleExecutePurchase(); + expect(api.executePurchase).toHaveBeenCalledWith([expect.objectContaining(priced)], 100, 'direct'); + }); + + test('legacy payment normalization preserves a valid Azure upfront price', async () => { + const rec: LocalRecommendation = { ...buildRows()[0]!, provider: 'azure', service: 'compute', resource_type: 'Standard_D2s_v3', region: 'eastus', payment: 'upfront' }; + (state.getRecommendations as jest.Mock).mockReturnValue([rec]); + + await openPurchaseModal([rec]); + + expect(getPurchaseModalRecommendations()).toEqual([{ ...rec, payment: 'all-upfront' }]); + await handleExecutePurchase(); + expect(api.executePurchase).toHaveBeenCalledWith([expect.objectContaining({ ...rec, payment: 'all-upfront' })], 100, undefined); + }); + + test.each<[string, string, string, Record, unknown]>([ + ['ec2', 'platform', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'Windows'], + ['ec2', 'tenancy', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'dedicated'], + ['ec2', 'scope', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'Availability Zone'], + ['compute', 'platform', 'm5.large', { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, 'Windows'], + ['rds', 'az_config', 'db.r5.large', { engine: 'postgres', az_config: 'single-az' }, 'multi-az'], + ['relational-db', 'az_config', 'db.r5.large', { engine: 'postgres', az_config: 'single-az' }, 'multi-az'], + ['rds', 'engine', 'db.r5.large', { engine: 'postgres', az_config: 'single-az' }, 'mysql'], + ['elasticache', 'engine', 'cache.r6g.large', { engine: 'redis', node_type: 'cache.r6g.large' }, 'memcached'], + ['cache', 'engine', 'cache.r6g.large', { engine: 'redis', node_type: 'cache.r6g.large' }, 'memcached'], + ['savingsplans', 'plan_type', '', { plan_type: 'Compute', hourly_commitment: 1 }, 'SageMaker'], + ['savings-plans-ec2instance', 'instance_family', '', { plan_type: 'EC2Instance', instance_family: 'm5', region: 'us-east-1', hourly_commitment: 1 }, 'm6i'], + ['savings-plans-ec2instance', 'region', '', { plan_type: 'EC2Instance', instance_family: 'm5', region: 'us-east-1', hourly_commitment: 1 }, 'us-west-2'], + ])('purchase identity excludes %s variants with different %s', async (service, field, resourceType, details, otherValue) => { + const savingsPlan = service === 'savingsplans' || service.startsWith('savings-plans'); + const original = { ...buildRows()[0]!, service, resource_type: resourceType, count: savingsPlan ? 1 : 2, region: savingsPlan ? '' : 'us-east-1', details }; + const other = { ...original, id: 'other-identity', term: 1, upfront_cost: 12000, details: { ...details, [field]: otherValue } }; + (state.getRecommendations as jest.Mock).mockReturnValue([other, original]); + + await openPurchaseModal([original]); + + const termSelect = document.querySelector('.purchase-row-term')!; + expect(Array.from(termSelect.options, (option) => option.value)).toEqual(['3']); + expect(getPurchaseModalRecommendations()).toEqual([original]); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + expect((api.executePurchase as jest.Mock).mock.calls[0]![0]).toEqual([ + expect.objectContaining({ id: original.id, term: 3, details, upfront_cost: original.upfront_cost }), + ]); + }); + + test.each([false, true])('purchase identity selects the matching priced EC2 variant (reverse order: %s)', async (reverse) => { + const details = { instance_type: 'm5.large', platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }; + const original = { ...buildRows()[0]!, details }; + const dedicated = { ...original, id: 'dedicated-1-all', term: 1, upfront_cost: 12000, details: { ...details, tenancy: 'dedicated' } }; + const matching = { ...original, id: 'default-1-no', term: 1, payment: 'no-upfront', upfront_cost: 0, monthly_cost: 800, savings: 500 }; + const loaded = [original, dedicated, matching]; + (state.getRecommendations as jest.Mock).mockReturnValue(reverse ? loaded.reverse() : loaded); + + await openPurchaseModal([original]); + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + const row = document.querySelector('.purchase-modal-table tbody tr')!; + expect(row.cells[5]!.textContent).toBe(formatCurrency(0)); + expect(row.cells[6]!.textContent).toBe(formatCurrency(800)); + expect(document.querySelector('.purchase-row-payment')!.value).toBe('no-upfront'); + expect(getPurchaseModalRecommendations()[0]).toMatchObject(matching); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + expect((api.executePurchase as jest.Mock).mock.calls[0]![0]).toEqual([ + expect.objectContaining(matching), + ]); + }); + + test.each([undefined, null, [], 'invalid', { platform: 1 }, {}])('purchase identity does not match populated EC2 details to %j', async (otherDetails) => { + const original = { ...buildRows()[0]!, details: { platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' } }; + const other = { ...original, id: 'missing-identity', term: 1, details: otherDetails }; + (state.getRecommendations as jest.Mock).mockReturnValue([other, original]); + + await openPurchaseModal([original]); + + expect(Array.from(document.querySelector('.purchase-row-term')!.options, (option) => option.value)) + .toEqual(['3']); + expect(getPurchaseModalRecommendations()[0]).toEqual(original); + }); + + test('purchase identity allows Savings Plans prices and offering IDs to change', async () => { + const original = { + ...buildRows()[0]!, service: 'savings-plans-ec2instance', resource_type: '', region: '', count: 1, + details: { plan_type: 'EC2Instance', instance_family: 'm5', region: 'us-east-1', hourly_commitment: 1, offering_id: 'offering-3-all', coverage: '50.0%' }, + }; + const matching = { + ...original, id: 'sp-1-no', term: 1, payment: 'no-upfront', upfront_cost: 0, monthly_cost: 1460, savings: 400, + details: { ...original.details, hourly_commitment: 2, offering_id: 'offering-1-no', coverage: '40.0%' }, + }; + (state.getRecommendations as jest.Mock).mockReturnValue([original, matching]); + + await openPurchaseModal([original]); + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + expect(getPurchaseModalRecommendations()[0]).toMatchObject(matching); + expect(document.querySelector('.purchase-modal-table tbody tr')!.cells[6]!.textContent) + .toBe(formatCurrency(1460)); + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + expect((api.executePurchase as jest.Mock).mock.calls[0]![0]).toEqual([ + expect.objectContaining(matching), + ]); + }); + + test('T1 term change re-prices the submitted body', async () => { + const rows = buildRows(); + const v3all = rows.find((r) => r.id === 'v-3-all')!; + + await openPurchaseModal([v3all]); + + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + const tr = document.querySelector('.purchase-modal-table tbody tr')!; + expect(tr.cells[5]!.textContent).toBe(formatCurrency(12000)); + expect(tr.cells[6]!.textContent).toBe(formatCurrency(0)); + expect(tr.cells[7]!.textContent).toBe(formatCurrency(700)); + expect(document.getElementById('purchase-modal-total-upfront')?.textContent).toContain(formatCurrency(12000)); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(api.executePurchase).toHaveBeenCalledTimes(1); + const body = (api.executePurchase as jest.Mock).mock.calls[0]![0] as Array>; + expect(body[0]).toMatchObject({ + id: 'v-1-all', term: 1, payment: 'all-upfront', upfront_cost: 12000, monthly_cost: 0, savings: 700, + }); + }); + + test('T2 payment change re-prices the submitted body', async () => { + const rows = buildRows(); + const v1all = rows.find((r) => r.id === 'v-1-all')!; + + await openPurchaseModal([v1all]); + + const paymentSelect = document.querySelector('.purchase-row-payment')!; + paymentSelect.value = 'no-upfront'; + paymentSelect.dispatchEvent(new Event('change')); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(api.executePurchase).toHaveBeenCalledTimes(1); + const body = (api.executePurchase as jest.Mock).mock.calls[0]![0] as Array>; + expect(body[0]).toMatchObject({ + id: 'v-1-no', term: 1, payment: 'no-upfront', upfront_cost: 0, monthly_cost: 800, count: 1, + }); + }); + + test('T3 options are only priced variants', async () => { + const rows = buildRows(); + const v3all = rows.find((r) => r.id === 'v-3-all')!; + + await openPurchaseModal([v3all]); + + const termSelect = document.querySelector('.purchase-row-term')!; + const paymentSelect = document.querySelector('.purchase-row-payment')!; + expect(Array.from(termSelect.options).map((o) => o.value)).toEqual(['1', '3']); + expect(Array.from(paymentSelect.options).map((o) => o.value)).toEqual(['all-upfront', 'partial-upfront']); + + // Azure cell with a single loaded variant. + const azureRec: LocalRecommendation = { + id: 'az-1', provider: 'azure', cloud_account_id: 'a2', service: 'compute', + region: 'eastus', resource_type: 'Standard_D2s_v3', count: 1, term: 3, + payment: 'upfront', upfront_cost: 500, savings: 100, + }; + (state.getRecommendations as jest.Mock).mockReturnValue([azureRec]); + clearPurchaseModalRecommendations(); + + await openPurchaseModal([azureRec]); + + const termSelect2 = document.querySelector('.purchase-row-term')!; + const paymentSelect2 = document.querySelector('.purchase-row-payment')!; + expect(Array.from(termSelect2.options).map((o) => o.value)).toEqual(['3']); + expect(Array.from(paymentSelect2.options).map((o) => o.value)).toEqual(['all-upfront']); + }); + + test('T3 capacity-aware payment options choose the viable alternate on term change', async () => { + const details = { platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }; + const rows = buildRows().filter((row) => row.id !== 'v-3-partial').map((row) => { + if (row.id === 'v-1-all') return { ...row, details, count: 1, upfront_cost: 6000 }; + if (row.id === 'v-1-no') return { + ...row, count: 2, recommended_count: 2, monthly_cost: 1600, + details, + }; + return { ...row, details }; + }); + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (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(['v-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + const row = document.querySelector('.purchase-modal-table tbody tr')!; + expect(row.cells[4]!.textContent).toBe('1'); + expect(row.cells[5]!.textContent).toBe(formatCurrency(0)); + expect(row.cells[6]!.textContent).toBe(formatCurrency(800)); + expect(document.querySelector('.purchase-row-payment')!.value).toBe('no-upfront'); + expect(Array.from(document.querySelector('.purchase-row-payment')!.options).map((o) => o.value)) + .toEqual(['no-upfront']); + expect(getPurchaseModalRecommendations()[0]).toMatchObject({ + id: 'v-1-no', term: 1, payment: 'no-upfront', count: 1, recommended_count: 2, + upfront_cost: 0, monthly_cost: 800, + }); + expect(document.getElementById('purchase-modal-total-upfront')?.textContent).toContain(formatCurrency(0)); + (document.getElementById('execute-mode-direct') as HTMLInputElement).click(); + expect(document.querySelector('.direct-execute-warning')?.textContent).toContain(formatCurrency(0)); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledWith( + [expect.objectContaining({ + id: 'v-1-no', term: 1, payment: 'no-upfront', count: 1, recommended_count: 2, + details: { platform: 'Linux/UNIX', tenancy: 'default', scope: 'Region' }, + })], + 50, + 'direct', + ); + }); + + test('T3 viable payment survives term swaps and starts from loaded count', async () => { + const rows = [ + { ...buildRows()[0]!, id: 'v-3-no', payment: 'no-upfront' as const, count: 2, monthly_cost: 1600 }, + { ...buildRows()[2]!, count: 2 }, + { ...buildRows()[3]!, count: 2, recommended_count: 2, monthly_cost: 1600 }, + ]; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (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(['v-3-no'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + expect(getPurchaseModalRecommendations()[0]).toMatchObject({ id: 'v-1-no', payment: 'no-upfront', count: 1 }); + expect(Array.from(document.querySelector('.purchase-row-payment')!.options).map((o) => o.value)) + .toEqual(['all-upfront', 'no-upfront']); + + const termSelectAgain = document.querySelector('.purchase-row-term')!; + termSelectAgain.value = '3'; + termSelectAgain.dispatchEvent(new Event('change')); + expect(getPurchaseModalRecommendations()[0]).toMatchObject({ id: 'v-3-no', payment: 'no-upfront', count: 1 }); + const row = document.querySelector('.purchase-modal-table tbody tr')!; + expect(row.cells[6]!.textContent).toBe(formatCurrency(800)); + expect(row.querySelector('.purchase-row-term')!.value).toBe('3'); + expect(row.querySelector('.purchase-row-payment')!.value).toBe('no-upfront'); + }); + + test('T3 all-zero term restores the prior priced row', async () => { + const rows = [ + { ...buildRows()[0]!, count: 2 }, + { ...buildRows()[2]!, count: 1, upfront_cost: 6000 }, + { ...buildRows()[3]!, count: 1, recommended_count: 1 }, + ]; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (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(['v-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const before = getPurchaseModalRecommendations()[0]!; + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })); + expect(termSelect.value).toBe('3'); + expect(getPurchaseModalRecommendations()[0]).toEqual(before); + expect(document.querySelector('.purchase-row-payment')!.value).toBe('all-upfront'); + }); + + test('T4 capacity scaling survives a swap (bulk path)', async () => { + const rows = buildRows(); + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (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(['v-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const tr = document.querySelector('.purchase-modal-table tbody tr')!; + expect(tr.cells[4]!.textContent).toBe('1'); + expect(tr.cells[5]!.textContent).toBe(formatCurrency(18000)); + + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + const tr2 = document.querySelector('.purchase-modal-table tbody tr')!; + expect(tr2.cells[5]!.textContent).toBe(formatCurrency(6000)); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(api.executePurchase).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'v-1-all', count: 1, recommended_count: 2, upfront_cost: 6000 })]), + 50, + undefined, + ); + }); + + test('T5 zero-unit variant is refused and the row keeps its price', async () => { + const rows = buildRows(); + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (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(['v-3-all'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + // The term-change re-render replaced the row — re-query the fresh Payment select. + const paymentSelect = document.querySelector('.purchase-row-payment')!; + paymentSelect.value = 'no-upfront'; + paymentSelect.dispatchEvent(new Event('change')); + + expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })); + expect(paymentSelect.value).toBe('all-upfront'); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const body = (api.executePurchase as jest.Mock).mock.calls[0]![0] as Array>; + expect(body[0]).toMatchObject({ id: 'v-1-all', upfront_cost: 6000 }); + }); + + test('T6 account override re-prices at open', async () => { + const rows = buildRows(); + const v3all = rows.find((r) => r.id === 'v-3-all')!; + + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'ovr-1', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }, + ]); + await openPurchaseModal([v3all]); + + const live = getPurchaseModalRecommendations(); + expect(live[0]).toMatchObject({ id: 'v-3-partial', payment: 'partial-upfront', upfront_cost: 18000 }); + expect(document.querySelector('.purchase-row-payment-source')).not.toBeNull(); + expect(document.querySelector('.purchase-modal-table tbody tr')!.cells[5]!.textContent) + .toBe(formatCurrency(18000)); + + clearPurchaseModalRecommendations(); + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'ovr-2', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'no-upfront' }, + ]); + await openPurchaseModal([v3all]); + + const live2 = getPurchaseModalRecommendations(); + expect(live2[0]).toMatchObject({ id: 'v-3-all', payment: 'all-upfront', upfront_cost: 36000 }); + expect(document.querySelector('.purchase-row-payment-source')).toBeNull(); + }); + + test('T7 direct-execute warning follows a term change', async () => { + const rows = buildRows(); + const v3all = rows.find((r) => r.id === 'v-3-all')!; + + await openPurchaseModal([v3all]); + + const directRadio = document.getElementById('execute-mode-direct') as HTMLInputElement; + expect(directRadio).not.toBeNull(); + directRadio.click(); + directRadio.dispatchEvent(new Event('change', { bubbles: true })); + + expect(document.querySelector('.direct-execute-warning')?.textContent).toContain('36,000.00'); + + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + + expect(document.querySelector('.direct-execute-warning')?.textContent).toContain('12,000.00'); + }); + + test('busy single purchase stays disabled while its request is pending', async () => { + const request = deferred>>(); + (api.executePurchase as jest.Mock).mockReturnValue(request.promise); + const rows = buildRows(); + await openPurchaseModal([rows[0]!]); + + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + executeBtn.click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + + const termSelect = document.querySelector('.purchase-row-term')!; + termSelect.value = '1'; + termSelect.dispatchEvent(new Event('change')); + const include = document.querySelector('.purchase-modal-row-include')!; + include.checked = false; + include.dispatchEvent(new Event('change')); + include.checked = true; + include.dispatchEvent(new Event('change')); + + expect(executeBtn.disabled).toBe(true); + executeBtn.click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + + request.resolve({ + execution_id: 'exec-single', + status: 'pending', + email_sent: true, + approval_recipient: 'approver@example.com', + }); + await flush(); + expect(executeBtn.dataset['submitting']).toBeUndefined(); + }); + + test('confirmation cancel restores normal single-purchase submission', async () => { + (confirmDialog as jest.Mock) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const rows = buildRows(); + await openPurchaseModal([rows[0]!]); + + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + executeBtn.click(); + await flush(); + + expect(api.executePurchase).not.toHaveBeenCalled(); + expect(executeBtn.dataset['submitting']).toBeUndefined(); + expect(executeBtn.disabled).toBe(false); + + executeBtn.click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + }); +}); + +// #1904: fan-out modal skips incompatible buckets + +describe('Issue #1904: fan-out modal skips incompatible buckets', () => { + function buildFanOutRows(): LocalRecommendation[] { + return [ + { + id: 'ec2-1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.large', term: 1, payment: 'no-upfront', + count: 1, upfront_cost: 0, monthly_cost: 100, savings: 50, + }, + { + id: 'rds-3', provider: 'aws', cloud_account_id: 'a1', service: 'rds', + region: 'us-east-1', resource_type: 'db.r5.large', term: 3, payment: undefined, + count: 1, upfront_cost: 1000, savings: 200, + }, + ]; + } + + test('T8 skipped bucket is not submitted and not totalled', async () => { + const [ec2Rec, rdsRec] = buildFanOutRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, recommendations: [ec2Rec, rdsRec], regions: [], + }); + (state.getRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['ec2-1', 'rds-3'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const errorSections = document.querySelectorAll('.fanout-bucket-error'); + expect(errorSections).toHaveLength(1); + expect(errorSections[0]!.textContent).toContain('will be skipped'); + + const summaryText = document.getElementById('fanout-summary')!.textContent ?? ''; + expect(summaryText).toContain('Will send 1 approval email'); + expect(summaryText).toContain('1 incompatible bucket will be skipped'); + + const totalUpfrontLine = Array.from(document.querySelectorAll('#fanout-summary p')) + .find((p) => p.textContent?.startsWith('Total upfront'))!; + expect(totalUpfrontLine.querySelector('strong')!.textContent).toBe(formatCurrency(0)); + const totalCommitmentsLine = Array.from(document.querySelectorAll('#fanout-summary p')) + .find((p) => p.textContent?.startsWith('Total commitments'))!; + expect(totalCommitmentsLine.querySelector('strong')!.textContent).toBe('1'); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(api.executePurchase).toHaveBeenCalledTimes(1); + const body = (api.executePurchase as jest.Mock).mock.calls[0]![0] as Array>; + for (const rec of body) { + expect(rec['service']).toBe('ec2'); + expect(rec['id']).not.toBe('rds-3'); + } + }); + + test('T9 repairing the bucket un-skips it everywhere', async () => { + const [ec2Rec, rdsRec] = buildFanOutRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, recommendations: [ec2Rec, rdsRec], regions: [], + }); + (state.getRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([ec2Rec, rdsRec]); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['ec2-1', 'rds-3'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const rdsSection = Array.from(document.querySelectorAll('.fanout-bucket')) + .find((s) => s.querySelector('.fanout-bucket-error') != null)!; + const rdsPaymentSelect = rdsSection.querySelector('.fanout-bucket-payment')!; + rdsPaymentSelect.value = 'partial-upfront'; + rdsPaymentSelect.dispatchEvent(new Event('change')); + + expect(rdsSection.querySelector('.fanout-bucket-ok')).not.toBeNull(); + const summaryText = document.getElementById('fanout-summary')!.textContent ?? ''; + expect(summaryText).toContain('Will send 2 approval emails'); + expect(summaryText).not.toContain('skipped'); + const totalUpfrontLine = Array.from(document.querySelectorAll('#fanout-summary p')) + .find((p) => p.textContent?.startsWith('Total upfront'))!; + expect(totalUpfrontLine.querySelector('strong')!.textContent).toBe(formatCurrency(1000)); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(api.executePurchase).toHaveBeenCalledTimes(2); + }); + + test('T10 nothing submittable disables Execute', async () => { + const [, rdsRec] = buildFanOutRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'no-upfront' } }); + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, recommendations: [rdsRec], regions: [], + }); + (state.getRecommendations as jest.Mock).mockReturnValue([rdsRec]); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([rdsRec]); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['rds-3'])); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + expect(executeBtn.disabled).toBe(true); + expect(getFanOutBuckets()).toEqual([]); + expect(document.getElementById('fanout-summary')!.textContent).toContain('Will send 0 approval emails'); + + executeBtn.click(); + await flush(); + + expect(api.executePurchase).not.toHaveBeenCalled(); + }); + // Regression for the double-scale CodeRabbit found on #2071. loadedCellVariants + // pushes `rec` itself when the loaded list no longer holds its id, and rec is + // already scaled, so re-scaling halved count and cost a second time. Uses a + // count of 4 deliberately: at count 2 the second scale floors to zero units + // and pricedCellVariant returns null, so the row is left alone and the test + // would pass with or without the guard. + test('T11 the fallback row is not re-scaled when the loaded list is replaced during open', async () => { + const rec: LocalRecommendation = { + id: 'x-1-all', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', + region: 'us-east-1', resource_type: 'm5.xlarge', count: 4, term: 1, + payment: 'all-upfront', upfront_cost: 24000, monthly_cost: 0, savings: 1400, + }; + (localStorage.getItem as jest.Mock).mockReturnValue(JSON.stringify({ capacity: 50 })); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: [rec], regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue([rec]); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue([rec]); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['x-1-all'])); + // A reload landing during openPurchaseModal's override fetch replaces the + // loaded list; the override matches the rec's own payment, so the seed + // path resolves to the fallback push, which is `rec` itself. + (api.listAccountServiceOverrides as jest.Mock).mockImplementation(async () => { + (state.getRecommendations as jest.Mock).mockReturnValue([]); + return [{ id: 'ovr-1', account_id: 'a1', provider: 'aws', service: 'ec2', payment: 'all-upfront' }]; + }); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(getPurchaseModalRecommendations()[0]).toMatchObject({ + id: 'x-1-all', count: 2, recommended_count: 4, upfront_cost: 12000, + }); + + (document.getElementById('execute-purchase-btn') as HTMLButtonElement).click(); + await flush(); + + expect(api.executePurchase).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ + id: 'x-1-all', count: 2, recommended_count: 4, upfront_cost: 12000, + })]), + 50, + undefined, + ); + }); + + test('busy fan-out stays disabled while its requests are pending', async () => { + const requests = [ + deferred>>(), + deferred>>(), + ]; + (api.executePurchase as jest.Mock) + .mockReturnValueOnce(requests[0]!.promise) + .mockReturnValueOnce(requests[1]!.promise); + const rows = buildFanOutRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'partial-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(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + executeBtn.click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(2); + + const paymentSelect = document.querySelector('.fanout-bucket-payment')!; + paymentSelect.value = 'partial-upfront'; + paymentSelect.dispatchEvent(new Event('change')); + + expect(executeBtn.disabled).toBe(true); + executeBtn.click(); + await flush(); + expect(api.executePurchase).toHaveBeenCalledTimes(2); + + for (const [i, request] of requests.entries()) { + request.resolve({ + execution_id: `exec-fanout-${i}`, + status: 'pending', + email_sent: true, + approval_recipient: 'approver@example.com', + }); + } + await flush(); + expect(executeBtn.dataset['submitting']).toBeUndefined(); + }); + + test('fan-out clears submitting state when result processing throws', async () => { + const rows = buildFanOutRows(); + (api.getConfig as jest.Mock).mockResolvedValue({ global: { default_payment: 'partial-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'])); + (api.executePurchase as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + execution_id: 'exec-valid', + status: 'pending', + email_sent: true, + }); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await flush(); + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement; + await expect(handleExecutePurchase()).rejects.toThrow(TypeError); + + expect(api.executePurchase).toHaveBeenCalledTimes(2); + expect(executeBtn.dataset['submitting']).toBeUndefined(); + expect(executeBtn.disabled).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 7f1215014..148b88e63 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -1710,6 +1710,7 @@ describe('Recommendations Module', () => { region: 'us-east-1', count: opts.count ?? 1, term: opts.term ?? 1, + payment: 'partial-upfront', savings: opts.savings ?? 100, upfront_cost: opts.upfront_cost ?? 600, monthly_cost: opts.monthly_cost !== undefined ? opts.monthly_cost : 400, @@ -2042,9 +2043,9 @@ describe('Recommendations Module', () => { }); }); -// --------------------------------------------------------------------------- +// // Bundle A: numeric expression parser + applyColumnFilters -// --------------------------------------------------------------------------- +// import { parseNumericFilter, applyColumnFilters } from '../recommendations'; import type { LocalRecommendation } from '../types'; @@ -2252,11 +2253,11 @@ describe('applyColumnFilters', () => { }); }); -// --------------------------------------------------------------------------- +// // Bundle A: state-accessor tests for the new column-filter / visible-recs API. // These import the REAL state module (the recommendations.test.ts above mocks // it; here we exercise the actual implementation in a separate require scope). -// --------------------------------------------------------------------------- +// describe('state.ts column-filter accessors', () => { // The top-level jest.mock('../state', …) replaces the module for every @@ -2320,12 +2321,12 @@ describe('state.ts column-filter accessors', () => { }); }); -// --------------------------------------------------------------------------- +// // Bundle B: column-filter popover + sticky bottom action box DOM behaviour. // These tests assert the surfaces Bundle B introduced — header filter // triggers, the detached popover lifecycle, and the bottom action box's // label/disabled-state transitions. -// --------------------------------------------------------------------------- +// describe('Bundle B: column header filter triggers', () => { const sampleRecs = [ @@ -3669,6 +3670,12 @@ describe('Issue #111 (iii): per-row Payment seed in openPurchaseModal', () => { document.body.appendChild(purchaseModal); jest.clearAllMocks(); (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + // Issue #1903: pricedCellVariant/cellTermOptions/cellPaymentOptions read + // state.getRecommendations() for the loaded sibling (term, payment) rows. + // Reset to empty here so per-test mockReturnValue overrides below don't + // leak into sibling describe blocks (clearAllMocks does not reset return + // values — see the note above at the #288 execute-mode-toggle suite). + (state.getRecommendations as jest.Mock).mockReturnValue([]); }); test('(a) single rec with matching override → row Payment seeded from override; source-note rendered', async () => { @@ -3684,12 +3691,20 @@ describe('Issue #111 (iii): per-row Payment seed in openPurchaseModal', () => { service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 5, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500, }; + // Issue #1903: the override is only honoured when a priced variant for + // it was actually loaded. + (state.getRecommendations as jest.Mock).mockReturnValue([ + rec, + { ...rec, id: 'rec-1-partial', payment: 'partial-upfront', upfront_cost: 250, monthly_cost: 20 }, + ]); await openPurchaseModal([rec]); const live = getPurchaseModalRecommendations(); expect(live).toHaveLength(1); expect(live[0]!.payment).toBe('partial-upfront'); + expect(live[0]!.upfront_cost).toBe(250); + expect(live[0]!.id).toBe('rec-1-partial'); const select = document.querySelector('.purchase-row-payment'); expect(select).not.toBeNull(); @@ -3760,6 +3775,13 @@ describe('Issue #111 (iii): per-row Payment seed in openPurchaseModal', () => { service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 5, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500, }; + // Issue #1903: the 3yr row Term now offers must be loaded (and thus + // priced) — mock the two 3yr variants this cell would have fanned out. + (state.getRecommendations as jest.Mock).mockReturnValue([ + rec, + { ...rec, id: 'rec-4-3-all', term: 3, upfront_cost: 1400 }, + { ...rec, id: 'rec-4-3-partial', term: 3, payment: 'partial-upfront', upfront_cost: 700, monthly_cost: 30 }, + ]); await openPurchaseModal([rec]); @@ -3784,6 +3806,10 @@ describe('Issue #111 (iii): per-row Payment seed in openPurchaseModal', () => { expect(paymentSelect!.value).toBe(live[0]!.payment); const options = Array.from(paymentSelect!.options).map((o) => o.value); expect(options.length).toBeGreaterThan(0); + // Issue #1903: options are restricted to the priced (loaded) set, and + // the swapped-in row carries the priced variant's own price/id. + expect(options).toEqual(['all-upfront', 'partial-upfront']); + expect(live[0]!.upfront_cost).toBe(1400); }); test('(e) user changes Payment dropdown → live state reflects new value (and would round-trip via handleExecutePurchase)', async () => { @@ -3792,6 +3818,11 @@ describe('Issue #111 (iii): per-row Payment seed in openPurchaseModal', () => { service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 5, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500, }; + // Issue #1903: the no-upfront option must be a loaded (priced) variant. + (state.getRecommendations as jest.Mock).mockReturnValue([ + rec, + { ...rec, id: 'rec-5-no', payment: 'no-upfront', upfront_cost: 0, monthly_cost: 60 }, + ]); await openPurchaseModal([rec]); @@ -3804,6 +3835,8 @@ describe('Issue #111 (iii): per-row Payment seed in openPurchaseModal', () => { const live = getPurchaseModalRecommendations(); expect(live[0]!.payment).toBe('no-upfront'); + expect(live[0]!.upfront_cost).toBe(0); + expect(live[0]!.monthly_cost).toBe(60); // The mapping in app.ts::handleExecutePurchase reads this value // verbatim (`payment: r.payment ?? 'all-upfront'`), so a downstream @@ -3820,6 +3853,12 @@ describe('Issue #111 (iii): per-row Payment seed in openPurchaseModal', () => { service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 2, term: 1, payment: 'all-upfront', savings: 50, upfront_cost: 200, }; + // Issue #1903: a 3yr option is only offered when a 3yr variant was + // actually loaded for this cell. + (state.getRecommendations as jest.Mock).mockReturnValue([ + rec, + { ...rec, id: 'rec-6-3yr', term: 3 }, + ]); await openPurchaseModal([rec]); @@ -3874,8 +3913,8 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { test('compute + sagemaker SPs at term=1 share a single bucket (happy path)', async () => { const recs = [ - { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, - { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-sagemaker', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 200, upfront_cost: 800 }, + { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 500 }, + { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-sagemaker', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 200, upfront_cost: 800 }, ]; (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); (state.getRecommendations as jest.Mock).mockReturnValue(recs); @@ -3888,6 +3927,7 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { const { getFanOutBuckets, getPurchaseModalRecommendations } = await import('../recommendations'); // 1 collapsed bucket → openPurchaseModal happy path, no fan-out. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(getFanOutBuckets()).toBeNull(); // The single-bucket modal carries BOTH SPs (proves they collapsed). const modalRecs = getPurchaseModalRecommendations(); @@ -4124,6 +4164,7 @@ describe('Issue #658: Azure SP bulk-buy bucketing', () => { const { getFanOutBuckets, getPurchaseModalRecommendations } = await import('../recommendations'); // Single bucket -> happy path (no fan-out modal). + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(getFanOutBuckets()).toBeNull(); const modalRecs = getPurchaseModalRecommendations(); expect(modalRecs).toHaveLength(1); @@ -4330,11 +4371,11 @@ describe('Issue #224: one-variant-per-cell radio selection', () => { }); }); -// --------------------------------------------------------------------------- +// // issue #223: default-seed from GlobalConfig across all 3 surfaces. // These tests exercise the pickBestVariantPerCell config-match tiebreaker // and the seedGlobalDefaults hook that injects resolved GlobalConfig values. -// --------------------------------------------------------------------------- +// describe('issue #223: pickBestVariantPerCell config-match tiebreaker', () => { const rec = ( @@ -4414,10 +4455,10 @@ describe('issue #223: pickBestVariantPerCell config-match tiebreaker', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #220 / #221: effectiveMonthlySavings + effectiveSavingsPct helpers // + Monthly Cost and Effective % column rendering -// --------------------------------------------------------------------------- +// describe('effectiveMonthlySavings', () => { const mk = (overrides: Partial): LocalRecommendation => ({ @@ -5076,9 +5117,9 @@ describe('Monthly Cost + Effective % column rendering', () => { }); }); -// --------------------------------------------------------------------------- +// // Issues #225 + #226: cell grouping with savings range and collapse/expand -// --------------------------------------------------------------------------- +// /** Helper to build a minimal LocalRecommendation fixture. */ const mkRec = (overrides: Partial = {}): LocalRecommendation => ({ @@ -5415,9 +5456,9 @@ describe('Issues #225 + #226: cell grouping with savings range and collapse/expa }); }); -// --------------------------------------------------------------------------- +// // Issue #319: cost-period selector tests -// --------------------------------------------------------------------------- +// const DOM_FOR_319 = ( '
' @@ -6113,9 +6154,9 @@ describe('Column visibility (issue #318)', () => { }); }); -// --------------------------------------------------------------------------- +// // formatCapacity (closes #219) -// --------------------------------------------------------------------------- +// describe('formatCapacity', () => { test('returns formatted string when both vcpu and memory_gb are populated', () => { expect(formatCapacity(8, 32)).toBe('8 vCPU / 32 GB'); @@ -6150,7 +6191,7 @@ describe('formatCapacity', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #494: deterministic group sort on multi-variant cells. // // After PR #195's per-(term, payment) fan-out, every cell has BOTH 1yr and 3yr @@ -6162,7 +6203,7 @@ describe('formatCapacity', () => { // // PR #491 (closes #480) fixed the default-direction inversion; this PR fixes // the upstream "every cell ties" symptom. -// --------------------------------------------------------------------------- +// describe('Issue #494: deterministic group sort on multi-variant cells', () => { /** Build the minimum DOM loadRecommendations needs, using createElement so * no innerHTML assignment is required (the rest of the file uses innerHTML; @@ -6287,9 +6328,9 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { (state.getCostPeriod as jest.Mock).mockReturnValue('monthly'); } - // ------------------------------------------------------------------------- + // // 4.9 - Term - // ------------------------------------------------------------------------- + // test('Term asc: orders cells by summary.termMin (1yr-grouped before 3yr-grouped)', async () => { const cellMixed = multiVariantCell({ resourceType: 'aaa-mixed', payment1y: 'no-upfront', payment3y: 'no-upfront', @@ -6362,9 +6403,9 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { .toBeLessThan(indexOrFail(order, 'bbb-1y-only')); }); - // ------------------------------------------------------------------------- + // // 4.10 - Payment - // ------------------------------------------------------------------------- + // test('Payment asc: orders cells by canonical PAYMENT_ORDER (no-upfront < partial-upfront < all-upfront)', async () => { // Each cell has term=1 + term=3 variants with the *same* payment so the // canonical first-variant payment per cell is unambiguous. @@ -6395,9 +6436,9 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { .toBeLessThan(indexOrFail(order, 'allup-cell')); }); - // ------------------------------------------------------------------------- + // // 4.12 - Upfront Cost - // ------------------------------------------------------------------------- + // test('Upfront Cost asc: orders cells by summary.upfrontMin', async () => { const cellLow = multiVariantCell({ resourceType: 'low-upfront', payment1y: 'no-upfront', payment3y: 'partial-upfront', @@ -6424,9 +6465,9 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { .toBeLessThan(indexOrFail(order, 'high-upfront')); }); - // ------------------------------------------------------------------------- + // // 4.13 - Monthly Cost - // ------------------------------------------------------------------------- + // test('Monthly Cost asc: orders cells by Math.min over non-null variants', async () => { const cellLow = multiVariantCell({ // min(30, 20) = 20 resourceType: 'low-monthly', payment1y: 'no-upfront', payment3y: 'no-upfront', @@ -6514,9 +6555,9 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { expect(second).toEqual(first); }); - // ------------------------------------------------------------------------- + // // 4.15 - Effective % - // ------------------------------------------------------------------------- + // test('Effective % asc: orders cells by Math.max over non-null variants (lowest best-pct first)', async () => { // effectiveSavingsPct uses on_demand_cost when set. Pick on-demand values // so each cell's pct is predictable. Formula: @@ -6581,7 +6622,7 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { .toBeLessThan(indexOrFail(order, 'allnull-pct')); }); - // ------------------------------------------------------------------------- + // // Determinism: repeating the same sort yields the same order. // // The pre-#494 bug also surfaced as "two clicks of the same header may @@ -6590,14 +6631,14 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { // and varies across JS engines. With the new comparator every cell has a // distinct score (or, if genuinely tied, a stable cellKey tiebreaker), so // repeated invocations MUST produce the same order. - // ------------------------------------------------------------------------- + // // Selection-independent Term sort: cells with different term distributions // must sort correctly by cellSummary score (termMin*100+termMax) regardless // of which variants the user has selected. Fix for Issue #768: the previous // "selected-variant short-circuit" in cellScoreFor() switched the score to // the selected variant's individual term value, which caused rows to reorder // on every checkbox toggle. - // ------------------------------------------------------------------------- + // test('Term sort is selection-independent: cells rank by term distribution not by selected variant', async () => { // cell-1y-only: both variants are term=1 (termMin=1, termMax=1, score=101) const cell1yOnly: LocalRecommendation[] = [ @@ -6642,13 +6683,13 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { .toBeLessThan(indexOrFail(order, '3y-only')); }); - // ------------------------------------------------------------------------- + // // Issue #768: toggling checkboxes must not change row sort order. // // Before the fix, cellScoreFor() switched to the selected variant's // individual value when selectedRecs contained a variant id, causing // groupsInSortOrder() to produce a different order after a checkbox toggle. - // ------------------------------------------------------------------------- + // test('Issue #768: row order is identical before and after toggling checkboxes', async () => { // Three cells with distinct savings so they sort in a predictable order. // Cell A: savings = 10 (lowest) → should be last under desc @@ -6721,7 +6762,7 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { } }); - // ------------------------------------------------------------------------- + // // QA 4.13 - Monthly Cost: zero-cost (all-upfront) variants must not prevent // sort-direction toggle from reordering rows. // @@ -6730,7 +6771,7 @@ describe('Issue #494: deterministic group sort on multi-variant cells', () => { // multiplier had nothing to act on and subsequent sort clicks were no-ops. // The fix uses the minimum NON-ZERO recurring cost, falling back to 0 only // when all finite values are 0 (pure all-upfront cell). - // ------------------------------------------------------------------------- + // test('Monthly Cost: mixed cell (all-upfront + no-upfront) sorts by non-zero recurring cost, not 0', async () => { // cellLow: all-upfront (monthly=0) + no-upfront (monthly=20) -> score = 20 const cellLow = multiVariantCell({ @@ -6811,12 +6852,12 @@ function setupOpportunitiesTabDom(): void { document.body.appendChild(purchaseModal); } -// --------------------------------------------------------------------------- +// // Issue #479: Select-all checkbox tri-state. // The header checkbox renders with the right .checked / .indeterminate state // reflecting current selection vs. the set of best-variant-per-cell recs // (the set the select-all click actually populates). -// --------------------------------------------------------------------------- +// describe('Issue #479: Select-all header checkbox tri-state', () => { const recs = [ { id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, @@ -6878,11 +6919,11 @@ describe('Issue #479: Select-all header checkbox tri-state', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #480: First-click sort direction per column. // Text columns and most numerics default to 'asc' (A→Z / low → high). // `savings` and `on_demand_monthly` keep 'desc' as the platform default. -// --------------------------------------------------------------------------- +// describe('Issue #480: per-column default sort direction', () => { const recs = [ { id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'no-upfront', savings: 100, upfront_cost: 0, monthly_cost: 50, on_demand_cost: 80 }, @@ -6944,10 +6985,10 @@ describe('Issue #480: per-column default sort direction', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #481: Sort column + direction persisted across page refresh via // URL query params (?sort=&dir=). -// --------------------------------------------------------------------------- +// describe('Issue #481: URL persistence of sort state', () => { const recs = [ { id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, @@ -7008,9 +7049,9 @@ describe('Issue #481: URL persistence of sort state', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #482: "All" checkbox tri-state + null-filter renders as all-checked. -// --------------------------------------------------------------------------- +// describe('Issue #482: column filter "All" tri-state semantics', () => { const recs = [ { id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, @@ -7175,9 +7216,9 @@ describe('Issue #482: column filter "All" tri-state semantics', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #483: Scrolling inside the popover does NOT dismiss it. -// --------------------------------------------------------------------------- +// describe('Issue #483: popover stays open while user scrolls its contents', () => { const recs = [ { id: 'r1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 100, upfront_cost: 500 }, @@ -7224,9 +7265,9 @@ describe('Issue #483: popover stays open while user scrolls its contents', () => }); }); -// --------------------------------------------------------------------------- +// // Issue #484: Numeric filter exact-match against the displayed rounded value. -// --------------------------------------------------------------------------- +// describe('Issue #484: numeric filter matches the displayed rounded value', () => { // Choose a savings value whose raw form rounds to a different display // value depending on which precision we use. Under hourly period, the @@ -7475,9 +7516,9 @@ describe('isHomogeneousSelection (#769)', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #239: renderUsageSparkline unit tests -// --------------------------------------------------------------------------- +// describe('renderUsageSparkline (issue #239)', () => { test('returns em-dash for null', () => { expect(renderUsageSparkline(null)).toBe('—'); @@ -7827,9 +7868,9 @@ describe('Column filters localStorage persistence (issue #163)', () => { }); }); -// --------------------------------------------------------------------------- +// // Issue #135: SP plan-type row grouping in the Recommendations table -// --------------------------------------------------------------------------- +// const mkSpRec = (service: string, overrides: Partial = {}): LocalRecommendation => ({ id: 'sp-' + service + '-' + Math.random().toString(36).slice(2), diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 73bd73f17..cd1c74a0c 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -309,12 +309,38 @@ function setupButtonHandlers(): void { } +function setExecutePurchaseSubmitting( + executeBtn: HTMLButtonElement | null, + submitting: boolean, + label: string, +): void { + if (!executeBtn) return; + + executeBtn.textContent = label; + if (submitting) { + executeBtn.dataset['submitting'] = 'true'; + executeBtn.disabled = true; + executeBtn.title = 'Purchase submission in progress'; + return; + } + + delete executeBtn.dataset['submitting']; + const fanOutBuckets = getFanOutBuckets(); + const unavailable = fanOutBuckets !== null + ? fanOutBuckets.length === 0 + : getPurchaseModalRecommendations().length === 0; + executeBtn.disabled = unavailable; + executeBtn.title = unavailable + ? fanOutBuckets !== null ? 'No compatible buckets to submit' : 'Select at least one purchase' + : ''; +} + /** * Handle execute purchase button click. Routes to the single-bucket * path when getPurchaseModalRecommendations has content, or to the * multi-bucket fan-out path when the fan-out modal set buckets. */ -async function handleExecutePurchase(): Promise { +export async function handleExecutePurchase(): Promise { const fanOutBuckets = getFanOutBuckets(); if (fanOutBuckets && fanOutBuckets.length > 0) { await handleFanOutExecute(fanOutBuckets); @@ -338,10 +364,7 @@ async function handleExecutePurchase(): Promise { // mint a duplicate pending execution (#644). The button is re-enabled on // cancel below and in the finally block once the request settles. const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; - if (executeBtn) { - executeBtn.disabled = true; - executeBtn.textContent = 'Sending...'; - } + setExecutePurchaseSubmitting(executeBtn, true, 'Sending...'); const defaultBtnLabel = isDirect ? 'Execute Purchase Now' : 'Send for Approval'; @@ -364,10 +387,7 @@ async function handleExecutePurchase(): Promise { }); if (!ok) { - if (executeBtn) { - executeBtn.disabled = false; - executeBtn.textContent = defaultBtnLabel; - } + setExecutePurchaseSubmitting(executeBtn, false, defaultBtnLabel); return; } @@ -460,10 +480,7 @@ async function handleExecutePurchase(): Promise { const verb = isDirect ? 'execute' : 'send for approval'; showToast({ message: `Failed to ${verb} purchase: ${err.message}`, kind: 'error' }); } finally { - if (executeBtn) { - executeBtn.disabled = false; - executeBtn.textContent = defaultBtnLabel; - } + setExecutePurchaseSubmitting(executeBtn, false, defaultBtnLabel); } } @@ -495,10 +512,7 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { // double-click can't fan out a second wave of duplicate executions (#644). // Re-enabled on cancel below and after the calls settle at the end. const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; - if (executeBtn) { - executeBtn.disabled = true; - executeBtn.textContent = `Sending 0/${buckets.length}…`; - } + setExecutePurchaseSubmitting(executeBtn, true, `Sending 0/${buckets.length}…`); // Same approval-required default as the single-purchase path: each // bucket POSTs a request that triggers an approval email; the actual @@ -510,13 +524,18 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { destructive: false, }); if (!ok) { - if (executeBtn) { - executeBtn.disabled = false; - executeBtn.textContent = 'Send for Approval'; - } + setExecutePurchaseSubmitting(executeBtn, false, 'Send for Approval'); return; } + try { + await submitFanOutBuckets(buckets); + } finally { + setExecutePurchaseSubmitting(executeBtn, false, 'Send for Approval'); + } +} + +async function submitFanOutBuckets(buckets: FanOutBucket[]): Promise { // Fire all POSTs in parallel via allSettled so one failure doesn't // cascade. Each bucket's recs are already scaled by its capacity %; // the POST body records capacity_percent for audit. Spread the full @@ -646,11 +665,6 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { } await loadDashboard(); - - if (executeBtn) { - executeBtn.disabled = false; - executeBtn.textContent = 'Send for Approval'; - } } /** diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index d0d7d3322..3ab55842f 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -39,6 +39,11 @@ export type { ParsedNumericFilter } from './lib/column-filters'; // Module state for current purchase modal recommendations let currentPurchaseRecommendations: LocalRecommendation[] = []; +// Capacity % the modal's recommendations were scaled at (issue #1903). Used +// by pricedCellVariant to re-scale a sibling variant swapped in on a +// Term/Payment change so the row keeps the same capacity as the rest of the +// modal. Reset to 100 on modal close. +let currentPurchaseCapacityPercent = 100; // Tracks which row indices in currentPurchaseRecommendations the user // has kept included (checked). Initialised to all indices on modal open; // toggled by per-row checkboxes and the select-all header checkbox. @@ -229,6 +234,7 @@ export function clearPurchaseModalRecommendations(): void { currentPurchaseRecommendations = []; checkedPurchaseIndices = new Set(); checkedPurchaseModalInitialised = false; + currentPurchaseCapacityPercent = 100; } /** @@ -934,6 +940,82 @@ export function groupRecsByCell(recs: readonly LocalRecommendation[]): Map { + const value = (left as Record)[field]; + return (value === undefined || typeof value === 'string') && value === (right as Record)[field]; + }); +} + +// Every loaded (term, payment) row of rec's purchase identity, plus rec itself when the +// loaded set lacks it (modal opened on a stale or test-supplied list). +// Reads state.getRecommendations() (not getVisibleRecommendations) so a +// column filter that hides a sibling term/payment row can never make the +// purchase modal think a priced variant doesn't exist (issue #1903). +function loadedCellVariants(rec: LocalRecommendation): LocalRecommendation[] { + const key = cellKey(rec); + const variants = (state.getRecommendations() as unknown as LocalRecommendation[]) + .filter((v) => cellKey(v) === key && samePurchaseVariantIdentity(rec, v)); + if (!variants.some((v) => v.id === rec.id)) variants.push(rec); + return sortVariantsInCell(variants); +} + +// The scaled variant the modal would submit for (term, payment), or null when +// no such row was loaded or it scales to zero units at the modal's capacity. +function pricedCellVariant(rec: LocalRecommendation, term: 1 | 3, payment: BulkPurchasePayment): LocalRecommendation | null { + const v = loadedCellVariants(rec).find((c) => c.term === term && normalizeBulkPayment(c.payment) === payment); + if (!v) return null; + // `rec` reached the modal already scaled to currentPurchaseCapacityPercent: + // openPurchaseModal's only caller passes handleBulkPurchaseClick's scaled + // rows. Only the rows read from state.getRecommendations() are unscaled, so + // re-scaling the fallback push would halve count and cost a second time. + if (v === rec) return v; + return scaleRecForCapacity(v, currentPurchaseCapacityPercent); +} + +// Distinct terms actually loaded for rec's cell, ascending. Used to build +// the purchase modal's Term with the -// supported Payment options for a (provider, service, term) cell. -// If `desired` is in the new option set, it stays selected; otherwise -// the first option wins and the select's `.value` reflects that. +// rebuildPaymentOptions clears and re-populates a