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
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound";
import { Landing } from "./pages/Landing";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import Account from "./pages/Account";
import SavingsGoals from "./pages/SavingsGoals";

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

// Mock UI components
jest.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => <span>{children}</span>,
}));
jest.mock('@/components/ui/progress', () => ({
Progress: ({ value }: { value: number }) => (
<div role="progressbar" aria-valuenow={value} />
),
}));
jest.mock('@/components/ui/financial-card', () => ({
FinancialCard: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardFooter: ({ children }: React.PropsWithChildren) => <div>{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>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTrigger: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));
jest.mock('@/components/ui/alert-dailog', () => ({
AlertDialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
AlertDialogTrigger: ({ children }: React.PropsWithChildren) => <>{children}</>,
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>
),
}));

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

const listSavingsGoalsMock = jest.fn();
const getSavingsSummaryMock = jest.fn();
const createSavingsGoalMock = jest.fn();
const deleteSavingsGoalMock = jest.fn();

jest.mock('@/api/savings', () => ({
listSavingsGoals: (...args: unknown[]) => listSavingsGoalsMock(...args),
getSavingsSummary: (...args: unknown[]) => getSavingsSummaryMock(...args),
createSavingsGoal: (...args: unknown[]) => createSavingsGoalMock(...args),
updateSavingsGoal: jest.fn(),
deleteSavingsGoal: (...args: unknown[]) => deleteSavingsGoalMock(...args),
addContribution: jest.fn(),
withdrawFromGoal: jest.fn(),
getSavingsGoal: jest.fn(),
}));

jest.mock('@/lib/currency', () => ({
formatMoney: (amount: number) => `$${amount.toFixed(2)}`,
}));

const mockSummary = {
total_goals: 2,
active_goals: 2,
completed_goals: 0,
total_saved: 2500,
total_target: 15000,
overall_progress_pct: 16.7,
};

const mockGoals = [
{
id: 1,
name: 'Emergency Fund',
description: '6 months of expenses',
target_amount: 10000,
current_amount: 2000,
currency: 'USD',
target_date: '2027-01-01',
icon: 'shield',
color: '#22c55e',
status: 'ACTIVE',
progress_pct: 20,
remaining: 8000,
days_remaining: 280,
monthly_needed: 857.14,
},
{
id: 2,
name: 'Vacation',
description: 'Summer trip',
target_amount: 5000,
current_amount: 500,
currency: 'USD',
target_date: null,
icon: 'piggy-bank',
color: '#6366f1',
status: 'ACTIVE',
progress_pct: 10,
remaining: 4500,
},
];

describe('SavingsGoals integration', () => {
beforeEach(() => {
jest.clearAllMocks();
listSavingsGoalsMock.mockResolvedValue(mockGoals);
getSavingsSummaryMock.mockResolvedValue(mockSummary);
});

it('loads and renders savings goals and summary', async () => {
render(<SavingsGoals />);
await waitFor(() => expect(listSavingsGoalsMock).toHaveBeenCalled());
expect(screen.getByText('Savings Goals')).toBeInTheDocument();
expect(screen.getByText('Emergency Fund')).toBeInTheDocument();
expect(screen.getByText('Vacation')).toBeInTheDocument();
expect(screen.getByText('Total Saved')).toBeInTheDocument();
});

it('shows empty state when no goals exist', async () => {
listSavingsGoalsMock.mockResolvedValue([]);
render(<SavingsGoals />);
await waitFor(() => expect(listSavingsGoalsMock).toHaveBeenCalled());
expect(screen.getByText('No savings goals yet')).toBeInTheDocument();
});

it('renders progress percentages', async () => {
render(<SavingsGoals />);
await waitFor(() => expect(listSavingsGoalsMock).toHaveBeenCalled());
expect(screen.getByText('20% complete')).toBeInTheDocument();
expect(screen.getByText('10% complete')).toBeInTheDocument();
});

it('renders target date and monthly needed', async () => {
render(<SavingsGoals />);
await waitFor(() => expect(listSavingsGoalsMock).toHaveBeenCalled());
expect(screen.getByText(/280 days left/)).toBeInTheDocument();
expect(screen.getByText(/\$857\.14/)).toBeInTheDocument();
});

it('filters goals by status', async () => {
render(<SavingsGoals />);
await waitFor(() => expect(listSavingsGoalsMock).toHaveBeenCalledWith('ACTIVE'));

const allButton = screen.getByRole('button', { name: /all goals/i });
await userEvent.click(allButton);
await waitFor(() => expect(listSavingsGoalsMock).toHaveBeenCalledWith('ALL'));
});
});
112 changes: 112 additions & 0 deletions app/src/api/savings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { api } from './client';

export type SavingsGoalStatus = 'ACTIVE' | 'COMPLETED' | 'CANCELLED';

export type SavingsGoal = {
id: number;
name: string;
description?: string | null;
target_amount: number;
current_amount: number;
currency: string;
target_date?: string | null;
icon: string;
color: string;
status: SavingsGoalStatus;
progress_pct: number;
remaining: number;
days_remaining?: number;
monthly_needed?: number;
created_at?: string;
updated_at?: string;
contributions?: SavingsContribution[];
};

export type SavingsContribution = {
id: number;
goal_id: number;
amount: number;
note?: string | null;
contributed_at?: string;
created_at?: string;
};

export type SavingsGoalCreate = {
name: string;
description?: string;
target_amount: number;
current_amount?: number;
currency?: string;
target_date?: string;
icon?: string;
color?: string;
};

export type SavingsGoalUpdate = Partial<SavingsGoalCreate> & {
status?: SavingsGoalStatus;
};

export type SavingsSummary = {
total_goals: number;
active_goals: number;
completed_goals: number;
total_saved: number;
total_target: number;
overall_progress_pct: number;
};

export async function listSavingsGoals(
status?: string,
): Promise<SavingsGoal[]> {
const qs = new URLSearchParams();
if (status) qs.set('status', status);
const path = '/savings' + (qs.toString() ? `?${qs.toString()}` : '');
return api<SavingsGoal[]>(path);
}

export async function getSavingsGoal(id: number): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/${id}`);
}

export async function createSavingsGoal(
payload: SavingsGoalCreate,
): Promise<SavingsGoal> {
return api<SavingsGoal>('/savings', { method: 'POST', body: payload });
}

export async function updateSavingsGoal(
id: number,
payload: SavingsGoalUpdate,
): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/${id}`, { method: 'PATCH', body: payload });
}

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

export async function addContribution(
goalId: number,
payload: { amount: number; note?: string; contributed_at?: string },
): Promise<{ contribution: SavingsContribution; goal: SavingsGoal }> {
return api(`/savings/${goalId}/contributions`, {
method: 'POST',
body: payload,
});
}

export async function withdrawFromGoal(
goalId: number,
payload: { amount: number; note?: string },
): Promise<{ contribution: SavingsContribution; goal: SavingsGoal }> {
return api(`/savings/${goalId}/withdraw`, {
method: 'POST',
body: payload,
});
}

export async function getSavingsSummary(): Promise<SavingsSummary> {
return api<SavingsSummary>('/savings/summary');
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logout as logoutApi } from '@/api/auth';
const navigation = [
{ name: 'Dashboard', href: '/dashboard' },
{ name: 'Budgets', href: '/budgets' },
{ name: 'Savings', href: '/savings' },
{ name: 'Bills', href: '/bills' },
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
Expand Down
Loading