From f938b0d9e04a61511eed1a50f46ebd846f54aeb3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 14:03:43 +0200 Subject: [PATCH] fix(home/kpi): Potential Monthly Savings sums recs same as Opportunities Home showed $0 while Opportunities showed $212-$314 for the same recommendation dataset. Root cause: the backend always returns the envelope { recommendations: [...], summary: {...}, regions: [...] } but loadDashboard() only checked Array.isArray(rawRecs), which is false for an object, so recs was silently coerced to [] and the pageLevelRange computation produced $0. The Opportunities page worked because it cast the result to RecommendationsResponse and read .recommendations directly. The Home KPI now unwraps .recommendations from the envelope (with a flat-array fallback so test fixtures still work), matching the Opportunities aggregator. Closes #749. --- frontend/src/__tests__/dashboard.test.ts | 37 ++++++++++++++++++++++++ frontend/src/dashboard.ts | 22 +++++++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index bc75f4ec9..04d8134ce 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -662,6 +662,43 @@ describe('Dashboard Module', () => { expect(savingsCard?.innerHTML).toContain('$0'); }); + // #749: the real backend always returns the envelope shape + // { recommendations: [...], summary: {...}, regions: [...] }, not a flat + // array. The dashboard must unwrap .recommendations so the savings range + // is computed from the actual recs rather than falling back to $0. + test('#749: getRecommendations returning envelope shape populates savings card', async () => { + const mockRecs = [ + { id: 'r1', provider: 'aws', service: 'ec2', region: 'us-east-1', resource_type: 't3.medium', term: 1, savings: 150, upfront_cost: 0, count: 1 }, + { id: 'r2', provider: 'aws', service: 'rds', region: 'us-east-1', resource_type: 'db.t3.medium', term: 1, savings: 62, upfront_cost: 0, count: 1 }, + ]; + // Simulate the real API response shape (envelope, not flat array). + (api.getRecommendations as jest.Mock).mockResolvedValue({ + recommendations: mockRecs, + summary: { total_count: 2, total_monthly_savings: 212, total_upfront_cost: 0, avg_payback_months: 0 }, + regions: ['us-east-1'], + } as unknown); + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ + potential_monthly_savings: 0, // would be $0 if the flat-sum path were used + total_recommendations: 2, + active_commitments: 0, + committed_monthly: 0, + current_coverage: 0, + target_coverage: 80, + ytd_savings: 0, + by_service: {} + }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadDashboard(); + + const savingsCard = document.querySelector('#summary .card'); + // mockGroupRecsByCell / mockPageLevelRange return savingsMin=300, + // savingsMax=400 for any non-empty recs array. The card must NOT be $0. + expect(savingsCard?.textContent).toContain('$300'); + expect(savingsCard?.textContent).toContain('$400'); + expect(savingsCard?.innerHTML).not.toContain('$0'); + }); + // #304: summaryData.by_service missing entirely (null/undefined from // backend). renderSavingsChart receives `undefined || {}` = {} which // is safe; verify no throw and the error banner does not appear. diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 102d866cc..ab09b9a8e 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -83,15 +83,21 @@ export async function loadDashboard(): Promise { // api.Recommendation and LocalRecommendation are structurally identical // except for provider: string vs provider: Provider. The provider values // from the API are always the union members at runtime, so this cast is safe. - // Defensive Array.isArray guard: apiRequest's catch block returns `null` when - // response.json() fails (HTTP 2xx with empty/non-JSON body), so the settled - // value may be null or a non-array shape even when status === 'fulfilled'. - // #304: that non-array value reaches groupRecsByCell which iterates via - // `for...of`, throwing "X is not iterable" and blanking the dashboard. + // Defensive extraction: the backend always returns the envelope shape + // { recommendations: [...], summary: {...}, regions: [...] } + // so the real runtime value is never a flat array. We unwrap it here to + // match what the Opportunities page does (cast to RecommendationsResponse + // and read .recommendations). A flat-array result is also accepted so + // test fixtures that resolve with a plain array continue to work. + // #304: apiRequest's catch block returns `null` on a 2xx with empty/non-JSON + // body; guard against null / unexpected shapes to avoid "X is not iterable". const rawRecs = recsResult.status === 'fulfilled' ? recsResult.value : null; - const recs: readonly LocalRecommendation[] = Array.isArray(rawRecs) - ? (rawRecs as unknown as LocalRecommendation[]) - : []; + const recsArray = Array.isArray(rawRecs) + ? rawRecs + : (rawRecs != null && typeof rawRecs === 'object' && Array.isArray((rawRecs as { recommendations?: unknown }).recommendations)) + ? (rawRecs as { recommendations: unknown[] }).recommendations + : []; + const recs: readonly LocalRecommendation[] = recsArray as unknown as LocalRecommendation[]; if (summaryResult.status === 'rejected') { throw summaryResult.reason as Error;