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
133 changes: 132 additions & 1 deletion frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Recommendations module tests
*/
import { loadRecommendations, openPurchaseModal, refreshRecommendations, setupRecommendationsHandlers } from '../recommendations';
import { loadRecommendations, openPurchaseModal, refreshRecommendations, setupRecommendationsHandlers, clearRecommendationDetailCache } from '../recommendations';

// Mock the api module
jest.mock('../api', () => ({
Expand All @@ -10,6 +10,21 @@ jest.mock('../api', () => ({
listAccounts: jest.fn().mockResolvedValue([])
}));

// Mock the per-id detail endpoint module so the drawer-fetch tests can
// assert on call shape without going through the apiRequest layer.
// Default resolution returns a benign empty payload so tests that
// merely open + close the drawer (and don't care about the detail
// fetch) don't trip on an undefined-promise return.
jest.mock('../api/recommendations', () => ({
getRecommendationDetail: jest.fn().mockResolvedValue({
id: 'rec-default',
usage_history: [],
confidence_bucket: 'low',
provenance_note: '',
}),
getRecommendationsFreshness: jest.fn().mockResolvedValue({ last_collected_at: null, last_collection_error: null }),
}));

// Mock state module
jest.mock('../state', () => ({
getCurrentProvider: jest.fn().mockReturnValue('all'),
Expand Down Expand Up @@ -458,6 +473,122 @@ describe('Recommendations Module', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(document.querySelector('.detail-drawer')).toBeNull();
});

describe('drawer fetches detail from /api/recommendations/:id/detail (issue #44)', () => {
// The detail-fetch mock lives on the api/recommendations module
// so the test can assert call shape without round-tripping
// through apiRequest.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const recApi = require('../api/recommendations') as { getRecommendationDetail: jest.Mock };

beforeEach(() => {
// Real timers — the drawer's fetch uses microtasks (Promise
// resolution) which jest's fake timers don't auto-advance.
jest.useRealTimers();
clearRecommendationDetailCache();
recApi.getRecommendationDetail.mockReset();
});

afterEach(() => {
jest.useFakeTimers();
});

test('drawer fetches detail once per id and renders backend confidence + provenance', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [],
confidence_bucket: 'high',
provenance_note: 'AWS ec2 recommendation APIs · last collected 2026-04-24T12:00:00Z',
});

await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
firstRow?.querySelectorAll('td')[3]?.click();

// Allow the .then() handler to run.
await Promise.resolve();
await Promise.resolve();

expect(recApi.getRecommendationDetail).toHaveBeenCalledTimes(1);
// Default sort is savings desc → rec-15 ($1500) renders first.
expect(recApi.getRecommendationDetail).toHaveBeenCalledWith('rec-15');

const badge = document.querySelector('.detail-drawer .confidence-badge');
expect(badge?.classList.contains('confidence-high')).toBe(true);
expect(badge?.textContent).toBe('High');

const provenance = document.querySelector('.detail-drawer .detail-drawer-note');
expect(provenance?.textContent).toContain('last collected 2026-04-24T12:00:00Z');
});

test('empty usage_history renders the "not yet available" placeholder, not a broken chart', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [],
confidence_bucket: 'medium',
provenance_note: 'AWS ec2 recommendation APIs.',
});

await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();

// No SVG sparkline — degraded path.
expect(document.querySelector('.detail-drawer-sparkline')).toBeNull();
// Placeholder note present.
const usageNote = document.querySelector('.detail-drawer-usage .detail-drawer-note-muted');
expect(usageNote?.textContent).toBe('Usage history not yet available.');
});

test('non-empty usage_history renders an inline SVG sparkline', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [
{ timestamp: '2026-04-23T00:00:00Z', cpu_pct: 12, mem_pct: 30 },
{ timestamp: '2026-04-23T01:00:00Z', cpu_pct: 18, mem_pct: 32 },
{ timestamp: '2026-04-23T02:00:00Z', cpu_pct: 25, mem_pct: 40 },
],
confidence_bucket: 'high',
provenance_note: 'AWS ec2 recommendation APIs.',
});

await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();

const svg = document.querySelector('.detail-drawer-sparkline');
expect(svg).not.toBeNull();
// Two paths (CPU + memory).
expect(svg?.querySelectorAll('path').length).toBe(2);
});

test('repeated open of same drawer reuses the cached detail (one fetch per id)', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [],
confidence_bucket: 'low',
provenance_note: 'AWS ec2 recommendation APIs.',
});

await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();

// Close and re-open the same drawer.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();

expect(recApi.getRecommendationDetail).toHaveBeenCalledTimes(1);
});
});
});

describe('openPurchaseModal', () => {
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/api/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,50 @@ export interface RecommendationsFreshness {
export async function getRecommendationsFreshness(): Promise<RecommendationsFreshness> {
return apiRequest<RecommendationsFreshness>('/recommendations/freshness');
}

/**
* A single sample in the per-recommendation usage time series. Mirrors
* `api.UsagePoint` in `internal/api/types.go`. The series is always
* ordered by `timestamp` ascending. cpu_pct / mem_pct are 0..100.
*/
export interface RecommendationUsagePoint {
timestamp: string;
cpu_pct: number;
mem_pct: number;
}

/**
* Per-id drill-down payload backing the Recommendations row-click
* drawer. Issued by `GET /api/recommendations/:id/detail`. Mirrors
* `api.RecommendationDetailResponse` in the backend.
*
* `confidence_bucket` is the server-computed confidence tier and
* replaces the former client-side `confidenceBucketFor` heuristic.
*
* `provenance_note` is rendered verbatim — the backend already names
* the collector + last-collected timestamp.
*
* `usage_history` is `[]` until the collector starts persisting
* time-series utilisation per recommendation; the drawer renders a
* "Usage history not yet available" line in that case rather than a
* broken empty chart.
*/
export interface RecommendationDetail {
id: string;
usage_history: RecommendationUsagePoint[];
confidence_bucket: 'low' | 'medium' | 'high';
provenance_note: string;
}

/**
* Fetch the per-id detail payload for the Recommendations drawer.
* Backend contract: `GET /api/recommendations/:id/detail`. Returns 404
* for unknown ids (and for ids that exist but belong to accounts the
* caller is not allowed to see — the existence-disclosure-safe path).
*
* The id is path-encoded so ids containing reserved URL characters
* round-trip cleanly.
*/
export async function getRecommendationDetail(id: string): Promise<RecommendationDetail> {
return apiRequest<RecommendationDetail>(`/recommendations/${encodeURIComponent(id)}/detail`);
}
Loading