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
111 changes: 111 additions & 0 deletions frontend/src/__tests__/dashboard-onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Issue #756: Home shows a first-run onboarding card when nothing has been
* loaded and nothing is committed yet.
*/
jest.mock('../toast', () => ({ showToast: jest.fn(() => ({ dismiss: jest.fn() })) }));
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.resolve(true)) }));
jest.mock('chart.js', () => {
const MockChart = jest.fn().mockImplementation(() => ({ destroy: jest.fn() }));
(MockChart as unknown as { register: jest.Mock }).register = jest.fn();
return { Chart: MockChart, registerables: [] };
});

const mockStartRefresh = jest.fn((_onReload: unknown) => Promise.resolve());
jest.mock('../recommendations', () => ({
groupRecsByCell: (recs: unknown[]) => new Map(recs.length ? [['cell-1', recs]] : []),
pageLevelRange: (groups: Map<string, unknown[]>) =>
(groups.size === 0 ? { savingsMin: 0, savingsMax: 0, cellCount: 0 } : { savingsMin: 1, savingsMax: 2, cellCount: 1 }),
formatSavingsRange: () => '$1 – $2',
triggerAutoRefreshIfStale: jest.fn(() => Promise.resolve()),
startRecommendationsRefresh: (onReload: unknown) => mockStartRefresh(onReload),
}));

jest.mock('../api/recommendations', () => ({
getRecommendationsFreshness: jest.fn().mockResolvedValue({ last_collected_at: null, last_collection_error: null }),
}));

jest.mock('../api', () => ({
getDashboardSummary: jest.fn(),
getUpcomingPurchases: jest.fn().mockResolvedValue({ purchases: [] }),
getRecommendations: jest.fn(),
listAccountsMinimal: jest.fn(),
getSavingsAnalytics: jest.fn().mockResolvedValue({ data_points: [] }),
}));

jest.mock('../state', () => ({
getCurrentProvider: jest.fn().mockReturnValue(''),
getCurrentAccountIDs: jest.fn().mockReturnValue([]),
getSavingsChart: jest.fn().mockReturnValue(null),
setSavingsChart: jest.fn(),
subscribeProvider: jest.fn(),
subscribeAccount: jest.fn(),
getCurrentUser: jest.fn().mockReturnValue({
id: 'admin-user', email: 'admin@example.com',
groups: ['00000000-0000-5000-8000-000000000001'],
effectivePermissions: [{ action: 'admin', resource: '*' }],
}),
}));

import { loadDashboard } from '../dashboard';
import * as api from '../api';
import { setOpenAccountsHandler } from '../first-run-empty-state';

const host = (): HTMLElement => document.getElementById('home-onboarding')!;
const EMPTY_SUMMARY = { total_recommendations: 0, active_commitments: 0, committed_monthly: 0, ytd_savings: 0, by_service: {} };

beforeEach(() => {
jest.clearAllMocks();
document.body.innerHTML = `
<section id="home-onboarding" class="hidden"></section>
<div id="summary"></div>
<div id="upcoming-list"></div>
`;
(api.getDashboardSummary as jest.Mock).mockResolvedValue(EMPTY_SUMMARY);
(api.getRecommendations as jest.Mock).mockResolvedValue({ recommendations: [] });
(api.listAccountsMinimal as jest.Mock).mockResolvedValue([]);
setOpenAccountsHandler(jest.fn());
});

describe('Home onboarding card (issue #756)', () => {
test('first run with no accounts: visible card with connect CTA', async () => {
await loadDashboard();
expect(host().classList.contains('hidden')).toBe(false);
expect(host().querySelector('h3')!.textContent).toBe('No cloud accounts connected yet');
expect(host().querySelector('button')!.textContent).toBe('Connect a cloud account');
// The KPI tiles still render underneath.
expect(document.querySelectorAll('#summary .kpi-tile').length).toBe(4);
});

test('accounts connected but no recs: refresh reloads the dashboard', async () => {
(api.listAccountsMinimal as jest.Mock).mockResolvedValue([{ id: 'a', name: 'A' }]);
await loadDashboard();
expect(host().querySelector('h3')!.textContent).toBe('No opportunities yet');
host().querySelector<HTMLButtonElement>('button')!.click();
expect(mockStartRefresh).toHaveBeenCalledWith(loadDashboard);
});

test('hidden once recommendations exist', async () => {
(api.getRecommendations as jest.Mock).mockResolvedValue({ recommendations: [{ id: 'r1' }] });
await loadDashboard();
expect(host().classList.contains('hidden')).toBe(true);
expect(host().childElementCount).toBe(0);
});

test('hidden when commitments exist even without recommendations', async () => {
(api.getDashboardSummary as jest.Mock).mockResolvedValue({ ...EMPTY_SUMMARY, active_commitments: 3 });
await loadDashboard();
expect(host().classList.contains('hidden')).toBe(true);
});

test('hidden when the recommendations fetch failed (unknown, not empty)', async () => {
(api.getRecommendations as jest.Mock).mockRejectedValue(new Error('down'));
await loadDashboard();
expect(host().classList.contains('hidden')).toBe(true);
});

test('account list failure degrades to the generic copy', async () => {
(api.listAccountsMinimal as jest.Mock).mockRejectedValue(new Error('403'));
await loadDashboard();
expect(host().querySelector('h3')!.textContent).toBe('No opportunities yet');
});
});
131 changes: 131 additions & 0 deletions frontend/src/__tests__/first-run-empty-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Issue #756: first-run empty state card shared by Opportunities and Home.
*/
import {
buildFirstRunEmptyState,
describeFreshness,
isScopeFiltered,
setOpenAccountsHandler,
} from '../first-run-empty-state';
import { isAdmin } from '../permissions';

