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
32 changes: 31 additions & 1 deletion frontend/src/__tests__/api-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { apiRequest } from '../api/client';
import { listActiveCommitments } from '../api/inventory';
import { listActiveCommitments, getCoverageBreakdown } from '../api/inventory';

jest.mock('../api/client', () => ({
apiRequest: jest.fn(),
Expand Down Expand Up @@ -68,3 +68,33 @@ describe('listActiveCommitments', () => {
expect(result).toEqual([]);
});
});

describe('getCoverageBreakdown', () => {
beforeEach(() => {
(apiRequest as jest.Mock).mockReset();
});

test('calls /inventory/coverage', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ providers: [] });

await getCoverageBreakdown();

expect(apiRequest).toHaveBeenCalledWith('/inventory/coverage');
});

test('returns the full response envelope including providers array', async () => {
const payload = {
providers: [
{ provider: 'aws', services: null, overall_coverage_pct: null },
{ provider: 'azure', services: null, overall_coverage_pct: null },
{ provider: 'gcp', services: null, overall_coverage_pct: null },
],
};
(apiRequest as jest.Mock).mockResolvedValue(payload);

const result = await getCoverageBreakdown();

expect(result).toEqual(payload);
expect(result.providers).toHaveLength(3);
});
});
182 changes: 164 additions & 18 deletions frontend/src/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
/**
* Inventory & Coverage section tests (issue #340 T4 + deferred sub-task
* for the Active commitments table).
* Inventory & Coverage section tests (issue #340 T4, #754).
*
* Verifies the sub-tab switching machinery for the umbrella section AND
* the per-commitment table fetch+render flow (skeleton → table | empty |
* error). The coverage sub-section remains an intentional placeholder.
* the per-commitment / coverage fetch+render flows.
*/

// loadRIExchange is a side-effect import from a module that touches the
Expand All @@ -14,16 +12,18 @@ jest.mock('../riexchange', () => ({
loadRIExchange: jest.fn(),
}));

// The active-commitments load path hits the API. Mock the entire api
// barrel so we don't need to stand up fetch — tests exercise the render
// machinery, not the network shape.
// The active-commitments and coverage load paths hit the API. Mock the
// entire api barrel so we don't need to stand up fetch — tests exercise
// the render machinery, not the network shape.
jest.mock('../api', () => ({
listActiveCommitments: jest.fn(),
getCoverageBreakdown: jest.fn(),
}));

import { loadInventory, switchInventorySubSection, loadActiveCommitments } from '../inventory';
import { loadInventory, switchInventorySubSection, loadActiveCommitments, loadCoverageBreakdown } from '../inventory';
import { loadRIExchange } from '../riexchange';
import * as api from '../api';
import type { ProviderCoverageSection } from '../api';

