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
57 changes: 51 additions & 6 deletions frontend/src/__tests__/api-inventory.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Tests for the inventory API module (issue #340 deferred sub-task).
* Tests for the inventory API module (issue #340 deferred sub-task, #866).
*
* Verifies the wire format / envelope handling for
* GET /api/inventory/commitments. The backend handler logic is covered
* in handler_inventory_test.go; here we lock down the client adapter.
* GET /api/inventory/commitments and GET /api/inventory/coverage,
* including the provider + account_id query params added by issue #866.
* Backend handler logic is covered in handler_inventory_test.go.
*/

import { apiRequest } from '../api/client';
Expand All @@ -18,7 +19,7 @@ describe('listActiveCommitments', () => {
(apiRequest as jest.Mock).mockReset();
});

test('calls /inventory/commitments without query string by default', async () => {
test('calls /inventory/commitments without query string when no filter', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ commitments: [] });

const result = await listActiveCommitments();
Expand All @@ -30,11 +31,29 @@ describe('listActiveCommitments', () => {
test('appends URL-encoded account_id when scoped to one account', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ commitments: [] });

await listActiveCommitments('acc/with special');
await listActiveCommitments({ accountID: 'acc/with special' });

expect(apiRequest).toHaveBeenCalledWith('/inventory/commitments?account_id=acc%2Fwith%20special');
});

test('appends provider query param when provider filter is set', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ commitments: [] });

await listActiveCommitments({ provider: 'aws' });

expect(apiRequest).toHaveBeenCalledWith('/inventory/commitments?provider=aws');
});

test('appends both account_id and provider when both are set', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ commitments: [] });

await listActiveCommitments({ accountID: 'acc-1', provider: 'azure' });

const url = (apiRequest as jest.Mock).mock.calls[0][0] as string;
expect(url).toContain('account_id=acc-1');
expect(url).toContain('provider=azure');
});

test('returns the commitments array unwrapped from the envelope', async () => {
const commitments = [
{
Expand Down Expand Up @@ -74,14 +93,40 @@ describe('getCoverageBreakdown', () => {
(apiRequest as jest.Mock).mockReset();
});

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

await getCoverageBreakdown();

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

test('appends provider query param when provider filter is set', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ providers: [] });

await getCoverageBreakdown({ provider: 'gcp' });

expect(apiRequest).toHaveBeenCalledWith('/inventory/coverage?provider=gcp');
});

test('appends account_id query param when accountID filter is set', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ providers: [] });

await getCoverageBreakdown({ accountID: 'acc-42' });

expect(apiRequest).toHaveBeenCalledWith('/inventory/coverage?account_id=acc-42');
});

test('appends both params when both are set', async () => {
(apiRequest as jest.Mock).mockResolvedValue({ providers: [] });

await getCoverageBreakdown({ accountID: 'acc-1', provider: 'aws' });

const url = (apiRequest as jest.Mock).mock.calls[0][0] as string;
expect(url).toContain('account_id=acc-1');
expect(url).toContain('provider=aws');
});

