Skip to content
Open
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
24 changes: 24 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import NotFound from "./pages/NotFound";
import { Landing } from "./pages/Landing";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import Account from "./pages/Account";
<<<<<<< Updated upstream
=======
import SavingsGoals from "./pages/SavingsGoals";
import AccountsOverview from "./pages/AccountsOverview";
>>>>>>> Stashed changes

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -84,6 +89,25 @@ const App = () => (
}
/>
<Route
<<<<<<< Updated upstream
=======
path="savings"
element={
<ProtectedRoute>
<SavingsGoals />
</ProtectedRoute>
}
/>
<Route
path="accounts"
element={
<ProtectedRoute>
<AccountsOverview />
</ProtectedRoute>
}
/>
<Route
>>>>>>> Stashed changes
path="account"
element={
<ProtectedRoute>
Expand Down
178 changes: 178 additions & 0 deletions app/src/__tests__/AccountsOverview.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AccountsOverview from '@/pages/AccountsOverview';

const toastMock = jest.fn();
jest.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: toastMock }),
}));

jest.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes<HTMLSpanElement>) => (
<span {...props}>{children}</span>
),
}));
jest.mock('@/components/ui/progress', () => ({
Progress: ({ value, ...props }: { value?: number } & React.HTMLAttributes<HTMLDivElement>) => (
<div role="progressbar" aria-valuenow={value} {...props} />
),
}));
jest.mock('@/components/ui/financial-card', () => ({
FinancialCard: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
FinancialCardContent: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
FinancialCardDescription: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
FinancialCardHeader: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
FinancialCardTitle: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
}));
jest.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: React.PropsWithChildren & { open?: boolean }) => open ? <div>{children}</div> : null,
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTrigger: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
}));
jest.mock('@/components/ui/alert-dailog', () => ({
AlertDialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogTrigger: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogCancel: ({ children }: React.PropsWithChildren) => <button>{children}</button>,
AlertDialogAction: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
}));
jest.mock('@/lib/currency', () => ({
formatMoney: (amount: number) => `$${Number(amount || 0).toFixed(2)}`,
}));

const listAccountsMock = jest.fn();
const createAccountMock = jest.fn();
const updateAccountMock = jest.fn();
const deleteAccountMock = jest.fn();
const getAccountsOverviewMock = jest.fn();
jest.mock('@/api/accounts', () => ({
listAccounts: (...args: unknown[]) => listAccountsMock(...args),
createAccount: (...args: unknown[]) => createAccountMock(...args),
updateAccount: (...args: unknown[]) => updateAccountMock(...args),
deleteAccount: (...args: unknown[]) => deleteAccountMock(...args),
getAccountsOverview: (...args: unknown[]) => getAccountsOverviewMock(...args),
}));

const sampleAccount = {
id: 1,
name: 'Main Checking',
account_type: 'BANK',
institution: 'Chase',
balance: 5000,
currency: 'USD',
color: '#3B82F6',
active: true,
created_at: '2025-01-01T00:00:00',
};

const sampleOverview = {
total_balance: 15000,
net_worth: 13000,
total_assets: 15000,
total_liabilities: 2000,
account_count: 2,
type_breakdown: [
{
type: 'BANK',
total: 13000,
count: 1,
accounts: [sampleAccount],
},
{
type: 'CREDIT',
total: 2000,
count: 1,
accounts: [{
...sampleAccount,
id: 2,
name: 'Visa Card',
account_type: 'CREDIT',
balance: 2000,
color: '#EF4444',
}],
},
],
};