jest.mock('../permissions', () => ({
isAdmin: jest.fn().mockReturnValue(true),
}));

const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
const buttons = (el: HTMLElement): string[] =>
[...el.querySelectorAll('button')].map((b) => b.textContent ?? '');

beforeEach(() => {
jest.clearAllMocks();
(isAdmin as jest.Mock).mockReturnValue(true);
setOpenAccountsHandler(null);
});

describe('isScopeFiltered', () => {
test.each([
['', [], false],
['all', [], false],
['ALL', [], false],
['aws', [], true],
['', ['acct-1'], true],
] as const)('provider=%p accounts=%p -> %p', (provider, accounts, expected) => {
expect(isScopeFiltered(provider, accounts)).toBe(expected);
});
});

describe('describeFreshness', () => {
test('in-flight collection wins over timestamps', () => {
expect(describeFreshness({
last_collected_at: '2026-10-01T10:00:00Z',
last_collection_error: null,
last_collection_started_at: '2026-10-01T11:00:00Z',
})).toBe('A recommendations collection is running now.');
});

test('never collected', () => {
expect(describeFreshness({ last_collected_at: null, last_collection_error: null }))
.toBe('Recommendations have not been collected yet.');
});

test('last collected time plus error', () => {
const text = describeFreshness({ last_collected_at: '2026-10-01T10:00:00Z', last_collection_error: 'AccessDenied' });
expect(text).toMatch(/^Last collected Oct 1, 2026, \d\d:\d\d\./);
expect(text).toContain('The last collection reported errors: AccessDenied');
});
});

describe('buildFirstRunEmptyState', () => {
test('no accounts, admin: connect CTA opens Admin > Accounts, no refresh', () => {
const open = jest.fn();
setOpenAccountsHandler(open);
const card = buildFirstRunEmptyState({ hasAccounts: false, scopeFiltered: false, onRefresh: jest.fn() });
expect(card.classList.contains('empty-state')).toBe(true);
expect(card.querySelector('h3')?.textContent).toBe('No cloud accounts connected yet');
expect(card.textContent).toContain('Connect an AWS, Azure or GCP account');
expect(buttons(card)).toEqual(['Connect a cloud account']);
card.querySelector('button')!.click();
expect(open).toHaveBeenCalledTimes(1);
});

test('no accounts, non-admin: points at an administrator instead of a button', () => {
(isAdmin as jest.Mock).mockReturnValue(false);
setOpenAccountsHandler(jest.fn());
const card = buildFirstRunEmptyState({ hasAccounts: false, scopeFiltered: false });
expect(buttons(card)).toEqual([]);
expect(card.textContent).toContain('Ask an administrator to connect one under Admin > Accounts.');
});

test('accounts connected: "No opportunities yet" with refresh and manage accounts', async () => {
setOpenAccountsHandler(jest.fn());
let resolve: () => void = () => undefined;
const onRefresh = jest.fn(() => new Promise<void>((r) => { resolve = r; }));
const card = buildFirstRunEmptyState({ hasAccounts: true, scopeFiltered: false, onRefresh });
expect(card.querySelector('h3')?.textContent).toBe('No opportunities yet');
expect(buttons(card)).toEqual(['Refresh recommendations', 'Manage cloud accounts']);
const refresh = card.querySelector('button')!;
refresh.click();
expect(onRefresh).toHaveBeenCalledTimes(1);
expect(refresh.disabled).toBe(true);
resolve();
await flush();
expect(refresh.disabled).toBe(false);
});

test('unknown account state (fetch failed) does not claim no accounts', () => {
const card = buildFirstRunEmptyState({ hasAccounts: null, scopeFiltered: false });
expect(card.querySelector('h3')?.textContent).toBe('No opportunities yet');
});

test('topbar scope filter: explains the scope instead of onboarding', () => {
const card = buildFirstRunEmptyState({ hasAccounts: false, scopeFiltered: true, onRefresh: jest.fn() });
expect(card.querySelector('h3')?.textContent).toBe('No opportunities for the selected provider or account');
expect(card.textContent).toContain('All Providers and All Accounts');
expect(buttons(card)).toEqual(['Refresh recommendations']);
});

test('freshness line fills in asynchronously and escapes the error text', async () => {
const card = buildFirstRunEmptyState({
hasAccounts: true,
scopeFiltered: false,
loadFreshness: () => Promise.resolve({ last_collected_at: null, last_collection_error: '<img src=x>' }),
});
const line = card.querySelector<HTMLElement>('.first-run-freshness')!;
expect(line.hidden).toBe(true);
await flush();
expect(line.hidden).toBe(false);
expect(line.textContent).toContain('<img src=x>');
expect(card.querySelector('img')).toBeNull();
});

test('freshness failure leaves the line hidden', async () => {
const card = buildFirstRunEmptyState({
hasAccounts: true,
scopeFiltered: false,
loadFreshness: () => Promise.reject(new Error('down')),
});
await flush();
expect(card.querySelector<HTMLElement>('.first-run-freshness')!.hidden).toBe(true);
});
});
Loading
Loading