Add page and component tests, lifting frontend coverage to ~78%
Tests / test (push) Failing after 22s
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user