From 3e57db55d250e3612b7fa386c0ed4440149dca14 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 8 Sep 2026 04:14:09 +0200 Subject: [PATCH 1/8] fix(frontend): re-price purchase modal rows from the loaded variant on Term/Payment change The purchase modal's Term and Payment so it never offers a term the API +// didn't price (issue #1903). +function cellTermOptions(rec: LocalRecommendation): Array<1 | 3> { + const terms = new Set<1 | 3>(); + for (const v of loadedCellVariants(rec)) { + if (v.term === 1 || v.term === 3) terms.add(v.term); + } + return Array.from(terms).sort((a, b) => a - b); +} + +// Payment options for rec's cell at `term`, restricted to combinations that +// were actually loaded (and thus priced) — the intersection of the compat +// table's order with the loaded set (issue #1903). +function cellPaymentOptions(rec: LocalRecommendation, term: 1 | 3): BulkPurchasePayment[] { + const loaded = new Set( + loadedCellVariants(rec) + .filter((v) => v.term === term) + .map((v) => normalizeBulkPayment(v.payment)) + .filter((p): p is BulkPurchasePayment => p !== null), + ); + return paymentOptionsFor(rec.provider as CompatProvider, rec.service, term).filter((p) => + loaded.has(p as BulkPurchasePayment), + ) as BulkPurchasePayment[]; +} + // issue #135: SP plan-type row grouping helpers. /** @@ -3906,6 +3958,28 @@ async function openCreatePlanFromBottomBox(snapshot: LocalRecommendation[]): Pro openCreatePlanModal(snapshot as unknown as readonly api.Recommendation[]); } +// Scale one rec's count-dependent fields (count, upfront_cost, monthly_cost, +// savings) to a capacity %, flooring the unit count. Returns null when the +// scaled count floors to 0 — the row contributes nothing at this capacity. +// Extracted from handleBulkPurchaseClick's scaling loop (issue #1903) so +// pricedCellVariant can apply the same scaling to a sibling variant swapped +// in on a Term/Payment change. +function scaleRecForCapacity(r: LocalRecommendation, capacityPercent: number): LocalRecommendation | null { + const newCount = Math.floor((r.count * capacityPercent) / 100); + if (newCount <= 0) return null; + const ratio = r.count > 0 ? newCount / r.count : 1; + return { + ...r, + count: newCount, + // Carry the pre-scaling count so the backend can verify the + // capacity_percent it records against the scaled count (#647). + recommended_count: r.count, + upfront_cost: r.upfront_cost * ratio, + monthly_cost: r.monthly_cost != null ? r.monthly_cost * ratio : null, + savings: r.savings * ratio, + }; +} + function handleBulkPurchaseClick(recommendations: LocalRecommendation[]): void { const tb = loadBulkPurchaseState(); if (recommendations.length === 0) { @@ -3916,19 +3990,8 @@ function handleBulkPurchaseClick(recommendations: LocalRecommendation[]): void { // Scale by capacity %; drop rows whose scaled count floors to 0. const scaled: LocalRecommendation[] = []; for (const r of recommendations) { - const newCount = Math.floor((r.count * tb.capacity) / 100); - if (newCount <= 0) continue; - const ratio = r.count > 0 ? newCount / r.count : 1; - scaled.push({ - ...r, - count: newCount, - // Carry the pre-scaling count so the backend can verify the - // capacity_percent it records against the scaled count (#647). - recommended_count: r.count, - upfront_cost: r.upfront_cost * ratio, - monthly_cost: r.monthly_cost != null ? r.monthly_cost * ratio : null, - savings: r.savings * ratio, - }); + const s = scaleRecForCapacity(r, tb.capacity); + if (s) scaled.push(s); } if (scaled.length === 0) { showToast({ @@ -3992,7 +4055,7 @@ function handleBulkPurchaseClick(recommendations: LocalRecommendation[]): void { // (wired in app.ts) picks up the recs via getPurchaseModalRecommendations. // openPurchaseModal is async (issue #111 (iii): per-rec override // prefetch); fire-and-forget — the modal is the user's surface. - void openPurchaseModal(scaled); + void openPurchaseModal(scaled, tb.capacity); } // FanOutBucket groups one batch of recs under a single (provider, @@ -4849,7 +4912,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { function resolvePerRecPaymentSeed( rec: LocalRecommendation, overridesByAccount: Map, -): { payment: CompatPayment; source: 'override' | 'rec' | 'fallback' } { +): { payment: CompatPayment; source: 'override' | 'rec' | 'fallback'; variant?: LocalRecommendation } { const provider = rec.provider as CompatProvider; const term = rec.term as 1 | 3; @@ -4859,12 +4922,13 @@ function resolvePerRecPaymentSeed( const match = overrides.find( (o) => o.provider === provider && o.service === rec.service, ); - if ( - match - && match.payment - && isPaymentSupported(provider, rec.service, term, match.payment as CompatPayment) - ) { - return { payment: match.payment as CompatPayment, source: 'override' }; + // Issue #1903: an override is only honoured when a priced variant for + // it was actually loaded — otherwise the override would relabel this + // row's payment without the matching price. + const overridePayment = normalizeBulkPayment(match?.payment); + if (overridePayment && isPaymentSupported(provider, rec.service, term, overridePayment)) { + const variant = pricedCellVariant(rec, term, overridePayment); + if (variant) return { payment: overridePayment, source: 'override', variant }; } } } @@ -4885,6 +4949,33 @@ function resolvePerRecPaymentSeed( return { payment: preferred, source: 'fallback' }; } +// renderDirectExecuteWarning rebuilds the "this will charge $X upfront +// immediately" callout from the currently checked rows. It is a money total +// in the same modal as the per-row cost cells (issue #1903), so it must +// follow both a checkbox toggle (updatePurchaseModalTotals, pre-existing +// gap) and a Term/Payment re-price, not only the radio's own change event. +// No-ops when the callout isn't in the DOM (no direct-execute permission). +function renderDirectExecuteWarning(): void { + const directWarning = document.querySelector('#purchase-details .direct-execute-warning'); + if (!directWarning) return; + directWarning.hidden = currentExecuteMode !== 'direct'; + if (currentExecuteMode !== 'direct') return; + let totalUpfront = 0; + for (const idx of checkedPurchaseIndices) { + const r = currentPurchaseRecommendations[idx]; + if (r) totalUpfront += r.upfront_cost; + } + while (directWarning.firstChild) directWarning.removeChild(directWarning.firstChild); + const icon = document.createElement('strong'); + icon.textContent = 'Warning: '; + directWarning.appendChild(icon); + const text = document.createTextNode( + `This will charge $${totalUpfront.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} upfront immediately. ` + + 'This bypasses the approval step. AWS allows cancellation within 24 hours via the Account & Billing console.', + ); + directWarning.appendChild(text); +} + /** * Open the single-bucket purchase modal with editable per-row Term and * Payment dropdowns (issue #111 sub-option (iii)). @@ -4917,7 +5008,8 @@ function resolvePerRecPaymentSeed( * `openFanOutModal`. Errors swallowed: the rec-payment fallback always * works, so a transient API blip shouldn't block the modal. */ -export async function openPurchaseModal(recommendations: LocalRecommendation[]): Promise { +export async function openPurchaseModal(recommendations: LocalRecommendation[], capacityPercent = 100): Promise { + currentPurchaseCapacityPercent = capacityPercent; currentPurchaseRecommendations = [...recommendations]; // Initialise all indices as checked (issue #320: all selected by default). checkedPurchaseIndices = new Set(currentPurchaseRecommendations.map((_, i) => i)); @@ -4943,7 +5035,10 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[]): // the time the modal opens. const seeds = currentPurchaseRecommendations.map((r) => resolvePerRecPaymentSeed(r, overridesByAccount)); for (let i = 0; i < currentPurchaseRecommendations.length; i++) { - currentPurchaseRecommendations[i]!.payment = seeds[i]!.payment; + const seed = seeds[i]!; + currentPurchaseRecommendations[i] = seed.variant + ? { ...seed.variant, payment: seed.payment } + : { ...currentPurchaseRecommendations[i]!, payment: seed.payment }; } while (container.firstChild) container.removeChild(container.firstChild); @@ -5012,24 +5107,7 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[]): // Wire radio changes to update state + show/hide warning. const updateExecuteMode = (): void => { currentExecuteMode = directRadio.checked ? 'direct' : ''; - directWarning.hidden = currentExecuteMode !== 'direct'; - if (currentExecuteMode === 'direct') { - // Compute total upfront from currently checked rows for the warning. - let totalUpfront = 0; - for (const idx of checkedPurchaseIndices) { - const r = currentPurchaseRecommendations[idx]; - if (r) totalUpfront += r.upfront_cost; - } - while (directWarning.firstChild) directWarning.removeChild(directWarning.firstChild); - const icon = document.createElement('strong'); - icon.textContent = 'Warning: '; - directWarning.appendChild(icon); - const text = document.createTextNode( - `This will charge $${totalUpfront.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} upfront immediately. ` + - 'This bypasses the approval step. AWS allows cancellation within 24 hours via the Account & Billing console.', - ); - directWarning.appendChild(text); - } + renderDirectExecuteWarning(); // Update the submit button label to reflect the selected mode. const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; if (executeBtn) { @@ -5278,6 +5356,11 @@ function updatePurchaseModalTotals(selectAllCb: HTMLInputElement): void { selectAllCb.checked = false; selectAllCb.indeterminate = true; } + + // Issue #1903: the direct-execute warning's dollar total is derived from + // checkedPurchaseIndices too, so it must refresh on every checkbox toggle, + // not only when the radio itself changes. + renderDirectExecuteWarning(); } // renderPurchaseModalRow builds one editable for the per-row @@ -5364,13 +5447,12 @@ function renderPurchaseModalRow(idx: number, paymentSource: 'override' | 'rec' | effPctTd.appendChild(document.createTextNode(pct !== null ? pct.toFixed(1) + '%' : '—')); tr.appendChild(effPctTd); - // Term select (col 9). AWS/Azure/GCP commitments universally support 1y and 3y; - // on change we rederive Payment options for the new term and pick a - // still-valid value if the current one becomes unsupported. + // Term select (col 9). Options are the terms actually loaded for this + // cell (issue #1903) — an unpriced term is never offered. const termCell = document.createElement('td'); const termSelect = document.createElement('select'); termSelect.className = 'purchase-row-term'; - for (const t of [1, 3]) { + for (const t of cellTermOptions(rec)) { const opt = document.createElement('option'); opt.value = String(t); opt.textContent = formatTerm(t); @@ -5380,13 +5462,13 @@ function renderPurchaseModalRow(idx: number, paymentSource: 'override' | 'rec' | termCell.appendChild(termSelect); tr.appendChild(termCell); - // Payment select (col 10). Options come from paymentOptionsFor (already - // filtered to supported values for this provider/service/term cell), - // so the user can never pick an unsupported combo through the UI. + // Payment select (col 10). Options are restricted to the (term, payment) + // combinations actually loaded for this cell (issue #1903), so the user + // can never pick a combo the API never priced. const paymentCell = document.createElement('td'); const paymentSelect = document.createElement('select'); paymentSelect.className = 'purchase-row-payment'; - rebuildPaymentOptions(paymentSelect, rec.provider as CompatProvider, rec.service, rec.term as 1 | 3, (rec.payment ?? '') as CompatPayment); + rebuildPaymentOptions(paymentSelect, cellPaymentOptions(rec, rec.term as 1 | 3), (rec.payment ?? '') as BulkPurchasePayment | ''); paymentCell.appendChild(paymentSelect); if (paymentSource === 'override') { const sourceNote = document.createElement('span'); @@ -5409,46 +5491,64 @@ function renderPurchaseModalRow(idx: number, paymentSource: 'override' | 'rec' | if (selectAllCb) updatePurchaseModalTotals(selectAllCb); }); - termSelect.addEventListener('change', () => { + // Issue #1903: swap in the loaded sibling variant for the selected + // (term, payment) and re-render the whole row so the cost cells, the + // Include checkbox, and both selects stay derived from one source. Term + // and Payment changes both funnel through this so neither can leave the + // row's price stale. + const applyVariantChange = (focusSelector: string): void => { + const term = parseInt(termSelect.value, 10) === 3 ? 3 : 1; + const payment = paymentSelect.value as BulkPurchasePayment; const live = currentPurchaseRecommendations[idx]; if (!live) return; - const newTerm = parseInt(termSelect.value, 10) === 3 ? 3 : 1; - live.term = newTerm; - // Rebuild this row's payment options for the new term; if current - // payment is no longer supported, pick the first valid option and - // mirror back to live state. - rebuildPaymentOptions( - paymentSelect, - live.provider as CompatProvider, - live.service, - newTerm, - (live.payment ?? '') as CompatPayment, - ); - live.payment = paymentSelect.value; - }); + const variant = pricedCellVariant(live, term, payment); + if (!variant) { + // Reachable: a sibling variant with a smaller count can floor to 0 at + // the modal's capacity. Keep the priced rec and put the selects back. + showToast({ + message: `No priced ${formatTerm(term)} / ${payment} option for this row at ${currentPurchaseCapacityPercent}% capacity.`, + kind: 'warning', + }); + termSelect.value = String(live.term); + rebuildPaymentOptions(paymentSelect, cellPaymentOptions(live, live.term as 1 | 3), (live.payment ?? '') as BulkPurchasePayment | ''); + return; + } + currentPurchaseRecommendations[idx] = { ...variant, payment }; + const fresh = renderPurchaseModalRow(idx, paymentSource); + tr.replaceWith(fresh); + fresh.querySelector(focusSelector)?.focus(); + const selectAllCb = document.getElementById('purchase-modal-select-all') as HTMLInputElement | null; + if (selectAllCb) updatePurchaseModalTotals(selectAllCb); + }; - paymentSelect.addEventListener('change', () => { + termSelect.addEventListener('change', () => { const live = currentPurchaseRecommendations[idx]; if (!live) return; - live.payment = paymentSelect.value; + const newTerm = parseInt(termSelect.value, 10) === 3 ? 3 : 1; + // Rebuild this row's payment options for the new term before applying — + // applyVariantChange reads paymentSelect.value, so it must already + // reflect the new term's loaded options. + rebuildPaymentOptions(paymentSelect, cellPaymentOptions(live, newTerm), (live.payment ?? '') as BulkPurchasePayment | ''); + applyVariantChange('.purchase-row-term'); }); + paymentSelect.addEventListener('change', () => applyVariantChange('.purchase-row-payment')); + return tr; } -// rebuildPaymentOptions clears and re-populates a with the given +// Payment options. If `desired` is in the new option set, it stays +// selected; otherwise the first option wins and the select's `.value` +// reflects that. Issue #1903: options are passed in by the caller +// (cellPaymentOptions — loaded variants only) rather than derived here from +// the full compat table, so an unpriced combination can never be offered. function rebuildPaymentOptions( select: HTMLSelectElement, - provider: CompatProvider, - service: string, - term: 1 | 3, - desired: CompatPayment | '', + options: readonly BulkPurchasePayment[], + desired: BulkPurchasePayment | '', ): void { while (select.firstChild) select.removeChild(select.firstChild); - const options = paymentOptionsFor(provider, service, term); let matched = false; for (const opt of options) { const o = document.createElement('option'); From efd28f4a8c0f45de0e6c8a44e6de77e05cbf7591 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 8 Sep 2026 04:16:49 +0200 Subject: [PATCH 2/8] fix(frontend): skip incompatible fan-out buckets on submit and in the header totals The fan-out modal rendered "Invalid combo ... This bucket will be skipped" for a bucket whose seeded payment was unsupported for its term, but getFanOutBuckets() returned every bucket regardless, so the "skipped" bucket was posted anyway and triggered its own approval email. The header's email count and totals also summed every bucket, not just the ones the UI promised to submit. getFanOutBuckets() now filters to buckets passing the same isBucketPaymentCompatible predicate the renderer uses, so a bucket flagged as skipped can never reach app.ts's executePurchase call. The header summary (title, email count, skipped-bucket note, and totals) is rebuilt from that same submittable subset and refreshes when a bucket's Payment dropdown changes, so repairing a skipped bucket immediately un-skips it everywhere. The Execute button is disabled when nothing is submittable. purchase-modal-submit.test.ts (added in the previous commit) gains the #1904 coverage: a skipped bucket is excluded from both the submitted POSTs and the header totals, repairing it un-skips it, and an all-skipped selection disables Execute. Verified failing on the pre-fix code (extra POST, inflated totals) and passing after. Closes #1904 Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC --- .../__tests__/purchase-modal-submit.test.ts | 119 ++++++++++++++++++ frontend/src/recommendations.ts | 61 +++++++-- 2 files changed, 173 insertions(+), 7 deletions(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index f621ec4c7..d2894292c 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -152,6 +152,7 @@ import { openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, + getFanOutBuckets, clearFanOutBuckets, loadRecommendations, } from '../recommendations'; @@ -438,3 +439,121 @@ describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { expect(document.querySelector('.direct-execute-warning')?.textContent).toContain('12,000.00'); }); }); + +// ── #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(); + }); +}); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 2ca54217f..a33f7255c 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4111,9 +4111,16 @@ export interface FanOutBucket { // the modal closes. let currentFanOutBuckets: FanOutBucket[] | null = null; +// The renderer promises "This bucket will be skipped" from exactly this +// check (renderFanOutBucketSection); submit and totals must agree with it +// (issue #1904) so a bucket the UI marks skipped is never posted. +function isSubmittableBucket(b: FanOutBucket): boolean { + return isBucketPaymentCompatible(b.recs, b.payment); +} + export function getFanOutBuckets(): FanOutBucket[] | null { if (!currentFanOutBuckets) return null; - return currentFanOutBuckets.map((b) => ({ + return currentFanOutBuckets.filter(isSubmittableBucket).map((b) => ({ ...b, // Deep-copy the per-rec map so callers can't mutate module state. perRecPayments: b.perRecPayments ? new Map(b.perRecPayments) : undefined, @@ -4321,17 +4328,43 @@ async function openFanOutModal( while (container.firstChild) container.removeChild(container.firstChild); const summary = document.createElement('div'); + summary.id = 'fanout-summary'; summary.className = 'form-section fanout-summary'; + renderFanOutSummary(summary, buckets); + container.appendChild(summary); + + for (const b of buckets) { + container.appendChild(renderFanOutBucketSection(b)); + } + + openModal(modal); +} + +// renderFanOutSummary rebuilds the fan-out modal's header — title, email +// count, skipped-bucket note, and totals — from the submittable subset +// (issue #1904), so what the user sees here matches exactly what +// getFanOutBuckets() returns to app.ts on submit. Also disables the +// Execute button when nothing is submittable. +function renderFanOutSummary(summary: HTMLElement, buckets: FanOutBucket[]): void { + while (summary.firstChild) summary.removeChild(summary.firstChild); + + const submittable = buckets.filter(isSubmittableBucket); + const skipped = buckets.length - submittable.length; + const summaryTitle = document.createElement('h3'); summaryTitle.textContent = `Bulk purchase — ${buckets.length} bucket${buckets.length === 1 ? '' : 's'}`; summary.appendChild(summaryTitle); const emailNote = document.createElement('p'); emailNote.className = 'fanout-email-note'; - emailNote.textContent = `Will send ${buckets.length} approval email${buckets.length === 1 ? '' : 's'} — one per bucket.`; + let emailText = `Will send ${submittable.length} approval email${submittable.length === 1 ? '' : 's'} — one per bucket.`; + if (skipped > 0) { + emailText += ` ${skipped} incompatible bucket${skipped === 1 ? '' : 's'} will be skipped.`; + } + emailNote.textContent = emailText; summary.appendChild(emailNote); - const totals = computeFanOutTotals(buckets); + const totals = computeFanOutTotals(submittable); const totalLine = (label: string, value: string, cls = ''): HTMLParagraphElement => { const p = document.createElement('p'); const strong = document.createElement('strong'); @@ -4346,13 +4379,23 @@ async function openFanOutModal( summary.appendChild(totalLine('Total commitments', String(totals.totalCount))); summary.appendChild(totalLine('Total upfront', formatCurrency(totals.totalUpfront))); summary.appendChild(totalLine(`Total savings ${periodSuffix(fanOutPeriod)}`, formatCostForPeriod(totals.totalSavings, fanOutPeriod), 'savings')); - container.appendChild(summary); - for (const b of buckets) { - container.appendChild(renderFanOutBucketSection(b)); + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; + if (executeBtn) { + executeBtn.disabled = submittable.length === 0; + executeBtn.title = submittable.length === 0 ? 'No compatible buckets to submit' : ''; } +} - openModal(modal); +// refreshFanOutSummary re-renders #fanout-summary from currentFanOutBuckets. +// Called after a bucket's Payment change so a user who repairs a skipped +// bucket sees the email count, skipped note, and totals follow immediately +// (issue #1904) rather than only on the next modal open. +function refreshFanOutSummary(): void { + if (!currentFanOutBuckets) return; + const summary = document.getElementById('fanout-summary'); + if (!summary) return; + renderFanOutSummary(summary, currentFanOutBuckets); } function computeFanOutTotals(buckets: FanOutBucket[]): { totalCount: number; totalUpfront: number; totalSavings: number } { @@ -4437,6 +4480,10 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { } b.payment = next; renderStatus(); + // Issue #1904: a payment fix here can move this bucket in or out of the + // submittable set, so the header's email count, skipped note, totals, + // and Execute-enabled state must follow immediately. + refreshFanOutSummary(); // Re-sync any visible per-rec selects whose ids are NOT explicit // overrides: those rows follow the bucket default, so their displayed // value must track the new bucket payment. Rows with an explicit From 59ff4d4c5f0e9255699daa8bd703082470e9491f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 8 Sep 2026 04:31:52 +0200 Subject: [PATCH 3/8] refactor(frontend): derive the fan-out skip label from the submit predicate The "this bucket will be skipped" label called isBucketPaymentCompatible directly while the submit filter and the header totals went through isSubmittableBucket. Both wrapped the same check, so they agreed, but only by coincidence: a future change to one predicate would silently reopen the divergence this PR closes. Adversarial review found this by mutation. Narrowing isSubmittableBucket to inspect only the first recommendation left every test passing, because no supported bucket today mixes services in a way that would disagree. Routing the label through the same helper makes the property structural instead. No behaviour change: isSubmittableBucket is a one-line wrapper around the call it replaces. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC --- frontend/src/recommendations.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index a33f7255c..4263ce079 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4431,11 +4431,9 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { const status = document.createElement('p'); const renderStatus = (): void => { - // For mixed-SP buckets check compatibility per rec — every rec must - // be supported. For non-SP buckets every rec shares b.service so a - // single check is equivalent. The shared helper keeps this in sync - // with the same check at handleBulkPurchaseClick. - const compat = isBucketPaymentCompatible(b.recs, b.payment); + // Same predicate the submit filter and the header totals use, so the + // "will be skipped" label cannot disagree with what actually submits. + const compat = isSubmittableBucket(b); status.className = compat ? 'fanout-bucket-ok' : 'fanout-bucket-error'; status.textContent = compat ? `${b.capacityPercent}% capacity · ${b.term}yr · ${b.payment}` From ba33a1eca506b5cdf75b565cd0d7dfa7707a404a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 8 Sep 2026 05:04:36 +0200 Subject: [PATCH 4/8] fix(frontend): stop double-scaling the fallback variant to capacity loadedCellVariants pushes the recommendation itself when the loaded list no longer holds its id, and rows reach the modal already scaled to the toolbar capacity: openPurchaseModal's only caller passes handleBulkPurchaseClick's scaled rows. pricedCellVariant then applied scaleRecForCapacity a second time, halving count and cost again and overwriting recommended_count with the once-scaled count. The window is narrow but real. A topbar filter reload, a lookback collect or the stale-on-open auto-refresh can replace the loaded list during openPurchaseModal's override fetch. When an account override names the same payment the row already carries, the seed lookup resolves to that fallback push and re-scales it. At 50% capacity a 4-unit row is submitted as 2 units at half the price. With a smaller count the second scale floors to zero units instead, so the override is silently dropped or a valid swap is refused with a "no priced option" toast. The backend does not catch it. validateCapacityConsistency asserts recommended_count * percent / 100 == count, and the second scale overwrites recommended_count with the already-scaled count, so the row is internally consistent and is purchased for fewer units than the user chose. Found by CodeRabbit on the pull request and confirmed by tracing every caller of openPurchaseModal, then reproduced. The regression test uses a count of 4 on purpose. At count 2 the second scale floors to zero, pricedCellVariant returns null and the row is left untouched, so the test would pass with or without the guard. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC --- .../__tests__/purchase-modal-submit.test.ts | 44 +++++++++++++++++++ frontend/src/recommendations.ts | 8 +++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index d2894292c..030b94341 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -556,4 +556,48 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { 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, + ); + }); }); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 4263ce079..dc2f9c66f 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -957,7 +957,13 @@ function loadedCellVariants(rec: LocalRecommendation): LocalRecommendation[] { // 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); - return v ? scaleRecForCapacity(v, currentPurchaseCapacityPercent) : null; + 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 From 8c00eafc10bbe0fb88fc89f192376ba94720513a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 10 Sep 2026 00:56:12 +0200 Subject: [PATCH 5/8] fix(frontend): keep purchase submission locked through modal edits Preserve the in-flight state when purchase rows or fan-out payment options rerender, and clear it on cancellation or request cleanup. Recompute availability from the current selection after submission. Cover duplicate submissions and cleanup after result-processing errors through the real modal handler. Preserve cents in the direct warning using the existing currency formatter. --- .../purchase-execution-toast.test.ts | 2 +- .../__tests__/purchase-modal-submit.test.ts | 143 +++++++++++++++++- frontend/src/app.ts | 66 ++++---- frontend/src/recommendations.ts | 16 +- 4 files changed, 194 insertions(+), 33 deletions(-) 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 index 030b94341..1754865f5 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -144,10 +144,11 @@ jest.mock('../toast', () => ({ // ── imports ─────────────────────────────────────────────────────────────────── -import { setupEventListeners } from '../app'; +import { handleExecutePurchase, setupEventListeners } from '../app'; import * as api from '../api'; import * as state from '../state'; import { showToast } from '../toast'; +import { confirmDialog } from '../confirmDialog'; import { openPurchaseModal, getPurchaseModalRecommendations, @@ -196,6 +197,17 @@ 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(() => { @@ -438,6 +450,61 @@ describe('Issue #1903: purchase modal re-prices on Term/Payment 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 ────────────────────────── @@ -600,4 +667,78 @@ describe('Issue #1904: fan-out modal skips incompatible buckets', () => { 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/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 dc2f9c66f..815bbdb8d 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -4388,8 +4388,11 @@ function renderFanOutSummary(summary: HTMLElement, buckets: FanOutBucket[]): voi const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; if (executeBtn) { - executeBtn.disabled = submittable.length === 0; - executeBtn.title = submittable.length === 0 ? 'No compatible buckets to submit' : ''; + const submitting = executeBtn.dataset['submitting'] === 'true'; + executeBtn.disabled = submitting || submittable.length === 0; + executeBtn.title = submitting + ? 'Purchase submission in progress' + : submittable.length === 0 ? 'No compatible buckets to submit' : ''; } } @@ -5021,7 +5024,7 @@ function renderDirectExecuteWarning(): void { icon.textContent = 'Warning: '; directWarning.appendChild(icon); const text = document.createTextNode( - `This will charge $${totalUpfront.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} upfront immediately. ` + + `This will charge ${formatCurrency(totalUpfront, '$', 2)} upfront immediately. ` + 'This bypasses the approval step. AWS allows cancellation within 24 hours via the Account & Billing console.', ); directWarning.appendChild(text); @@ -5390,8 +5393,11 @@ function updatePurchaseModalTotals(selectAllCb: HTMLInputElement): void { const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; if (executeBtn) { const noneSelected = checkedPurchaseIndices.size === 0; - executeBtn.disabled = noneSelected; - executeBtn.title = noneSelected ? 'Select at least one purchase' : ''; + const submitting = executeBtn.dataset['submitting'] === 'true'; + executeBtn.disabled = submitting || noneSelected; + executeBtn.title = submitting + ? 'Purchase submission in progress' + : noneSelected ? 'Select at least one purchase' : ''; } // Sync select-all checkbox indeterminate/checked/unchecked state. From e88009de5196f9ec362a232eb8dd2493fb9a2c85 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 11 Sep 2026 19:11:51 +0200 Subject: [PATCH 6/8] docs(frontend): clarify purchase modal test pricing contract --- .../__tests__/purchase-modal-submit.test.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 1754865f5..784934390 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -3,15 +3,13 @@ * 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 the REAL app.ts + recommendations.ts modules end to end - * and assert on the actual request body handed to api.executePurchase — the - * body the backend prices, records, and emails from (see - * internal/api/handler_purchases.go: validateAndTotalRecommendations / - * recTotalCommitment trust the submitted amounts verbatim). Asserting on an - * intermediate helper instead of this body would not prove the fix. + * 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) ───────────────────────────────────────────── +// Mocks must precede imports. jest.mock('../api', () => ({ initAuth: jest.fn(), @@ -142,7 +140,7 @@ jest.mock('../toast', () => ({ showToast: jest.fn(), })); -// ── imports ─────────────────────────────────────────────────────────────────── +// Imports import { handleExecutePurchase, setupEventListeners } from '../app'; import * as api from '../api'; @@ -161,7 +159,7 @@ import { formatCurrency } from '../utils'; import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; import type { LocalRecommendation } from '../types'; -// ── fixtures ────────────────────────────────────────────────────────────────── +// 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 @@ -208,7 +206,7 @@ function deferred(): { return { promise, resolve }; } -// ── DOM / mock scaffolding ──────────────────────────────────────────────────── +// DOM / mock scaffolding beforeEach(() => { document.body.replaceChildren(); @@ -264,7 +262,7 @@ beforeEach(() => { (state.getRecommendations as jest.Mock).mockReturnValue(buildRows()); }); -// ── #1903: purchase modal re-prices on Term/Payment change ─────────────────── +// #1903: purchase modal re-prices on Term/Payment change describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { test('T1 term change re-prices the submitted body', async () => { @@ -507,7 +505,7 @@ describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { }); }); -// ── #1904: fan-out modal skips incompatible buckets ────────────────────────── +// #1904: fan-out modal skips incompatible buckets describe('Issue #1904: fan-out modal skips incompatible buckets', () => { function buildFanOutRows(): LocalRecommendation[] { From 39003684be16542ec606b0d83b0a6e13364176e4 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 11 Sep 2026 20:14:56 +0200 Subject: [PATCH 7/8] fix(purchases): preserve modal purchase identity --- .../__tests__/purchase-modal-submit.test.ts | 97 +++++++++++++++++++ frontend/src/recommendations.ts | 63 +++++++++--- 2 files changed, 144 insertions(+), 16 deletions(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index 784934390..ad7d9dea8 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -265,6 +265,103 @@ beforeEach(() => { // #1903: purchase modal re-prices on Term/Payment change describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { + 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')!; diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 815bbdb8d..7923e9eaa 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -940,7 +940,38 @@ 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 @@ -948,7 +979,7 @@ export function groupRecsByCell(recs: readonly LocalRecommendation[]): Map cellKey(v) === key); + .filter((v) => cellKey(v) === key && samePurchaseVariantIdentity(rec, v)); if (!variants.some((v) => v.id === rec.id)) variants.push(rec); return sortVariantsInCell(variants); } @@ -1520,9 +1551,9 @@ export function onDemandMonthly(r: LocalRecommendation): number | null { return null; } -// --------------------------------------------------------------------------- +// // Cost-period scaling (issue #319) -// --------------------------------------------------------------------------- +// /** Conversion factors relative to a monthly base. */ const PERIOD_FACTOR: Record = { @@ -1647,7 +1678,7 @@ export function pickBestVariantPerCell(recs: readonly LocalRecommendation[]): Lo return result; } -// --------------------------------------------------------------------------- +// // COLUMN_DEFS — single source of truth for the recommendations table columns. // // Order here matches the rendered column order (left to right), excluding the @@ -1663,7 +1694,7 @@ export function pickBestVariantPerCell(recs: readonly LocalRecommendation[]): Lo // cost columns (`savings`, `monthly_cost`, `on_demand_monthly`) are handled // separately by `getColumnLabel` per-period, so their entry here is only // used as the data-attribute / fallback label, not the rendered . -// --------------------------------------------------------------------------- +// export interface ColumnDef { key: state.RecommendationsColumnId; label: string; @@ -1931,7 +1962,7 @@ function roundForDisplay(n: number, precision: number): number { return Number(n.toFixed(precision)); } -// --------------------------------------------------------------------------- +// // Column-filter popover (portal pattern) // // The popover element lives appended to document.body so it survives @@ -1943,7 +1974,7 @@ function roundForDisplay(n: number, precision: number): number { // The popover STRUCTURE is built once on open; STATE (.checked / .value) is // re-synced on every anchor re-bind from the latest column-filter state, EXCEPT // when the input is document.activeElement (mid-typing protection). -// --------------------------------------------------------------------------- +// // Derived from COLUMN_DEFS — numeric columns get a text-input filter; categoricals // get a checkbox-list filter. Kept as a Set for O(1) membership tests. @@ -2563,13 +2594,13 @@ function ensureRecommendationsTabObserver(): void { recommendationsTabObserver.observe(tab, { attributes: true, attributeFilter: ['class'] }); } -// --------------------------------------------------------------------------- +// // Column-visibility popover (issue #318) // // Separate state from the column-filter popover (openPopover / outsideClickHandler // etc.) to avoid conflating the two interactions. Shares the positionPopover() // helper for positioning. -// --------------------------------------------------------------------------- +// interface VisibilityPopoverState { el: HTMLDivElement; @@ -2715,7 +2746,7 @@ function mountColumnsButton(bar: HTMLElement): void { } } -// --------------------------------------------------------------------------- +// // Render (or update) the filter-status bar: a "Clear filters (N)" button // when at least one column filter is active, plus an aria-live region @@ -3500,9 +3531,9 @@ function saveBulkPurchaseState(s: BulkPurchaseToolbarState): void { } } -// --------------------------------------------------------------------------- +// // Column visibility — localStorage persistence (issue #318) -// --------------------------------------------------------------------------- +// // TOGGLEABLE_COLUMNS — the subset of COLUMN_DEFS whose visibility can be toggled. // Provider, Account, Service, and Resource Type are "cell identity anchors" on @@ -3555,9 +3586,9 @@ export function saveColumnVisibility(hidden: ReadonlySet !hidden.has(c.key)); } -// --------------------------------------------------------------------------- +// // Mount-once-then-update lifecycle for the sticky bottom action box. // mountBottomActionBox builds the DOM (input/select/button identities) and // wires listeners exactly once. updateBottomActionBox refreshes only the From 23b6dc27422b18471057b6c12cd66507af12a852 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 14 Sep 2026 20:56:49 +0200 Subject: [PATCH 8/8] fix(purchases): resolve priced modal payment variants Require legacy payment fallbacks to select a loaded priced variant and exclude unavailable rows from the purchase modal. Keep payment options capacity-aware when changing terms so zero-unit alternatives cannot be selected. Add real DOM and POST regressions for stale async opens, legacy fallback resolution, unavailable mixed selections, identity filtering, viable capacity alternatives, repeated term swaps, and all-zero term restoration. --- .../__tests__/purchase-modal-submit.test.ts | 273 ++++++++++++++++++ .../src/__tests__/recommendations.test.ts | 109 +++---- frontend/src/recommendations.ts | 115 ++++---- 3 files changed, 379 insertions(+), 118 deletions(-) diff --git a/frontend/src/__tests__/purchase-modal-submit.test.ts b/frontend/src/__tests__/purchase-modal-submit.test.ts index ad7d9dea8..2d9a43a39 100644 --- a/frontend/src/__tests__/purchase-modal-submit.test.ts +++ b/frontend/src/__tests__/purchase-modal-submit.test.ts @@ -147,6 +147,7 @@ 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, @@ -154,6 +155,7 @@ import { getFanOutBuckets, clearFanOutBuckets, loadRecommendations, + seedGlobalDefaults, } from '../recommendations'; import { formatCurrency } from '../utils'; import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; @@ -234,11 +236,16 @@ beforeEach(() => { 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 — @@ -265,6 +272,161 @@ beforeEach(() => { // #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'], @@ -436,6 +598,117 @@ describe('Issue #1903: purchase modal re-prices on Term/Payment change', () => { 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 })); diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 3861c690c..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 = [ @@ -3912,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); @@ -3926,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(); @@ -4162,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); @@ -4368,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 = ( @@ -4452,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 => ({ @@ -5114,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 => ({ @@ -5453,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 = ( '
' @@ -6151,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'); @@ -6188,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 @@ -6200,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; @@ -6325,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', @@ -6400,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. @@ -6433,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', @@ -6462,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', @@ -6552,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: @@ -6619,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 @@ -6628,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[] = [ @@ -6680,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 @@ -6759,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. // @@ -6768,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({ @@ -6849,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 }, @@ -6916,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 }, @@ -6982,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 }, @@ -7046,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 }, @@ -7213,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 }, @@ -7262,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 @@ -7513,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('—'); @@ -7865,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/recommendations.ts b/frontend/src/recommendations.ts index 7923e9eaa..3ab55842f 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -1009,17 +1009,10 @@ function cellTermOptions(rec: LocalRecommendation): Array<1 | 3> { } // Payment options for rec's cell at `term`, restricted to combinations that -// were actually loaded (and thus priced) — the intersection of the compat -// table's order with the loaded set (issue #1903). +// were loaded and remain priced at the modal capacity (issue #1903). function cellPaymentOptions(rec: LocalRecommendation, term: 1 | 3): BulkPurchasePayment[] { - const loaded = new Set( - loadedCellVariants(rec) - .filter((v) => v.term === term) - .map((v) => normalizeBulkPayment(v.payment)) - .filter((p): p is BulkPurchasePayment => p !== null), - ); return paymentOptionsFor(rec.provider as CompatProvider, rec.service, term).filter((p) => - loaded.has(p as BulkPurchasePayment), + pricedCellVariant(rec, term, p as BulkPurchasePayment) !== null, ) as BulkPurchasePayment[]; } @@ -4972,32 +4965,12 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { }); } -// resolvePerRecPaymentSeed picks the default Payment value for one rec -// in the per-row purchase modal (issue #111 sub-option (iii)). The -// precedence: -// 1. Account override: rec carries a non-empty cloud_account_id, that -// account has an AccountServiceOverride matching -// `(rec.provider, rec.service)`, the override's `payment` is -// non-empty, AND `(provider, service, term, payment)` is supported -// by isPaymentSupported. → seed from override; the row's source- -// note span renders "(from account override)". -// 2. Rec's own payment: the API stamps payment at collection time; -// use it if non-empty AND supported for `(provider, service, term)`. -// 3. paymentOptionsFor(provider, service, term)[0]: defensive fallback -// for malformed test fixtures or pre-#111 cached responses where -// the rec lacks a payment. paymentOptionsFor returns at least one -// option for every provider/service the recommendations engine -// generates rows for. -// -// NOTE: this helper duplicates the override-fetch shape from -// resolveBucketPaymentSeed (per-bucket, used by the fan-out modal). The -// two are kept separate by deliberate scope discipline; a follow-up -// issue will consolidate them into a single -// `frontend/src/lib/overrides.ts` helper once both surfaces have shipped. +// Resolve a priced override, valid own payment, or priced legacy fallback. +// A payment label alone cannot establish the price of a legacy row. function resolvePerRecPaymentSeed( rec: LocalRecommendation, overridesByAccount: Map, -): { payment: CompatPayment; source: 'override' | 'rec' | 'fallback'; variant?: LocalRecommendation } { +): { payment: CompatPayment; source: 'override' | 'rec' | 'fallback'; variant: LocalRecommendation } | null { const provider = rec.provider as CompatProvider; const term = rec.term as 1 | 3; @@ -5018,20 +4991,22 @@ function resolvePerRecPaymentSeed( } } - if (rec.payment && isPaymentSupported(provider, rec.service, term, rec.payment as CompatPayment)) { - return { payment: rec.payment as CompatPayment, source: 'rec' }; + const ownPayment = normalizeBulkPayment(rec.payment); + if (ownPayment && isPaymentSupported(provider, rec.service, term, ownPayment)) { + return { payment: ownPayment, source: 'rec', variant: rec }; } - // Defensive fallback: rec is missing/has-unsupported payment AND no - // matching override. paymentOptionsFor always returns at least one - // option for the (provider, service, term) cells the engine emits. - // issue #223: prefer GlobalConfig.DefaultPayment over the first option - // so the fallback is consistent with the operator's configured preference. - const options = paymentOptionsFor(provider, rec.service, term); - const preferred = (options as string[]).includes(cachedGlobalDefaultPayment) - ? cachedGlobalDefaultPayment - : (options[0] ?? 'all-upfront') as CompatPayment; - return { payment: preferred, source: 'fallback' }; + const options = cellPaymentOptions(rec, term); + const preferred = normalizeBulkPayment(cachedGlobalDefaultPayment); + if (preferred && options.includes(preferred)) { + options.splice(options.indexOf(preferred), 1); + options.unshift(preferred); + } + for (const payment of options) { + const variant = pricedCellVariant(rec, term, payment); + if (variant) return { payment, source: 'fallback', variant }; + } + return null; } // renderDirectExecuteWarning rebuilds the "this will charge $X upfront @@ -5082,7 +5057,8 @@ function renderDirectExecuteWarning(): void { * canonical). * * Defaults are seeded by resolvePerRecPaymentSeed: - * override → rec's own payment → paymentOptionsFor[0] fallback. + * priced override, valid own payment, then a priced legacy fallback. + * Rows without a priced payment are explicitly excluded. * * On change, handlers mutate `currentPurchaseRecommendations[idx]` in * place so `getPurchaseModalRecommendations()` returns the user's @@ -5090,14 +5066,14 @@ function renderDirectExecuteWarning(): void { * (replacing the historical hardcoded `'all-upfront'` on that path). * * Async because it pre-fetches per-account overrides — same pattern as - * `openFanOutModal`. Errors swallowed: the rec-payment fallback always - * works, so a transient API blip shouldn't block the modal. + * `openFanOutModal`. A failed override fetch still permits valid own + * payments and loaded priced alternatives. */ export async function openPurchaseModal(recommendations: LocalRecommendation[], capacityPercent = 100): Promise { currentPurchaseCapacityPercent = capacityPercent; - currentPurchaseRecommendations = [...recommendations]; - // Initialise all indices as checked (issue #320: all selected by default). - checkedPurchaseIndices = new Set(currentPurchaseRecommendations.map((_, i) => i)); + currentPurchaseRecommendations = []; + const pendingRows = currentPurchaseRecommendations; + checkedPurchaseIndices = new Set(); checkedPurchaseModalInitialised = true; const container = document.getElementById('purchase-details'); @@ -5107,27 +5083,36 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[], // in the input set. One fetch per account, parallel via Promise.all, // cached in a per-call Map. const accountIDs = new Set(); - for (const r of currentPurchaseRecommendations) { + for (const r of recommendations) { if (r.cloud_account_id) accountIDs.add(r.cloud_account_id); } const overridesByAccount = await fetchOverridesForAccounts(accountIDs); - // Compute seed per rec and mutate currentPurchaseRecommendations in - // place so the in-flight modal state matches what the dropdowns - // render. The 'rec' source case is a no-op write (same value), but - // keeping the assignment uniform avoids "did the user edit this?" - // ambiguity downstream — every rec carries an explicit payment by - // the time the modal opens. - const seeds = currentPurchaseRecommendations.map((r) => resolvePerRecPaymentSeed(r, overridesByAccount)); - for (let i = 0; i < currentPurchaseRecommendations.length; i++) { - const seed = seeds[i]!; - currentPurchaseRecommendations[i] = seed.variant - ? { ...seed.variant, payment: seed.payment } - : { ...currentPurchaseRecommendations[i]!, payment: seed.payment }; - } + if (currentPurchaseRecommendations !== pendingRows) return; + + const seeds = recommendations.map((r) => resolvePerRecPaymentSeed(r, overridesByAccount)); + const resolvedSeeds = seeds.filter((seed) => seed !== null); + currentPurchaseRecommendations = resolvedSeeds.map((seed) => ({ ...seed.variant, payment: seed.payment })); + checkedPurchaseIndices = new Set(currentPurchaseRecommendations.map((_, i) => i)); while (container.firstChild) container.removeChild(container.firstChild); + const unavailable = recommendations.filter((_, i) => seeds[i] === null); + if (unavailable.length > 0) { + const notice = document.createElement('div'); + notice.className = 'purchase-modal-unavailable'; + notice.setAttribute('role', 'alert'); + notice.textContent = 'These recommendations are unavailable and excluded: no priced payment is available for their term and selected capacity.'; + const list = document.createElement('ul'); + for (const rec of unavailable) { + const item = document.createElement('li'); + item.textContent = [rec.cloud_account_id, rec.provider, rec.service, rec.resource_type, rec.region, `${rec.term}-year`].filter(Boolean).join(' / '); + list.appendChild(item); + } + notice.appendChild(list); + container.appendChild(notice); + } + // Reset the execute mode for this modal session so a prior direct-execute // choice does not carry over to a freshly opened modal (issue #289). currentExecuteMode = ''; @@ -5249,7 +5234,7 @@ export async function openPurchaseModal(recommendations: LocalRecommendation[], const tbody = document.createElement('tbody'); for (let i = 0; i < currentPurchaseRecommendations.length; i++) { - tbody.appendChild(renderPurchaseModalRow(i, seeds[i]!.source)); + tbody.appendChild(renderPurchaseModalRow(i, resolvedSeeds[i]!.source)); } table.appendChild(tbody);