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
17 changes: 16 additions & 1 deletion frontend/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
getDeploymentInfo
} from '../api';
import { base64Encode } from '../api/client';
import type { CreatePlanRequest, Config, Recommendation } from '../api';
import type { CreatePlanRequest, Config, Recommendation, UpcomingPurchasesResponse } from '../api';

describe('Authentication', () => {
beforeEach(() => {
Expand Down Expand Up @@ -740,6 +740,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]);
});
});
});

Expand Down
16 changes: 16 additions & 0 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,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();
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/api/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<UpcomingPurchase[]> {
return apiRequest<UpcomingPurchase[]>('/dashboard/upcoming');
export async function getUpcomingPurchases(): Promise<UpcomingPurchasesResponse> {
return apiRequest<UpcomingPurchasesResponse>('/dashboard/upcoming');
}
1 change: 1 addition & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type {
LoginResponse,
DashboardSummary,
UpcomingPurchase,
UpcomingPurchasesResponse,
Recommendation,
RecommendationFilters,
Plan,
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export async function loadDashboard(): Promise<void> {
]);

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.
Expand Down Expand Up @@ -427,7 +427,8 @@ async function retryUpcomingPurchases(): Promise<void> {
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);
Expand Down
23 changes: 4 additions & 19 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading