Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 14 additions & 8 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,21 @@ export async function loadDashboard(): Promise<void> {
// 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;
Expand Down
Loading