function buildInventoryDOM(): void {
// Build the inventory tab + sub-nav via DOM methods rather than an
Expand Down Expand Up @@ -66,16 +66,24 @@ function buildInventoryDOM(): void {
ac.appendChild(list);
tab.appendChild(ac);

for (const [id, hidden, body] of [
['inventory-coverage', true, 'coverage'],
['inventory-ri-exchange', false, 'ri-exchange'],
] as const) {
const section = document.createElement('section');
section.id = id;
if (hidden) section.classList.add('hidden');
section.textContent = body;
tab.appendChild(section);
}
// coverage section: matches the real HTML structure with refresh button +
// providers container that loadCoverageBreakdown renders into.
const coverageSection = document.createElement('section');
coverageSection.id = 'inventory-coverage';
coverageSection.classList.add('hidden');
const coverageRefresh = document.createElement('button');
coverageRefresh.id = 'coverage-refresh-btn';
coverageRefresh.textContent = 'Refresh';
coverageSection.appendChild(coverageRefresh);
const coverageProviders = document.createElement('div');
coverageProviders.id = 'coverage-providers';
coverageSection.appendChild(coverageProviders);
tab.appendChild(coverageSection);

const riSection = document.createElement('section');
riSection.id = 'inventory-ri-exchange';
riSection.textContent = 'ri-exchange';
tab.appendChild(riSection);

document.body.appendChild(tab);
}
Expand Down Expand Up @@ -115,6 +123,9 @@ describe('Inventory & Coverage sub-section switching', () => {
// switching tests don't need to care about the fetch outcome.
(api.listActiveCommitments as jest.Mock).mockReset();
(api.listActiveCommitments as jest.Mock).mockResolvedValue([]);
// getCoverageBreakdown is invoked when switching to the coverage sub-tab.
(api.getCoverageBreakdown as jest.Mock).mockReset();
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({ providers: [] });
});

afterEach(() => {
Expand Down Expand Up @@ -264,3 +275,138 @@ describe('loadActiveCommitments — fetch + render flow', () => {
expect(accountCell?.querySelector('.monospace')).not.toBeNull();
});
});

// ──────────────────────────────────────────────
// loadCoverageBreakdown — fetch + render flow (issue #754)
// ──────────────────────────────────────────────

function makeProviderSection(
provider: string,
services: ProviderCoverageSection['services'],
overallPct: number | null
): ProviderCoverageSection {
return { provider, services, overall_coverage_pct: overallPct };
}

describe('loadCoverageBreakdown — fetch + render flow', () => {
beforeEach(() => {
buildInventoryDOM();
(api.getCoverageBreakdown as jest.Mock).mockReset();
});

afterEach(() => {
clearDOM();
});

test('renders per-provider sections with service rows', async () => {
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({
providers: [
makeProviderSection('aws', [
{ service: 'ec2', covered_monthly: 200, on_demand_monthly: 300, coverage_pct: 40 },
{ service: 'rds', covered_monthly: 100, on_demand_monthly: 0, coverage_pct: 100 },
], 50),
makeProviderSection('azure', null, null),
makeProviderSection('gcp', null, null),
],
});

await loadCoverageBreakdown();

const container = document.getElementById('coverage-providers')!;
const cards = container.querySelectorAll('.coverage-provider-card');
expect(cards.length).toBe(3);

// AWS card: has service table rows.
const awsCard = cards[0]!;
expect(awsCard.textContent).toContain('AWS');
expect(awsCard.textContent).toContain('50.0% covered');
const rows = awsCard.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
expect(rows[0]!.textContent).toContain('ec2');
expect(rows[0]!.textContent).toContain('40.0%');
expect(rows[1]!.textContent).toContain('rds');
expect(rows[1]!.textContent).toContain('100.0%');
});

test('renders "No usage detected" for providers with null services', async () => {
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({
providers: [
makeProviderSection('aws', null, null),
makeProviderSection('azure', null, null),
makeProviderSection('gcp', null, null),
],
});

await loadCoverageBreakdown();

const container = document.getElementById('coverage-providers')!;
const empties = container.querySelectorAll('.empty');
expect(empties.length).toBe(3);
expect(empties[0]!.textContent).toContain('AWS');
});

test('renders N/A for null coverage_pct (no usage signal on that service)', async () => {
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({
providers: [
makeProviderSection('aws', [
{ service: 'ec2', covered_monthly: 0, on_demand_monthly: 0, coverage_pct: null },
], null),
makeProviderSection('azure', null, null),
makeProviderSection('gcp', null, null),
],
});

await loadCoverageBreakdown();

const container = document.getElementById('coverage-providers')!;
const row = container.querySelector('tbody tr');
expect(row).not.toBeNull();
expect(row!.textContent).toContain('N/A');
});

test('renders an error paragraph when the API rejects', async () => {
(api.getCoverageBreakdown as jest.Mock).mockRejectedValue(new Error('network failure'));

await loadCoverageBreakdown();

const container = document.getElementById('coverage-providers')!;
const err = container.querySelector('.error');
expect(err).not.toBeNull();
expect(err!.textContent).toContain('network failure');
});

test('refresh button re-invokes the fetch', async () => {
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({ providers: [] });

await loadCoverageBreakdown();
expect(api.getCoverageBreakdown).toHaveBeenCalledTimes(1);

const btn = document.getElementById('coverage-refresh-btn')!;
btn.click();
await Promise.resolve();
expect(api.getCoverageBreakdown).toHaveBeenCalledTimes(2);
});

test('coverage bar <th> has non-empty text and aria-label for screen readers', async () => {
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({
providers: [
makeProviderSection('aws', [
{ service: 'ec2', covered_monthly: 100, on_demand_monthly: 100, coverage_pct: 50 },
], 50),
makeProviderSection('azure', null, null),
makeProviderSection('gcp', null, null),
],
});

await loadCoverageBreakdown();

const container = document.getElementById('coverage-providers')!;
const headers = Array.from(container.querySelectorAll('thead th'));
// The last header column is the coverage bar — it must have a visible
// label (not empty string) so screen readers announce the column purpose.
const barTh = headers[headers.length - 1];
expect(barTh).not.toBeNull();
expect(barTh!.textContent).toBe('Coverage bar');
expect(barTh!.getAttribute('aria-label')).toBe('Coverage bar');
});
});
8 changes: 6 additions & 2 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ export type {
ExchangeResult,
RIExchangeConfig,
RIExchangeHistoryRecord,
InventoryCommitment
InventoryCommitment,
CoverageServiceRow,
ProviderCoverageSection,
CoverageBreakdownResponse
} from './types';

// Re-export client functions
Expand Down Expand Up @@ -170,7 +173,8 @@ export {

// Re-export inventory functions
export {
listActiveCommitments
listActiveCommitments,
getCoverageBreakdown
} from './inventory';

// Re-export RI exchange functions
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/api/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { apiRequest } from './client';
import type { InventoryCommitment } from './types';
import type { CoverageBreakdownResponse, InventoryCommitment } from './types';

/**
* List active (non-expired) commitments across the user's accessible
Expand All @@ -21,3 +21,12 @@ export async function listActiveCommitments(accountID?: string): Promise<Invento
const resp = await apiRequest<{ commitments: InventoryCommitment[] }>(`/inventory/commitments${qs}`);
return resp.commitments ?? [];
}

/**
* Fetch per-provider, per-service coverage breakdowns.
* Returns one section per known provider (aws, azure, gcp). A provider
* with no usage data has services=null and overall_coverage_pct=null.
*/
export async function getCoverageBreakdown(): Promise<CoverageBreakdownResponse> {
return apiRequest<CoverageBreakdownResponse>('/inventory/coverage');
}
30 changes: 30 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,36 @@ export interface InventoryCommitment {
status: string;
}

// Coverage breakdown types (issue #754)

/**
* One service row within a provider's coverage section.
* coverage_pct is null when both covered_monthly and on_demand_monthly
* are zero (no usage detected) -- do not coerce null to 0.
*/
export interface CoverageServiceRow {
service: string;
covered_monthly: number;
on_demand_monthly: number;
coverage_pct: number | null;
}

/**
* Per-provider coverage section returned by GET /api/inventory/coverage.
* services is null when the provider has no usage data. Render "No usage
* detected" rather than an empty table in that case.
*/
export interface ProviderCoverageSection {
provider: string;
services: CoverageServiceRow[] | null;
overall_coverage_pct: number | null;
}

/** Envelope returned by GET /api/inventory/coverage. */
export interface CoverageBreakdownResponse {
providers: ProviderCoverageSection[];
}

// Internal types
export interface ApiError extends Error {
status?: number;
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,18 @@ <h2>Active commitments</h2>
</section>
</section>

<!-- Coverage — placeholder until T7 wires per-provider donuts. -->
<!-- Coverage — per-provider breakdowns (issue #754). -->
<section id="inventory-coverage" class="hidden" role="tabpanel" aria-labelledby="inv-subtab-coverage">
<div class="empty-state">
<h3>Coverage</h3>
<p>Per-provider coverage breakdowns are coming soon. Your overall coverage rate is on the Home dashboard.</p>
</div>
<section class="card page-hero">
<div class="section-header">
<h2>Coverage</h2>
<div class="section-header-actions">
<button id="coverage-refresh-btn" class="btn btn-small">Refresh</button>
</div>
</div>
<p class="settings-description">Per-provider coverage rate: the share of on-demand spend protected by Reserved Instances, Savings Plans, or Committed Use Discounts.</p>
</section>
<div id="coverage-providers"></div>
</section>

<!-- RI Exchange — relocated from the former top-level ri-exchange-tab. -->
Expand Down
Loading
Loading