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
49 changes: 48 additions & 1 deletion frontend/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* History module tests
*/
import { initHistoryDateRange, viewPlanHistory, loadHistory } from '../history';
import { initHistoryDateRange, viewPlanHistory, loadHistory, setupHistoryHandlers } from '../history';

// Mock the dependent modules
jest.mock('../api', () => ({
Expand Down Expand Up @@ -316,4 +316,51 @@ describe('History Module', () => {
});
});
});

// Issue #701: setupHistoryHandlers must subscribe to the global topbar
// provider/account filter chips so the Purchase History table and
// Approval Queue reload when a chip changes. PR #716 fixed the backend
// filter params but the frontend subscription was never registered in
// app.ts -- adding this suite guards against a regression.
describe('setupHistoryHandlers (issue #701)', () => {
beforeEach(() => {
(api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [] });
});

test('registers a callback with state.subscribeProvider', () => {
setupHistoryHandlers();
expect((require('../state').subscribeProvider as jest.Mock)).toHaveBeenCalledTimes(1);
});

test('registers a callback with state.subscribeAccount', () => {
setupHistoryHandlers();
expect((require('../state').subscribeAccount as jest.Mock)).toHaveBeenCalledTimes(1);
});

test('provider change triggers loadHistory', async () => {
setupHistoryHandlers();
const stateModule = require('../state');
const providerCb = (stateModule.subscribeProvider as jest.Mock).mock.calls[0]?.[0] as () => void;
expect(typeof providerCb).toBe('function');

(api.getHistory as jest.Mock).mockClear();
providerCb();
await new Promise((r) => setTimeout(r, 0));

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

test('account change triggers loadHistory', async () => {
setupHistoryHandlers();
const stateModule = require('../state');
const accountCb = (stateModule.subscribeAccount as jest.Mock).mock.calls[0]?.[0] as () => void;
expect(typeof accountCb).toBe('function');

(api.getHistory as jest.Mock).mockClear();
accountCb();
await new Promise((r) => setTimeout(r, 0));

expect(api.getHistory).toHaveBeenCalledTimes(1);
});
});
});
3 changes: 2 additions & 1 deletion frontend/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ jest.mock('../recommendations', () => ({
}));

jest.mock('../history', () => ({
loadHistory: jest.fn()
loadHistory: jest.fn(),
setupHistoryHandlers: jest.fn(),
}));

