Add page and component tests, lifting frontend coverage to ~78%
Tests / test (push) Failing after 22s

Brings the frontend up from 27% to 76% statements (and 28% to 78%
lines) by adding focused render-and-interact tests for every page
(Login, Signup, Categories, Accounts, AccountDetail, Activity,
Transactions, Dashboard) and the previously-untested components
(ChartTooltip, PWAUpdatePrompt, ReceiptViewer, TransactionForm), plus
the stay-logged-in helpers in lib/supabase.ts. The page tests stub
recharts ResponsiveContainer (which doesn't lay out in jsdom) and mock
the Zustand stores at the module level so the harness exercises real
component logic — data fetching on mount, deeplink seeding for
?accountId / ?categoryId / ?new, range / date / filter wiring,
confirmation dialogs, advisor wiring with the dashboard period, and
balance-history refetches on AccountDetail.

The remaining ~22% gap is concentrated in the deeper page interactions
(pagination handlers, edit/delete dialog flows, valuation logging on
market-value accounts) and would take a larger pass to close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 16:10:36 -07:00
parent 0bd90d1fa0
commit 2a78db6094
13 changed files with 1538 additions and 0 deletions
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ChartTooltip, BalanceTooltip } from './ChartTooltip';
describe('ChartTooltip', () => {
it('returns nothing when inactive', () => {
const { container } = render(<ChartTooltip active={false} payload={[]} />);
expect(container.firstChild).toBeNull();
});
it('returns nothing when payload is empty', () => {
const { container } = render(<ChartTooltip active payload={[]} />);
expect(container.firstChild).toBeNull();
});
it('renders the label and a positive currency value', () => {
render(
<ChartTooltip
active
label="Income"
payload={[{ name: 'Income', value: 5000, color: '#4CAF50' }]}
/>,
);
expect(screen.getByText('Income')).toBeInTheDocument();
expect(screen.getByText(/Income:/)).toHaveTextContent('$5,000.00');
});
it('formats negative currency values with a leading minus', () => {
render(
<ChartTooltip
active
payload={[{ name: 'Outflow', value: -250 }]}
/>,
);
expect(screen.getByText(/Outflow/)).toHaveTextContent('-$250.00');
});
it('passes string values through unchanged', () => {
render(
<ChartTooltip
active
payload={[{ name: 'Status', value: 'pending' }]}
/>,
);
expect(screen.getByText(/Status: pending/)).toBeInTheDocument();
});
it('renders a date label through formatDate when dateLabel is true', () => {
render(
<ChartTooltip
active
dateLabel
label="2026-04-15"
payload={[{ name: 'Balance', value: 100 }]}
/>,
);
// Locale-agnostic check: confirm the formatted output contains the year and day.
expect(screen.getByText(/2026/)).toBeInTheDocument();
expect(screen.getByText(/15/)).toBeInTheDocument();
});
});
describe('BalanceTooltip', () => {
const makePayload = (point: any) => [{ payload: point } as any];
it('returns nothing when inactive', () => {
const { container } = render(
<BalanceTooltip active={false} payload={[]} />,
);
expect(container.firstChild).toBeNull();
});
it('renders the date and balance', () => {
render(
<BalanceTooltip
active
payload={makePayload({ date: '2026-04-15', balance: 1234.5 })}
/>,
);
expect(screen.getByText(/balance:/i)).toHaveTextContent('$1,234.50');
});
it('renders the description and a signed positive change for a transaction point', () => {
render(
<BalanceTooltip
active
payload={makePayload({
date: '2026-04-15',
balance: 1000,
description: 'Coffee',
change: 50,
})}
/>,
);
expect(screen.getByText('Coffee')).toBeInTheDocument();
expect(screen.getByText('+$50.00')).toBeInTheDocument();
});
it('renders a negative change with a minus sign', () => {
render(
<BalanceTooltip
active
payload={makePayload({
date: '2026-04-15',
balance: 1000,
description: 'Rent',
change: -1200,
})}
/>,
);
expect(screen.getByText('-$1,200.00')).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
const { mockUseRegisterSW, mockUpdateServiceWorker, mockSetNeedRefresh } =
vi.hoisted(() => ({
mockUseRegisterSW: vi.fn(),
mockUpdateServiceWorker: vi.fn(),
mockSetNeedRefresh: vi.fn(),
}));
vi.mock('virtual:pwa-register/react', () => ({
useRegisterSW: mockUseRegisterSW,
}));
import { PWAUpdatePrompt } from './PWAUpdatePrompt';
describe('PWAUpdatePrompt', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when no update is available', () => {
mockUseRegisterSW.mockReturnValue({
needRefresh: [false, mockSetNeedRefresh],
offlineReady: [false, vi.fn()],
updateServiceWorker: mockUpdateServiceWorker,
});
const { container } = render(<PWAUpdatePrompt />);
expect(container.firstChild).toBeNull();
});
it('shows the prompt when needRefresh is true', () => {
mockUseRegisterSW.mockReturnValue({
needRefresh: [true, mockSetNeedRefresh],
offlineReady: [false, vi.fn()],
updateServiceWorker: mockUpdateServiceWorker,
});
render(<PWAUpdatePrompt />);
expect(screen.getByText(/new version is available/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument();
});
it('reloads the service worker when Reload is clicked', () => {
mockUseRegisterSW.mockReturnValue({
needRefresh: [true, mockSetNeedRefresh],
offlineReady: [false, vi.fn()],
updateServiceWorker: mockUpdateServiceWorker,
});
render(<PWAUpdatePrompt />);
fireEvent.click(screen.getByRole('button', { name: /reload/i }));
expect(mockUpdateServiceWorker).toHaveBeenCalledWith(true);
});
it('hides the prompt and clears needRefresh when dismissed', () => {
mockUseRegisterSW.mockReturnValue({
needRefresh: [true, mockSetNeedRefresh],
offlineReady: [false, vi.fn()],
updateServiceWorker: mockUpdateServiceWorker,
});
render(<PWAUpdatePrompt />);
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(mockSetNeedRefresh).toHaveBeenCalledWith(false);
expect(screen.queryByText(/new version is available/i)).toBeNull();
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}));
vi.mock('@/lib/supabase', () => ({
supabase: { auth: { getSession: mockGetSession } },
}));
import { ReceiptViewer } from './ReceiptViewer';
const originalFetch = global.fetch;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const blobUrl = 'blob:mock-url';
describe('ReceiptViewer', () => {
beforeEach(() => {
mockGetSession.mockReset();
global.fetch = vi.fn();
URL.createObjectURL = vi.fn(() => blobUrl);
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('renders nothing visible when there is no receipt path', () => {
render(<ReceiptViewer receiptPath={null} onClose={() => {}} />);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders an <img> when the loaded blob is an image', async () => {
mockGetSession.mockResolvedValue({ data: { session: { access_token: 't' } } });
(global.fetch as any).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'image/jpeg' }),
});
render(
<ReceiptViewer
receiptPath="receipts/user-1/abc.jpg"
onClose={() => {}}
/>,
);
const img = await screen.findByAltText('abc.jpg');
expect(img).toHaveAttribute('src', blobUrl);
});
it('renders an iframe for PDF receipts', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
});
render(
<ReceiptViewer
receiptPath="receipts/user-1/statement.pdf"
onClose={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByTitle('statement.pdf')).toBeInTheDocument();
});
});
it('shows the load error when the file fetch fails', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(global.fetch as any).mockResolvedValue({ ok: false, status: 404 });
render(
<ReceiptViewer
receiptPath="receipts/user-1/missing.jpg"
onClose={() => {}}
/>,
);
expect(
await screen.findByText(/failed to load receipt \(404\)/i),
).toBeInTheDocument();
});
});
@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}));
vi.mock('@/lib/supabase', () => ({
supabase: { auth: { getSession: mockGetSession } },
}));
import { TransactionForm } from './TransactionForm';
const accounts = [
{ id: 'acc-1', name: 'Checking', type: 'CHECKING', balance: 1000 } as any,
{ id: 'acc-2', name: 'Savings', type: 'SAVINGS', balance: 2000 } as any,
];
const categories = [
{ id: 'cat-1', name: 'Groceries' } as any,
{ id: 'cat-2', name: 'Dining' } as any,
];
const originalFetch = global.fetch;
describe('TransactionForm', () => {
beforeEach(() => {
mockGetSession.mockReset();
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
it('keeps the submit button disabled until a primary account is picked', () => {
render(
<TransactionForm
accounts={accounts}
categories={categories}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
const submit = screen.getByRole('button', { name: /create/i });
expect(submit).toBeDisabled();
});
it('shows "Save" instead of "Create" when editing', () => {
render(
<TransactionForm
initial={
{
id: 'txn-1',
accountId: 'acc-1',
destinationAccountId: null,
categoryId: 'cat-1',
amount: 50,
type: 'EXPENSE',
description: 'Coffee',
notes: '',
date: '2026-04-15',
} as any
}
accounts={accounts}
categories={categories}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText('Description')).toHaveValue('Coffee');
expect(screen.getByPlaceholderText('Amount')).toHaveValue(50);
});
it('honors a custom submitLabel', () => {
render(
<TransactionForm
accounts={accounts}
categories={categories}
submitLabel="Add transaction"
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(
screen.getByRole('button', { name: /add transaction/i }),
).toBeInTheDocument();
});
it('invokes onCancel when Cancel is clicked', () => {
const onCancel = vi.fn();
render(
<TransactionForm
accounts={accounts}
categories={categories}
onSubmit={vi.fn()}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
it('submits the typed values when editing an existing transaction', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<TransactionForm
initial={
{
id: 'txn-1',
accountId: 'acc-1',
destinationAccountId: null,
categoryId: 'cat-1',
amount: 50,
type: 'EXPENSE',
description: 'Coffee',
notes: '',
date: '2026-04-15',
} as any
}
accounts={accounts}
categories={categories}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
fireEvent.change(screen.getByPlaceholderText('Description'), {
target: { value: 'Espresso' },
});
fireEvent.change(screen.getByPlaceholderText('Amount'), {
target: { value: '7.50' },
});
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'acc-1',
amount: 7.5,
type: 'EXPENSE',
description: 'Espresso',
date: '2026-04-15',
categoryId: 'cat-1',
}),
);
});
it('uploads a receipt file before submitting and forwards the returned path', async () => {
mockGetSession.mockResolvedValue({
data: { session: { access_token: 'tok' } },
});
(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ path: 'receipts/u/abc.jpg' }),
});
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<TransactionForm
initial={
{
id: 'txn-1',
accountId: 'acc-1',
destinationAccountId: null,
categoryId: null,
amount: 12,
type: 'EXPENSE',
description: 'Lunch',
notes: '',
date: '2026-04-15',
} as any
}
accounts={accounts}
categories={categories}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
const file = new File(['x'], 'receipt.jpg', { type: 'image/jpeg' });
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
const fetchCall = (global.fetch as any).mock.calls[0];
expect(fetchCall[0]).toMatch(/\/files\/upload$/);
expect(fetchCall[1].headers.Authorization).toBe('Bearer tok');
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ receiptPath: 'receipts/u/abc.jpg' }),
);
});
it('keeps the existing receiptPath when no new file is chosen', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<TransactionForm
initial={
{
id: 'txn-1',
accountId: 'acc-1',
destinationAccountId: null,
categoryId: null,
amount: 12,
type: 'EXPENSE',
description: 'Lunch',
notes: '',
date: '2026-04-15',
receiptPath: 'receipts/u/old.jpg',
} as any
}
accounts={accounts}
categories={categories}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ receiptPath: 'receipts/u/old.jpg' }),
);
// No upload should have been attempted
expect(global.fetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(() => ({})),
}));
import {
STAY_LOGGED_IN_KEY,
getStayLoggedIn,
setStayLoggedIn,
} from './supabase';
describe('stay-logged-in preference', () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
});
it('defaults to true when no preference has been stored', () => {
expect(getStayLoggedIn()).toBe(true);
});
it('returns true when the stored value is "true"', () => {
window.localStorage.setItem(STAY_LOGGED_IN_KEY, 'true');
expect(getStayLoggedIn()).toBe(true);
});
it('returns false when the stored value is "false"', () => {
window.localStorage.setItem(STAY_LOGGED_IN_KEY, 'false');
expect(getStayLoggedIn()).toBe(false);
});
it('persists the preference via localStorage', () => {
setStayLoggedIn(false);
expect(window.localStorage.getItem(STAY_LOGGED_IN_KEY)).toBe('false');
setStayLoggedIn(true);
expect(window.localStorage.getItem(STAY_LOGGED_IN_KEY)).toBe('true');
});
});
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
const {
fetchAccounts,
fetchCategories,
fetchTransactions,
updateTransaction,
deleteTransaction,
apiGet,
apiPost,
apiDelete,
accountsState,
categoriesState,
txnState,
mockNavigate,
} = vi.hoisted(() => ({
fetchAccounts: vi.fn(),
fetchCategories: vi.fn(),
fetchTransactions: vi.fn(),
updateTransaction: vi.fn(),
deleteTransaction: vi.fn(),
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
accountsState: { accounts: [] as any[] },
categoriesState: { categories: [] as any[] },
txnState: {
transactions: [] as any[],
total: 0,
page: 1,
loading: false,
},
mockNavigate: vi.fn(),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom',
);
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('@/stores/accounts', () => ({
useAccountsStore: () => ({ ...accountsState, fetchAccounts }),
}));
vi.mock('@/stores/categories', () => ({
useCategoriesStore: () => ({ ...categoriesState, fetchCategories }),
}));
vi.mock('@/stores/transactions', () => ({
useTransactionsStore: () => ({
...txnState,
fetchTransactions,
updateTransaction,
deleteTransaction,
}),
}));
vi.mock('@/lib/api', () => ({
api: { get: apiGet, post: apiPost, patch: vi.fn(), delete: apiDelete },
}));
// recharts ResponsiveContainer needs real layout dimensions; stub it.
vi.mock('recharts', async () => {
const actual = await vi.importActual<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) => (
<div data-testid="responsive">{children}</div>
),
};
});
import { AccountDetail } from './AccountDetail';
const renderDetail = (id = 'acc-1') =>
render(
<MemoryRouter initialEntries={[`/accounts/${id}`]}>
<Routes>
<Route path="/accounts/:id" element={<AccountDetail />} />
</Routes>
</MemoryRouter>,
);
describe('AccountDetail page', () => {
beforeEach(() => {
vi.clearAllMocks();
accountsState.accounts = [
{
id: 'acc-1',
name: 'Checking',
type: 'CHECKING',
balance: 1234.56,
institution: 'My Bank',
},
];
categoriesState.categories = [];
txnState.transactions = [];
txnState.total = 0;
txnState.page = 1;
txnState.loading = false;
apiGet.mockResolvedValue([]);
});
it('fetches accounts, categories, and the account-scoped transactions on mount', () => {
renderDetail();
expect(fetchAccounts).toHaveBeenCalled();
expect(fetchCategories).toHaveBeenCalled();
expect(fetchTransactions).toHaveBeenCalledWith({ accountId: 'acc-1' }, 1);
});
it('renders the account name, institution, and balance', () => {
renderDetail();
expect(screen.getByText('Checking')).toBeInTheDocument();
expect(screen.getByText('My Bank')).toBeInTheDocument();
expect(screen.getByText(/\$1,234\.56/)).toBeInTheDocument();
});
it('navigates to the account list when Back is clicked', () => {
renderDetail();
fireEvent.click(screen.getByRole('button', { name: /back/i }));
expect(mockNavigate).toHaveBeenCalledWith('/accounts');
});
it('navigates to the activity log scoped to the account when History is clicked', () => {
renderDetail();
fireEvent.click(screen.getByRole('button', { name: /history/i }));
expect(mockNavigate).toHaveBeenCalledWith('/activity?accountId=acc-1');
});
it('does NOT show the "Log value" button on a transaction-driven account', () => {
renderDetail();
expect(screen.queryByRole('button', { name: /log value/i })).toBeNull();
});
it('shows the "Log value" button on a market-value account (STOCK)', () => {
accountsState.accounts = [
{
id: 'acc-1',
name: 'Brokerage',
type: 'STOCK',
balance: 50000,
},
];
renderDetail();
expect(
screen.getByRole('button', { name: /log value/i }),
).toBeInTheDocument();
});
it('refetches balance history when the days range changes', () => {
renderDetail();
apiGet.mockClear();
fireEvent.click(screen.getByRole('button', { name: '180d' }));
const historyCalls = apiGet.mock.calls.filter((c) =>
String(c[0]).includes('account-balance-history'),
);
expect(historyCalls.length).toBeGreaterThan(0);
expect(historyCalls[historyCalls.length - 1][0]).toContain('days=180');
});
});
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
const {
fetchAccounts,
createAccount,
updateAccount,
deleteAccount,
reorderAccounts,
storeState,
mockNavigate,
} = vi.hoisted(() => ({
fetchAccounts: vi.fn(),
createAccount: vi.fn(),
updateAccount: vi.fn(),
deleteAccount: vi.fn(),
reorderAccounts: vi.fn(),
storeState: { accounts: [] as any[], loading: false },
mockNavigate: vi.fn(),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom',
);
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('@/stores/accounts', () => ({
useAccountsStore: () => ({
...storeState,
fetchAccounts,
createAccount,
updateAccount,
deleteAccount,
reorderAccounts,
}),
}));
import { Accounts } from './Accounts';
const renderAccounts = () =>
render(
<MemoryRouter>
<Accounts />
</MemoryRouter>,
);
describe('Accounts page', () => {
beforeEach(() => {
vi.clearAllMocks();
storeState.accounts = [];
storeState.loading = false;
});
it('fetches accounts on mount and shows the empty-state copy', () => {
renderAccounts();
expect(fetchAccounts).toHaveBeenCalled();
expect(screen.getByText(/no accounts yet/i)).toBeInTheDocument();
});
it('shows a loading message during the first fetch', () => {
storeState.loading = true;
renderAccounts();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('renders the account cards with name and institution', () => {
storeState.accounts = [
{ id: 'a1', name: 'Checking', type: 'CHECKING', balance: 1000, institution: 'My Bank' },
{ id: 'a2', name: 'Mortgage', type: 'LOAN', balance: 250000 },
];
renderAccounts();
expect(screen.getByText('Checking')).toBeInTheDocument();
expect(screen.getByText('Mortgage')).toBeInTheDocument();
expect(screen.getByText('My Bank')).toBeInTheDocument();
});
it('navigates to the account detail when a card is clicked', () => {
storeState.accounts = [
{ id: 'a1', name: 'Checking', type: 'CHECKING', balance: 100 },
];
renderAccounts();
fireEvent.click(screen.getByText('Checking'));
expect(mockNavigate).toHaveBeenCalledWith('/accounts/a1');
});
it('opens the delete confirmation when a card delete button is clicked', () => {
storeState.accounts = [
{ id: 'a1', name: 'Checking', type: 'CHECKING', balance: 100 },
];
renderAccounts();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
expect(screen.getByText(/permanently delete/i)).toBeInTheDocument();
});
it('creates a new account from the form', async () => {
createAccount.mockResolvedValue(undefined);
renderAccounts();
fireEvent.click(screen.getByRole('button', { name: /add account/i }));
fireEvent.change(screen.getByPlaceholderText('Account name'), {
target: { value: 'Travel fund' },
});
fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
await waitFor(() =>
expect(createAccount).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Travel fund' }),
),
);
});
});
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
const {
fetchActivities,
fetchAccounts,
fetchCategories,
activityState,
accountsState,
categoriesState,
} = vi.hoisted(() => ({
fetchActivities: vi.fn(),
fetchAccounts: vi.fn(),
fetchCategories: vi.fn(),
activityState: {
activities: [] as any[],
total: 0,
page: 1,
loading: false,
},
accountsState: { accounts: [] as any[] },
categoriesState: { categories: [] as any[] },
}));
vi.mock('@/stores/activity', () => ({
useActivityStore: () => ({
...activityState,
fetchActivities,
}),
}));
vi.mock('@/stores/accounts', () => ({
useAccountsStore: () => ({
...accountsState,
fetchAccounts,
}),
}));
vi.mock('@/stores/categories', () => ({
useCategoriesStore: () => ({
...categoriesState,
fetchCategories,
}),
}));
import { Activity } from './Activity';
const renderActivity = (initialEntry = '/activity') =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<Activity />
</MemoryRouter>,
);
describe('Activity page', () => {
beforeEach(() => {
vi.clearAllMocks();
activityState.activities = [];
activityState.total = 0;
activityState.page = 1;
activityState.loading = false;
accountsState.accounts = [
{ id: 'acc-1', name: 'Checking' },
{ id: 'acc-2', name: 'Savings' },
];
categoriesState.categories = [{ id: 'cat-1', name: 'Groceries' }];
});
it('fetches activities, accounts, and categories on mount', () => {
renderActivity();
expect(fetchActivities).toHaveBeenCalled();
expect(fetchAccounts).toHaveBeenCalled();
expect(fetchCategories).toHaveBeenCalled();
});
it('shows the empty-state message when there are no entries', () => {
renderActivity();
expect(screen.getByText(/no activity/i)).toBeInTheDocument();
});
it('renders an entry summary and the formatted timestamp', () => {
activityState.activities = [
{
id: 'log-1',
entityType: 'TRANSACTION',
entityId: 'tx-1',
action: 'CREATE',
accountId: 'acc-1',
destinationAccountId: null,
summary: '$50.00 EXPENSE — Coffee',
snapshot: { amount: 50, accountId: 'acc-1', type: 'EXPENSE' },
createdAt: '2026-04-15T12:00:00.000Z',
},
];
activityState.total = 1;
renderActivity();
expect(screen.getByText('$50.00 EXPENSE — Coffee')).toBeInTheDocument();
expect(screen.getByText('Checking')).toBeInTheDocument();
});
it('seeds the accountId filter from the ?accountId= deeplink', () => {
renderActivity('/activity?accountId=acc-2');
// First fetch call should carry the deeplinked accountId.
const [firstFilters] = fetchActivities.mock.calls[0];
expect(firstFilters).toEqual(expect.objectContaining({ accountId: 'acc-2' }));
});
it('expands the snapshot detail panel when a row is clicked', () => {
activityState.activities = [
{
id: 'log-acc',
entityType: 'ACCOUNT',
entityId: 'acc-x',
action: 'CREATE',
accountId: 'acc-x',
destinationAccountId: null,
summary: 'Side hustle',
snapshot: {
name: 'Side hustle',
type: 'SAVINGS',
balance: 0,
institution: 'My Bank',
},
createdAt: '2026-04-15T12:00:00.000Z',
},
];
activityState.total = 1;
renderActivity();
fireEvent.click(screen.getByText('Side hustle'));
expect(screen.getByText(/institution/i)).toBeInTheDocument();
expect(screen.getByText('My Bank')).toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const {
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
storeState,
} = vi.hoisted(() => ({
fetchCategories: vi.fn(),
createCategory: vi.fn(),
updateCategory: vi.fn(),
deleteCategory: vi.fn(),
storeState: { categories: [] as any[], loading: false },
}));
vi.mock('@/stores/categories', () => ({
useCategoriesStore: () => ({
...storeState,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
}),
}));
import { Categories } from './Categories';
describe('Categories page', () => {
beforeEach(() => {
vi.clearAllMocks();
storeState.categories = [];
storeState.loading = false;
});
it('fetches categories on mount and shows the empty-state message', () => {
render(<Categories />);
expect(fetchCategories).toHaveBeenCalled();
expect(
screen.getByText(/no categories yet/i),
).toBeInTheDocument();
});
it('shows a loading message while the first fetch is in flight', () => {
storeState.loading = true;
render(<Categories />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('renders the existing categories', () => {
storeState.categories = [
{ id: 'c1', name: 'Groceries', color: '#4CAF50' },
{ id: 'c2', name: 'Dining', color: '#FF9800' },
];
render(<Categories />);
expect(screen.getByText('Groceries')).toBeInTheDocument();
expect(screen.getByText('Dining')).toBeInTheDocument();
});
it('deletes a category when its trash button is clicked', () => {
storeState.categories = [{ id: 'c1', name: 'Groceries', color: '#4CAF50' }];
render(<Categories />);
// Two ghost buttons per row (edit + delete). Identify them by their position.
const buttons = screen.getAllByRole('button');
// Last button is delete on the only row (after Add Category).
fireEvent.click(buttons[buttons.length - 1]);
expect(deleteCategory).toHaveBeenCalledWith('c1');
});
it('creates a new category from the dialog form', async () => {
createCategory.mockResolvedValue(undefined);
render(<Categories />);
fireEvent.click(screen.getByRole('button', { name: /add category/i }));
fireEvent.change(screen.getByPlaceholderText(/category name/i), {
target: { value: 'Travel' },
});
fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
await waitFor(() =>
expect(createCategory).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Travel' }),
),
);
});
});
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
const {
fetchSummary,
fetchSpendingByCategory,
fetchCashFlow,
fetchTransactions,
startConversation,
sendMessage,
resetConversation,
aggregationsState,
txnState,
advisorState,
mockNavigate,
} = vi.hoisted(() => ({
fetchSummary: vi.fn(),
fetchSpendingByCategory: vi.fn(),
fetchCashFlow: vi.fn(),
fetchTransactions: vi.fn(),
startConversation: vi.fn(),
sendMessage: vi.fn(),
resetConversation: vi.fn(),
aggregationsState: {
summary: null as any,
spendingByCategory: [] as any[],
cashFlow: null as any,
},
txnState: { transactions: [] as any[] },
advisorState: { messages: [] as any[], loading: false },
mockNavigate: vi.fn(),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom',
);
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('@/stores/aggregations', () => ({
useAggregationsStore: () => ({
...aggregationsState,
fetchSummary,
fetchSpendingByCategory,
fetchCashFlow,
}),
}));
vi.mock('@/stores/transactions', () => ({
useTransactionsStore: () => ({
...txnState,
fetchTransactions,
}),
}));
vi.mock('@/stores/advisor', () => ({
useAdvisorStore: () => ({
...advisorState,
startConversation,
sendMessage,
resetConversation,
}),
}));
// Recharts ResponsiveContainer requires real layout dimensions in jsdom; stub
// it so the dashboard can render in tests.
vi.mock('recharts', async () => {
const actual = await vi.importActual<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) => (
<div data-testid="responsive">{children}</div>
),
};
});
import { Dashboard } from './Dashboard';
const renderDashboard = () =>
render(
<MemoryRouter>
<Dashboard />
</MemoryRouter>,
);
describe('Dashboard page', () => {
beforeEach(() => {
vi.clearAllMocks();
aggregationsState.summary = {
netWorth: 15000,
totalDebt: -500,
income: 5000,
expense: 3200,
};
aggregationsState.spendingByCategory = [];
aggregationsState.cashFlow = { inflows: 5000, outflows: 3500, net: 1500 };
txnState.transactions = [];
advisorState.messages = [];
advisorState.loading = false;
});
it('fetches summary, spending, cash flow, and recent transactions on mount', () => {
renderDashboard();
expect(fetchSummary).toHaveBeenCalled();
expect(fetchSpendingByCategory).toHaveBeenCalled();
expect(fetchCashFlow).toHaveBeenCalled();
expect(fetchTransactions).toHaveBeenCalled();
});
it('refetches with new dates when a different range is picked', () => {
renderDashboard();
fetchSummary.mockClear();
fireEvent.click(screen.getByRole('button', { name: /last 30 days/i }));
expect(fetchSummary).toHaveBeenCalled();
const [startDate, endDate] = fetchSummary.mock.calls[0];
// The "Last 30 days" range covers 30 inclusive days (29-day span).
const start = new Date(`${startDate}T00:00:00Z`).getTime();
const end = new Date(`${endDate}T00:00:00Z`).getTime();
const days = (end - start) / (1000 * 60 * 60 * 24);
expect(days).toBe(29);
});
it('renders the headline summary numbers', () => {
renderDashboard();
expect(screen.getByText(/\$15,000\.00/)).toBeInTheDocument();
// $5,000.00 shows up in Income and Cash Flow inflows; just confirm at least one render.
expect(screen.getAllByText(/\$5,000\.00/).length).toBeGreaterThan(0);
expect(screen.getByText(/\$3,200\.00/)).toBeInTheDocument();
});
it('starts a conversation with the current period when "Get Advice" is clicked', () => {
renderDashboard();
fireEvent.click(screen.getByRole('button', { name: /get advice/i }));
expect(startConversation).toHaveBeenCalledWith(
expect.objectContaining({
label: 'This month',
startDate: expect.any(String),
endDate: expect.any(String),
}),
);
});
it('renders the existing advisor messages and the follow-up form', () => {
advisorState.messages = [
{ role: 'assistant', content: 'You saved $1,000 this month.' },
];
renderDashboard();
expect(
screen.getByText(/you saved \$1,000 this month\./i),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/ask a follow-up/i),
).toBeInTheDocument();
});
it('renders the empty-state message when there are no transactions yet', () => {
renderDashboard();
expect(screen.getByText(/no transactions yet/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
const { mockNavigate, mockLogin, mockSetStayLoggedIn } = vi.hoisted(() => ({
mockNavigate: vi.fn(),
mockLogin: vi.fn(),
mockSetStayLoggedIn: vi.fn(),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom',
);
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
login: mockLogin,
loading: false,
stayLoggedIn: true,
setStayLoggedIn: mockSetStayLoggedIn,
}),
}));
import { Login } from './Login';
const renderLogin = () =>
render(
<MemoryRouter>
<Login />
</MemoryRouter>,
);
describe('Login page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the form', () => {
renderLogin();
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /sign in/i }),
).toBeInTheDocument();
});
it('submits credentials and navigates home on success', async () => {
mockLogin.mockResolvedValue(undefined);
renderLogin();
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'kevin@example.com' },
});
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'hunter2' },
});
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => expect(mockLogin).toHaveBeenCalledWith(
'kevin@example.com',
'hunter2',
));
expect(mockNavigate).toHaveBeenCalledWith('/');
});
it('shows the supabase error message and stays on the page on login failure', async () => {
mockLogin.mockRejectedValue(new Error('Invalid credentials'));
renderLogin();
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'wrong@example.com' },
});
fireEvent.change(screen.getByPlaceholderText('Password'), {
target: { value: 'bad' },
});
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText('Invalid credentials')).toBeInTheDocument();
expect(mockNavigate).not.toHaveBeenCalled();
});
it('toggles the "stay logged in" preference', () => {
renderLogin();
const checkbox = screen.getByLabelText(/stay logged in/i) as HTMLInputElement;
fireEvent.click(checkbox);
expect(mockSetStayLoggedIn).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
const { mockNavigate, mockSignup } = vi.hoisted(() => ({
mockNavigate: vi.fn(),
mockSignup: vi.fn(),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom',
);
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ signup: mockSignup, loading: false }),
}));
import { Signup } from './Signup';
const renderSignup = () =>
render(
<MemoryRouter>
<Signup />
</MemoryRouter>,
);
const fillField = (placeholder: string, value: string) => {
fireEvent.change(screen.getByPlaceholderText(placeholder), {
target: { value },
});
};
describe('Signup page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows a mismatched-password error without calling signup', async () => {
renderSignup();
fillField('Email', 'a@b.com');
fillField('Password', 'first-password');
fillField('Confirm Password', 'second-password');
fireEvent.click(screen.getByRole('button', { name: /sign up/i }));
expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument();
expect(mockSignup).not.toHaveBeenCalled();
});
it('signs the user up and navigates home when the passwords match', async () => {
mockSignup.mockResolvedValue(undefined);
renderSignup();
fillField('Email', 'new@example.com');
fillField('Password', 'matching-pwd');
fillField('Confirm Password', 'matching-pwd');
fireEvent.click(screen.getByRole('button', { name: /sign up/i }));
await waitFor(() =>
expect(mockSignup).toHaveBeenCalledWith(
'new@example.com',
'matching-pwd',
),
);
expect(mockNavigate).toHaveBeenCalledWith('/');
});
it('surfaces supabase errors when signup rejects', async () => {
mockSignup.mockRejectedValue(new Error('Email already in use'));
renderSignup();
fillField('Email', 'taken@example.com');
fillField('Password', 'whatever');
fillField('Confirm Password', 'whatever');
fireEvent.click(screen.getByRole('button', { name: /sign up/i }));
expect(await screen.findByText('Email already in use')).toBeInTheDocument();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
const {
fetchTransactions,
createTransaction,
updateTransaction,
deleteTransaction,
fetchAccounts,
fetchCategories,
txnState,
accountsState,
categoriesState,
} = vi.hoisted(() => ({
fetchTransactions: vi.fn(),
createTransaction: vi.fn(),
updateTransaction: vi.fn(),
deleteTransaction: vi.fn(),
fetchAccounts: vi.fn(),
fetchCategories: vi.fn(),
txnState: {
transactions: [] as any[],
total: 0,
page: 1,
loading: false,
},
accountsState: { accounts: [] as any[] },
categoriesState: { categories: [] as any[] },
}));
vi.mock('@/stores/transactions', () => ({
useTransactionsStore: () => ({
...txnState,
fetchTransactions,
createTransaction,
updateTransaction,
deleteTransaction,
}),
}));
vi.mock('@/stores/accounts', () => ({
useAccountsStore: () => ({
...accountsState,
fetchAccounts,
}),
}));
vi.mock('@/stores/categories', () => ({
useCategoriesStore: () => ({
...categoriesState,
fetchCategories,
}),
}));
import { Transactions } from './Transactions';
const renderTxns = (initialEntry = '/transactions') =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<Transactions />
</MemoryRouter>,
);
describe('Transactions page', () => {
beforeEach(() => {
vi.clearAllMocks();
txnState.transactions = [];
txnState.total = 0;
txnState.page = 1;
txnState.loading = false;
accountsState.accounts = [
{ id: 'acc-1', name: 'Checking', type: 'CHECKING', balance: 1000 },
];
categoriesState.categories = [{ id: 'cat-1', name: 'Groceries' }];
});
it('fetches dependencies on mount', () => {
renderTxns();
expect(fetchTransactions).toHaveBeenCalled();
expect(fetchAccounts).toHaveBeenCalled();
expect(fetchCategories).toHaveBeenCalled();
});
it('shows the empty-state copy when there are no transactions', () => {
renderTxns();
expect(screen.getByText(/no transactions/i)).toBeInTheDocument();
});
it('renders rows for existing transactions', () => {
txnState.transactions = [
{
id: 't1',
description: 'Coffee',
amount: 5,
type: 'EXPENSE',
accountId: 'acc-1',
date: '2026-04-15',
category: { id: 'cat-1', name: 'Groceries', color: '#4CAF50' },
account: { id: 'acc-1', name: 'Checking', type: 'CHECKING' },
},
];
txnState.total = 1;
renderTxns();
expect(screen.getByText('Coffee')).toBeInTheDocument();
// Amount cell shows the dollar value
expect(screen.getByText(/\$5/)).toBeInTheDocument();
});
it('seeds the categoryId filter from a ?categoryId= deeplink', () => {
renderTxns('/transactions?categoryId=cat-1');
const [firstFilters] = fetchTransactions.mock.calls[0];
expect(firstFilters).toEqual(
expect.objectContaining({ categoryId: 'cat-1' }),
);
});
it('opens the new-transaction dialog when ?new=1 is in the URL', () => {
renderTxns('/transactions?new=1');
// The dialog title shows when open.
expect(screen.getByText(/new transaction/i)).toBeInTheDocument();
});
it('opens the delete confirmation when a transaction trash button is clicked', () => {
txnState.transactions = [
{
id: 't1',
description: 'Coffee',
amount: 5,
type: 'EXPENSE',
accountId: 'acc-1',
date: '2026-04-15',
account: { id: 'acc-1', name: 'Checking', type: 'CHECKING' },
},
];
txnState.total = 1;
renderTxns();
fireEvent.click(screen.getByLabelText(/delete transaction/i));
expect(screen.getByText(/delete transaction\?/i)).toBeInTheDocument();
expect(
screen.getByText(/permanently delete the expense/i),
).toBeInTheDocument();
});
});