From cdc5bf356560c049f9b51e92f9c4fe1f95979edb Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 22:44:25 +0000 Subject: [PATCH] fix(frontend): type getUpcomingPurchases as the { purchases } envelope getUpcomingPurchases() declared Promise, but GET /api/dashboard/upcoming returns { purchases: [...] }, so both dashboard call sites cast the result to an ad-hoc object type and tsc never checked them. The function now returns UpcomingPurchasesResponse, whose purchases field is UpcomingPurchase[] | null because the Go handler serializes an empty result from a nil slice as null. The casts are gone. The dashboard's hand-kept UpcomingPurchase copy in types.ts (provider narrowed to Provider) is now an alias of the API type, since that narrowing was what forced a cast; the widget already treats provider as an arbitrary string. Reverting the return type to an array now fails typecheck at both call sites and in the new api test. Refs #1189 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/api.test.ts | 17 ++++++++++++++++- frontend/src/__tests__/dashboard.test.ts | 16 ++++++++++++++++ frontend/src/api/dashboard.ts | 9 +++++---- frontend/src/api/index.ts | 1 + frontend/src/api/types.ts | 7 +++++++ frontend/src/dashboard.ts | 5 +++-- frontend/src/types.ts | 23 ++++------------------- 7 files changed, 52 insertions(+), 26 deletions(-) diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index af7174aca..fc1e98cf4 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -36,7 +36,7 @@ import { getPublicInfo, getDeploymentInfo } from '../api'; -import type { CreatePlanRequest, Config, Recommendation } from '../api'; +import type { CreatePlanRequest, Config, Recommendation, UpcomingPurchasesResponse } from '../api'; describe('Authentication', () => { beforeEach(() => { @@ -698,6 +698,21 @@ describe('Dashboard API', () => { expect.anything() ); }); + + // #1189: the endpoint returns { purchases: [...] }, not a bare array. The + // annotation makes tsc (npm run typecheck) reject a regression to the + // old UpcomingPurchase[] return type. + test('resolves the { purchases } envelope the handler returns', async () => { + const row = { + execution_id: 'exec-1', plan_id: 'plan-1', plan_name: 'Plan', scheduled_date: '2026-11-01', + provider: 'aws', service: 'ec2', step_number: 1, total_steps: 2, estimated_savings: 10, + has_recommendations: true, + }; + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve({ purchases: [row] }) }); + const res: UpcomingPurchasesResponse = await getUpcomingPurchases(); + expect(Array.isArray(res)).toBe(false); + expect(res.purchases).toEqual([row]); + }); }); }); diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 9a296bec6..95d194e8a 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -352,6 +352,22 @@ describe('Dashboard Module', () => { expect(upcomingList?.innerHTML).toContain('No upcoming scheduled purchases'); }); + // #1189: the Go handler serializes an empty result as `purchases: null` + // (nil slice), and apiRequest resolves null for an empty 2xx body (#304). + test.each([ + ['purchases: null', { purchases: null }], + ['a null response', null], + ])('shows the empty message for %s', async (_label, value) => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ potential_monthly_savings: 1000, by_service: {} }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue(value); + + await loadDashboard(); + + const upcomingList = document.getElementById('upcoming-list'); + expect(upcomingList?.textContent).toContain('No upcoming scheduled purchases'); + expect(upcomingList?.querySelector('.error')).toBeNull(); + }); + test('shows error on API failure', async () => { (api.getDashboardSummary as jest.Mock).mockRejectedValue(new Error('API Error')); console.error = jest.fn(); diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index c11523841..968069fee 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -3,7 +3,7 @@ */ import { apiRequest } from './client'; -import type { DashboardSummary, UpcomingPurchase, Provider } from './types'; +import type { DashboardSummary, UpcomingPurchasesResponse, Provider } from './types'; /** * Get dashboard summary @@ -15,8 +15,9 @@ export async function getDashboardSummary(provider: Provider | '' = '', accountI } /** - * Get upcoming purchases + * Get upcoming purchases. The endpoint returns `{ purchases: [...] }`, not a + * bare array (#1189). */ -export async function getUpcomingPurchases(): Promise { - return apiRequest('/dashboard/upcoming'); +export async function getUpcomingPurchases(): Promise { + return apiRequest('/dashboard/upcoming'); } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 562b6f5ef..32bede27f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -14,6 +14,7 @@ export type { LoginResponse, DashboardSummary, UpcomingPurchase, + UpcomingPurchasesResponse, Recommendation, RecommendationFilters, Plan, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f3ad8bc6f..ddbdf9d7e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -120,6 +120,13 @@ export interface UpcomingPurchase { created_by_user_id?: string; } +// GET /api/dashboard/upcoming wraps the rows in an object (#1189). The Go +// handler builds the slice with `var upcoming []UpcomingPurchase`, so an +// empty result serializes as `purchases: null`, not `[]`. +export interface UpcomingPurchasesResponse { + purchases: UpcomingPurchase[] | null; +} + // Recommendation types export interface Recommendation { id: string; diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 2a8e61716..2ba2cc5ef 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -215,7 +215,7 @@ export async function loadDashboard(): Promise { ]); const summaryData = summaryResult.status === 'fulfilled' ? (summaryResult.value as DashboardSummary) : null; - const upcomingData = upcomingResult.status === 'fulfilled' ? (upcomingResult.value as { purchases?: UpcomingPurchase[] }) : null; + const upcomingData = upcomingResult.status === 'fulfilled' ? upcomingResult.value : null; // 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. @@ -498,7 +498,8 @@ async function retryUpcomingPurchases(): Promise { showSkeletonBlock(container, '100%', '6rem'); } try { - const data = (await api.getUpcomingPurchases()) as { purchases?: UpcomingPurchase[] } | null; + const data = await api.getUpcomingPurchases(); + // `?.`: apiRequest resolves null for an empty 2xx body (#304). renderUpcomingPurchases(data?.purchases || []); } catch (error) { console.error('Failed to load upcoming purchases:', error); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cdb35630c..c4647b89d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -37,25 +37,10 @@ export interface ServiceSavings { current_savings: number; } -export interface UpcomingPurchase { - execution_id: string; - plan_id: string; - plan_name: string; - scheduled_date: string; - provider: api.Provider; - service: string; - step_number: number; - total_steps: number; - estimated_savings: number; - // false when the step carries no recommendations; estimated_savings is - // then a placeholder, not data (platform#609). Only an explicit false is bare. - has_recommendations?: boolean; - // created_by_user_id mirrors the field on api.UpcomingPurchase so the - // dashboard widget can apply the issue-#950 creator-scope ownership - // gate on the Cancel button. Optional: legacy / scheduler-tick rows - // ship NULL here. - created_by_user_id?: string; -} +// The dashboard renders the API rows as-is, so this is the API type rather +// than a hand-kept copy that drifted from it and forced casts at the call +// sites (#1189). Field notes live on api.UpcomingPurchase. +export type UpcomingPurchase = api.UpcomingPurchase; // Recommendations types export interface RecommendationsResponse {