jest.mock('../settings', () => ({
Expand Down
1 change: 1 addition & 0 deletions frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ jest.mock('../apikeys', () => ({

jest.mock('../history', () => ({
loadHistory: jest.fn(),
setupHistoryHandlers: jest.fn(),
}));

jest.mock('../modules/savings-history', () => ({
Expand Down
165 changes: 164 additions & 1 deletion frontend/src/__tests__/savings-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ describe('Savings History Module', () => {
<div id="savings-history-container">
<canvas id="savings-history-chart"></canvas>
</div>
<div id="savings-history-empty" class="hidden">No data</div>
<div id="savings-history-empty" class="hidden">
<p>No savings history data available yet.</p>
<p class="help-text">Data will be collected hourly once you have active purchases.</p>
</div>
<div id="savings-stats">
<span id="period-savings">$0</span>
<span id="avg-hourly-savings">$0/hr</span>
Expand Down Expand Up @@ -975,4 +978,164 @@ describe('Savings History Module', () => {
expect(mockChartInstance.destroy).toHaveBeenCalled();
});
});

// Issue #701: when the chart returns no data because a filter is active, the
// empty-state message must distinguish "nothing in this scope" from "no data
// at all". Without a filter the original message applies; with a filter the
// message should name the active filter so the user understands why the chart
// is blank.
describe('empty-state copy with active filter (issue #701)', () => {
test('shows filter name in empty-state heading when provider chip is set', async () => {
(state.getCurrentProvider as jest.Mock).mockReturnValue('aws');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const emptyEl = document.getElementById('savings-history-empty');
const heading = emptyEl?.querySelector('p:first-child');
expect(heading?.textContent).toContain('AWS');
expect(heading?.textContent).not.toContain('available yet');
});

test('shows help text asking to broaden filter when a filter is active', async () => {
(state.getCurrentProvider as jest.Mock).mockReturnValue('gcp');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const emptyEl = document.getElementById('savings-history-empty');
const help = emptyEl?.querySelector('p.help-text');
expect(help?.textContent).toMatch(/broaden/i);
});

test('shows original message when no filter is active', async () => {
(state.getCurrentProvider as jest.Mock).mockReturnValue('');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const emptyEl = document.getElementById('savings-history-empty');
const heading = emptyEl?.querySelector('p:first-child');
expect(heading?.textContent).toContain('available yet');
});

test('shows filter name in empty-state heading when account chip is set', async () => {
const testUUID = 'aabbccdd-1234-5678-abcd-aabbccddee00';
(state.getCurrentProvider as jest.Mock).mockReturnValue('');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([testUUID]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const emptyEl = document.getElementById('savings-history-empty');
const heading = emptyEl?.querySelector('p:first-child');
expect(heading?.textContent).toContain(testUUID);
});

test('shows provider and account in heading when both chips are set', async () => {
const testUUID = 'aabbccdd-1234-5678-abcd-aabbccddee00';
(state.getCurrentProvider as jest.Mock).mockReturnValue('azure');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([testUUID]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const emptyEl = document.getElementById('savings-history-empty');
const heading = emptyEl?.querySelector('p:first-child');
expect(heading?.textContent).toContain('AZURE');
expect(heading?.textContent).toContain(testUUID);
});

test('shows distinct error copy on API failure (not the filter-aware empty state)', async () => {
// CR feedback on PR #741: a fetch failure used to fall through to the
// "no data for the selected filter" empty state, which masked real
// outages (network/5xx/auth). The error path must now render its own
// copy so the user can tell "filter matched nothing" from "the fetch
// never completed".
(state.getCurrentProvider as jest.Mock).mockReturnValue('aws');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
(getSavingsAnalytics as jest.Mock).mockRejectedValue(new Error('network failure'));
console.error = jest.fn();

await loadSavingsHistory();

const emptyEl = document.getElementById('savings-history-empty');
const heading = emptyEl?.querySelector('p:first-child');
const help = emptyEl?.querySelector('p.help-text');
expect(heading?.textContent).toBe('Failed to load savings history.');
expect(help?.textContent).toContain('network failure');
// Error copy is NOT the empty-state copy — it does not name the filter.
expect(heading?.textContent).not.toContain('AWS');
expect(heading?.textContent).not.toContain('available yet');
});

test('error state hides chart container and stats', async () => {
(getSavingsAnalytics as jest.Mock).mockRejectedValue(new Error('boom'));
console.error = jest.fn();

await loadSavingsHistory();

const chartContainer = document.getElementById('savings-history-chart')?.parentElement;
const statsEl = document.getElementById('savings-stats');
expect(chartContainer?.classList.contains('hidden')).toBe(true);
expect(statsEl?.classList.contains('hidden')).toBe(true);
});

test('error state handles non-Error rejection with "Unknown error"', async () => {
// Generic reject value (e.g. a thrown string from a non-Error catch path)
// must still surface a helpful message rather than `[object Object]`.
(getSavingsAnalytics as jest.Mock).mockRejectedValue('something broke');
console.error = jest.fn();

await loadSavingsHistory();

const help = document.getElementById('savings-history-empty')?.querySelector('p.help-text');
expect(help?.textContent).toContain('Unknown error');
});
});

// CR feedback on PR #741 F2: a provider chip set to the "all" sentinel
// is the user explicitly choosing unfiltered, so buildFilterDesc must not
// include "ALL" in the empty-state copy.
describe('buildFilterDesc — "all" sentinel handling (PR #741 F2)', () => {
test('treats provider === "all" as unfiltered (no provider clause in heading)', async () => {
(state.getCurrentProvider as jest.Mock).mockReturnValue('all');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const heading = document.getElementById('savings-history-empty')?.querySelector('p:first-child');
expect(heading?.textContent).toContain('available yet');
expect(heading?.textContent).not.toMatch(/all/i);
});

test('treats provider === "ALL" (case-insensitive) as unfiltered', async () => {
(state.getCurrentProvider as jest.Mock).mockReturnValue('ALL');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const heading = document.getElementById('savings-history-empty')?.querySelector('p:first-child');
expect(heading?.textContent).toContain('available yet');
expect(heading?.textContent).not.toContain('ALL');
});

test('still shows account clause when provider === "all" but account chip is set', async () => {
const testUUID = 'aabbccdd-1234-5678-abcd-aabbccddee00';
(state.getCurrentProvider as jest.Mock).mockReturnValue('all');
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([testUUID]);
(getSavingsAnalytics as jest.Mock).mockResolvedValue({ data_points: [] });

await loadSavingsHistory();

const heading = document.getElementById('savings-history-empty')?.querySelector('p:first-child');
expect(heading?.textContent).toContain(testUUID);
expect(heading?.textContent).not.toMatch(/all,/i);
});
});
});
2 changes: 1 addition & 1 deletion frontend/src/__tests__/sidebar-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jest.mock('../settings', () => ({
}));
jest.mock('../users', () => ({ setupUserHandlers: jest.fn() }));
jest.mock('../apikeys', () => ({ initApiKeys: jest.fn() }));
jest.mock('../history', () => ({ loadHistory: jest.fn() }));
jest.mock('../history', () => ({ loadHistory: jest.fn(), setupHistoryHandlers: jest.fn() }));
jest.mock('../modules/savings-history', () => ({ initSavingsHistory: jest.fn() }));
jest.mock('../riexchange', () => ({
setupRIExchangeHandlers: jest.fn(),
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { savePlan, setupPlanHandlers, closePlanModal, openNewPlanModal, closePur
import { saveGlobalSettings, setupSettingsHandlers, resetSettings } from './settings';
import { setupUserHandlers } from './users';
import { initApiKeys } from './apikeys';
import { loadHistory } from './history';
import { loadHistory, setupHistoryHandlers } from './history';
import { initSavingsHistory } from './modules/savings-history';
import { setupRIExchangeHandlers, saveAutomationSettings } from './riexchange';
import { showToast } from './toast';
Expand Down Expand Up @@ -167,6 +167,12 @@ export function setupEventListeners(): void {
// Setup savings history charts
initSavingsHistory();

// Wire provider/account topbar filter chips to Purchase History +
// Approval Queue (issue #701). initSavingsHistory above subscribes
// the chart; setupHistoryHandlers subscribes the two list consumers
// so all three reload together when a chip changes.
setupHistoryHandlers();

// Setup feedback link
setupFeedbackLink();

Expand Down
Loading
Loading