describe('AccountsOverview integration', () => {
beforeEach(() => {
jest.clearAllMocks();
listAccountsMock.mockResolvedValue([sampleAccount]);
getAccountsOverviewMock.mockResolvedValue(sampleOverview);
deleteAccountMock.mockResolvedValue({ message: 'deleted' });
});

it('renders accounts list with balance', async () => {
render(<AccountsOverview />);
await waitFor(() => expect(listAccountsMock).toHaveBeenCalled());

expect(await screen.findByText('Main Checking')).toBeInTheDocument();
expect(screen.getByText('$5000.00')).toBeInTheDocument();
});

it('shows empty state when no accounts', async () => {
listAccountsMock.mockResolvedValue([]);
getAccountsOverviewMock.mockResolvedValue({
total_balance: 0,
net_worth: 0,
total_assets: 0,
total_liabilities: 0,
account_count: 0,
type_breakdown: [],
});
render(<AccountsOverview />);
await waitFor(() => expect(listAccountsMock).toHaveBeenCalled());

expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
expect(screen.getByText(/add your first account/i)).toBeInTheDocument();
});

it('shows net worth and summary stats', async () => {
render(<AccountsOverview />);
await waitFor(() => expect(getAccountsOverviewMock).toHaveBeenCalled());

// Net worth
expect(await screen.findByText('$13000.00')).toBeInTheDocument();
// Total assets
expect(screen.getByText('$15000.00')).toBeInTheDocument();
// Total liabilities
expect(screen.getByText('$2000.00')).toBeInTheDocument();
});

it('shows type breakdown chart', async () => {
render(<AccountsOverview />);
await waitFor(() => expect(getAccountsOverviewMock).toHaveBeenCalled());

// Progress bars in breakdown
const progressBars = await screen.findAllByRole('progressbar');
expect(progressBars.length).toBeGreaterThan(0);
});

it('deletes an account when confirmed', async () => {
listAccountsMock.mockResolvedValue([sampleAccount]);
render(<AccountsOverview />);
await waitFor(() => expect(listAccountsMock).toHaveBeenCalled());
await screen.findByText('Main Checking');

// Click the delete confirmation button
const deleteButtons = screen.getAllByText('Delete');
await userEvent.click(deleteButtons[deleteButtons.length - 1]);

await waitFor(() => expect(deleteAccountMock).toHaveBeenCalledWith(1));
expect(toastMock).toHaveBeenCalledWith({ title: 'Account deleted' });
});
});
71 changes: 71 additions & 0 deletions app/src/api/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { api } from './client';

export type AccountType = 'BANK' | 'CREDIT' | 'INVESTMENT' | 'CASH';

export type Account = {
id: number;
name: string;
account_type: AccountType;
institution: string | null;
balance: number;
currency: string;
color: string;
active: boolean;
created_at: string | null;
};

export type AccountCreate = {
name: string;
account_type?: AccountType;
institution?: string;
balance?: number;
currency?: string;
color?: string;
};

export type AccountUpdate = Partial<AccountCreate> & { active?: boolean };

export type TypeBreakdown = {
type: AccountType;
total: number;
count: number;
accounts: Account[];
};

export type AccountsOverview = {
total_balance: number;
net_worth: number;
total_assets: number;
total_liabilities: number;
account_count: number;
type_breakdown: TypeBreakdown[];
};

export async function listAccounts(params?: {
include_inactive?: boolean;
}): Promise<Account[]> {
const qs = new URLSearchParams();
if (params?.include_inactive) qs.set('include_inactive', 'true');
const path = '/accounts' + (qs.toString() ? `?${qs.toString()}` : '');
return api<Account[]>(path);
}

export async function getAccount(id: number): Promise<Account> {
return api<Account>(`/accounts/${id}`);
}

export async function createAccount(payload: AccountCreate): Promise<Account> {
return api<Account>('/accounts', { method: 'POST', body: payload });
}

export async function updateAccount(id: number, payload: AccountUpdate): Promise<Account> {
return api<Account>(`/accounts/${id}`, { method: 'PATCH', body: payload });
}

export async function deleteAccount(id: number): Promise<{ message: string }> {
return api<{ message: string }>(`/accounts/${id}`, { method: 'DELETE' });
}

export async function getAccountsOverview(): Promise<AccountsOverview> {
return api<AccountsOverview>('/accounts/overview');
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { logout as logoutApi } from '@/api/auth';

const navigation = [
{ name: 'Dashboard', href: '/dashboard' },
{ name: 'Accounts', href: '/accounts' },
{ name: 'Budgets', href: '/budgets' },
{ name: 'Bills', href: '/bills' },
{ name: 'Reminders', href: '/reminders' },
Expand Down
Loading