test('returns the full response envelope including providers array', async () => {
const payload = {
providers: [
Expand Down
227 changes: 225 additions & 2 deletions frontend/src/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/**
* Inventory & Coverage section tests (issue #340 T4, #754).
* Inventory & Coverage section tests (issue #340 T4, #754, #866).
*
* Verifies the sub-tab switching machinery for the umbrella section AND
* the per-commitment / coverage fetch+render flows.
* the per-commitment / coverage fetch+render flows, AND the chip-
* subscription pattern (issue #866: Main Header chips must propagate to
* both sub-tabs).
*/

// loadRIExchange is a side-effect import from a module that touches the
Expand All @@ -20,9 +22,22 @@ jest.mock('../api', () => ({
getCoverageBreakdown: jest.fn(),
}));

// Mock state so chip subscription tests can control provider/account
// values and capture the registered callbacks. subscribeProvider and
// subscribeAccount must return a function (the unsubscribe handle) since
// inventory.ts calls the return value to tear down old subscriptions on
// repeated loadInventory() calls (feedback_event_listener_dedup pattern).
jest.mock('../state', () => ({
subscribeProvider: jest.fn(() => jest.fn()),
subscribeAccount: jest.fn(() => jest.fn()),
getCurrentProvider: jest.fn(() => ''),
getCurrentAccountIDs: jest.fn(() => []),
}));

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

function buildInventoryDOM(): void {
Expand Down Expand Up @@ -126,6 +141,13 @@ describe('Inventory & Coverage sub-section switching', () => {
// getCoverageBreakdown is invoked when switching to the coverage sub-tab.
(api.getCoverageBreakdown as jest.Mock).mockReset();
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({ providers: [] });
// Reset state mocks to defaults. subscribeProvider/Account must keep
// returning an unsubscribe function so inventory.ts can call it during
// teardown without throwing.
(state.subscribeProvider as jest.Mock).mockReset().mockReturnValue(jest.fn());
(state.subscribeAccount as jest.Mock).mockReset().mockReturnValue(jest.fn());
(state.getCurrentProvider as jest.Mock).mockReturnValue('');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
});

afterEach(() => {
Expand Down Expand Up @@ -193,6 +215,10 @@ describe('loadActiveCommitments — fetch + render flow', () => {
beforeEach(() => {
buildInventoryDOM();
(api.listActiveCommitments as jest.Mock).mockReset();
(state.subscribeProvider as jest.Mock).mockReturnValue(jest.fn());
(state.subscribeAccount as jest.Mock).mockReturnValue(jest.fn());
(state.getCurrentProvider as jest.Mock).mockReturnValue('');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
});

afterEach(() => {
Expand Down Expand Up @@ -295,6 +321,10 @@ describe('loadCoverageBreakdown — fetch + render flow', () => {
beforeEach(() => {
buildInventoryDOM();
(api.getCoverageBreakdown as jest.Mock).mockReset();
(state.subscribeProvider as jest.Mock).mockReturnValue(jest.fn());
(state.subscribeAccount as jest.Mock).mockReturnValue(jest.fn());
(state.getCurrentProvider as jest.Mock).mockReturnValue('');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
});

afterEach(() => {
Expand Down Expand Up @@ -413,3 +443,196 @@ describe('loadCoverageBreakdown — fetch + render flow', () => {
expect(barTh!.getAttribute('aria-label')).toBe('Coverage bar');
});
});

// ──────────────────────────────────────────────
// Chip subscription wiring (issue #866)
//
// Main Header global chips (Provider + Account) must propagate to the
// Active Commitments and Coverage sub-tabs. Mirrors the savings-history
// subscriber tests (PR #741) and dashboard tests (PR #747).
// ──────────────────────────────────────────────

describe('chip subscriptions (issue #866)', () => {
/**
* Add an inventory-tab div with the .active class so isInventoryTabActive()
* returns true. The DOM is built fresh in buildInventoryDOM but doesn't
* receive the .active class — add it here for the guard to pass.
*/
function activateInventoryTab(): void {
const tab = document.getElementById('inventory-tab');
if (tab) tab.classList.add('active');
}

beforeEach(() => {
buildInventoryDOM();
(api.listActiveCommitments as jest.Mock).mockResolvedValue([]);
(api.getCoverageBreakdown as jest.Mock).mockResolvedValue({ providers: [] });
// Each test's loadInventory() call will call subscribeProvider/Account;
// return a fresh jest.fn() as the unsubscribe handle each time.
(state.subscribeProvider as jest.Mock).mockReset().mockReturnValue(jest.fn());
(state.subscribeAccount as jest.Mock).mockReset().mockReturnValue(jest.fn());
(state.getCurrentProvider as jest.Mock).mockReturnValue('');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
});

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

test('loadInventory registers callbacks with state.subscribeProvider and state.subscribeAccount', () => {
loadInventory();

expect(state.subscribeProvider).toHaveBeenCalledTimes(1);
expect(state.subscribeAccount).toHaveBeenCalledTimes(1);
expect(typeof (state.subscribeProvider as jest.Mock).mock.calls[0]?.[0]).toBe('function');
expect(typeof (state.subscribeAccount as jest.Mock).mock.calls[0]?.[0]).toBe('function');
});

test('account chip change re-fetches Active Commitments when inventory tab is active', async () => {
activateInventoryTab();
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue(['acct-X']);

// Start on active-commitments sub-tab. Force the sub-section in case
// a prior test left module-scoped currentSubSection at 'coverage'.
loadInventory();
switchInventorySubSection('active-commitments');

// Clear the initial load call, then simulate chip change.
(api.listActiveCommitments as jest.Mock).mockClear();
const accountCb = (state.subscribeAccount as jest.Mock).mock.calls[0]?.[0] as () => void;
accountCb();
// queueMicrotask drains within a setTimeout(0).
await new Promise((r) => setTimeout(r, 0));

expect(api.listActiveCommitments).toHaveBeenCalledTimes(1);
expect(api.listActiveCommitments).toHaveBeenCalledWith(
expect.objectContaining({ accountID: 'acct-X' })
);
});

test('provider chip change re-fetches Active Commitments when inventory tab is active', async () => {
activateInventoryTab();
(state.getCurrentProvider as jest.Mock).mockReturnValue('aws');

loadInventory();
switchInventorySubSection('active-commitments');

(api.listActiveCommitments as jest.Mock).mockClear();
const providerCb = (state.subscribeProvider as jest.Mock).mock.calls[0]?.[0] as () => void;
providerCb();
await new Promise((r) => setTimeout(r, 0));

expect(api.listActiveCommitments).toHaveBeenCalledTimes(1);
expect(api.listActiveCommitments).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'aws' })
);
});

test('provider chip change re-fetches Coverage when coverage sub-tab is active', async () => {
activateInventoryTab();
(state.getCurrentProvider as jest.Mock).mockReturnValue('azure');

loadInventory();
// Switch to the coverage sub-tab.
switchInventorySubSection('coverage');
(api.getCoverageBreakdown as jest.Mock).mockClear();

const providerCb = (state.subscribeProvider as jest.Mock).mock.calls[0]?.[0] as () => void;
providerCb();
await new Promise((r) => setTimeout(r, 0));

expect(api.getCoverageBreakdown).toHaveBeenCalledTimes(1);
expect(api.getCoverageBreakdown).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'azure' })
);
});

test('account chip change re-fetches Coverage when coverage sub-tab is active', async () => {
activateInventoryTab();
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue(['acct-Y']);

loadInventory();
switchInventorySubSection('coverage');
(api.getCoverageBreakdown as jest.Mock).mockClear();

const accountCb = (state.subscribeAccount as jest.Mock).mock.calls[0]?.[0] as () => void;
accountCb();
await new Promise((r) => setTimeout(r, 0));

expect(api.getCoverageBreakdown).toHaveBeenCalledTimes(1);
expect(api.getCoverageBreakdown).toHaveBeenCalledWith(
expect.objectContaining({ accountID: 'acct-Y' })
);
});

test('does NOT re-fetch when inventory tab is inactive (active-tab guard)', async () => {
// First load with tab active to fix module-scoped currentSubSection
// to 'active-commitments'. Then deactivate the tab and verify the
// chip callbacks early-return.
activateInventoryTab();
loadInventory();
switchInventorySubSection('active-commitments');
const tab = document.getElementById('inventory-tab');
if (tab) tab.classList.remove('active');

(api.listActiveCommitments as jest.Mock).mockClear();
(api.getCoverageBreakdown as jest.Mock).mockClear();
const providerCb = (state.subscribeProvider as jest.Mock).mock.calls[0]?.[0] as () => void;
const accountCb = (state.subscribeAccount as jest.Mock).mock.calls[0]?.[0] as () => void;
providerCb();
accountCb();
await new Promise((r) => setTimeout(r, 0));

expect(api.listActiveCommitments).not.toHaveBeenCalled();
expect(api.getCoverageBreakdown).not.toHaveBeenCalled();
});

test('coalesces back-to-back provider+account fires into one fetch', async () => {
activateInventoryTab();

loadInventory();
switchInventorySubSection('active-commitments');
(api.listActiveCommitments as jest.Mock).mockClear();

const providerCb = (state.subscribeProvider as jest.Mock).mock.calls[0]?.[0] as () => void;
const accountCb = (state.subscribeAccount as jest.Mock).mock.calls[0]?.[0] as () => void;

// Simulate topbar: clear accounts then set provider synchronously.
accountCb();
providerCb();
await new Promise((r) => setTimeout(r, 0));

expect(api.listActiveCommitments).toHaveBeenCalledTimes(1);
});

test('filter-aware empty state: shows provider name when provider chip is set', async () => {
(state.getCurrentProvider as jest.Mock).mockReturnValue('gcp');

await loadActiveCommitments();

const list = document.getElementById('active-commitments-list')!;
const empty = list.querySelector('.empty');
expect(empty).not.toBeNull();
expect(empty!.textContent).toContain('"gcp"');
});

test('filter-aware empty state: shows account ID when account chip is set', async () => {
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue(['acct-42']);

await loadActiveCommitments();

const list = document.getElementById('active-commitments-list')!;
const empty = list.querySelector('.empty');
expect(empty).not.toBeNull();
expect(empty!.textContent).toContain('acct-42');
});

test('generic empty state: shown when no chip filters are active', async () => {
await loadActiveCommitments();

const list = document.getElementById('active-commitments-list')!;
const empty = list.querySelector('.empty');
expect(empty).not.toBeNull();
expect(empty!.textContent).toMatch(/no active commitments found across your registered accounts/i);
});
});
Loading
Loading