Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/components/dashboard/__tests__/PerformanceChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { PerformanceChart } from '@/components/dashboard/PerformanceChart';

jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
AnimatePresence: ({ children }: any) => <>{children}</>,
}));

// Recharts uses ResizeObserver
global.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};

describe('PerformanceChart', () => {
it('renders the title and subtitle', () => {
render(<PerformanceChart />);
expect(screen.getByText('Portfolio Performance')).toBeInTheDocument();
expect(
screen.getByText('Track your investment growth over time'),
).toBeInTheDocument();
});

it('renders all timeframe buttons with 1Y active by default', () => {
render(<PerformanceChart />);

for (const label of ['7D', '30D', '90D', '1Y', 'All']) {
expect(screen.getByRole('button', { name: label })).toBeInTheDocument();
}

expect(screen.getByRole('button', { name: '1Y' })).toHaveClass('bg-primary');
});

it('renders a chart with a descriptive aria-label for the active timeframe', () => {
const { container } = render(<PerformanceChart />);
const chartEl = container.querySelector('[role="img"][aria-label]');
expect(chartEl).not.toBeNull();
expect(chartEl!.getAttribute('aria-label')).toMatch(/portfolio performance over 1Y/i);
});

it('includes the latest derived value in the chart aria-label', () => {
const { container } = render(<PerformanceChart />);
const chartEl = container.querySelector('[role="img"][aria-label]');
expect(chartEl!.getAttribute('aria-label')).toMatch(/latest value: \$/i);
});

it('switches the active timeframe and re-derives the chart data', () => {
const { container } = render(<PerformanceChart />);

fireEvent.click(screen.getByRole('button', { name: '7D' }));

const chartEl = container.querySelector('[role="img"][aria-label]');
expect(chartEl!.getAttribute('aria-label')).toMatch(/portfolio performance over 7D/i);
expect(screen.getByRole('button', { name: '7D' })).toHaveClass('bg-primary');
});

it('renders the legend with actual and projected labels', () => {
render(<PerformanceChart />);
expect(screen.getByText('Actual Value')).toBeInTheDocument();
expect(screen.getByText('Projected')).toBeInTheDocument();
});
});
33 changes: 33 additions & 0 deletions src/components/dashboard/__tests__/PortfolioOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,37 @@ describe('PortfolioOverview', () => {
const { container } = render(<PortfolioOverview />);
expect(container.innerHTML).toContain('empty-state');
});

it('shows the error state when the portfolio load reports an error', () => {
mockUsePortfolioOverview.mockReturnValue({
portfolio: {
totalValueUSD: 50000,
chains: [{ holdings: [{ apy: 6 }] }],
error: 'Failed to fetch portfolio',
},
isLoading: false,
error: null,
refresh: jest.fn(),
});

render(<PortfolioOverview />);
expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
expect(screen.getByText('Error loading')).toBeInTheDocument();
});

it('shows the empty state when the portfolio has no holdings', () => {
mockUsePortfolioOverview.mockReturnValue({
portfolio: {
totalValueUSD: 0,
chains: [],
},
isLoading: false,
error: null,
refresh: jest.fn(),
});

render(<PortfolioOverview />);
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
expect(screen.getByText('No investments yet')).toBeInTheDocument();
});
});
180 changes: 180 additions & 0 deletions src/store/__tests__/paperTradingStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { act, renderHook } from '@testing-library/react';
import { usePaperTradingStore } from '../paperTradingStore';

describe('paperTradingStore', () => {
beforeEach(() => {
usePaperTradingStore.getState().resetPortfolio();
usePaperTradingStore.setState({ isPaperMode: false, leaderboard: [] });
});

it('starts with a default virtual balance and no positions', () => {
const { result } = renderHook(() => usePaperTradingStore());
expect(result.current.isPaperMode).toBe(false);
expect(result.current.virtualBalance).toBe(10000);
expect(result.current.positions).toEqual([]);
expect(result.current.transactions).toEqual([]);
});

it('toggles paper mode on and off', () => {
const { result } = renderHook(() => usePaperTradingStore());

act(() => result.current.togglePaperMode());
expect(result.current.isPaperMode).toBe(true);

act(() => result.current.enablePaperMode());
expect(result.current.isPaperMode).toBe(true);

act(() => result.current.disablePaperMode());
expect(result.current.isPaperMode).toBe(false);
});

it('buys tokens and records the transaction', () => {
const { result } = renderHook(() => usePaperTradingStore());

let outcome: { success: boolean; error?: string } | undefined;
act(() => {
outcome = result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 100);
});

expect(outcome?.success).toBe(true);
expect(result.current.virtualBalance).toBe(9000);
expect(result.current.positions).toHaveLength(1);
expect(result.current.positions[0].propertyId).toBe('prop-1');
expect(result.current.positions[0].tokensBought).toBe(10);
expect(result.current.positions[0].avgBuyPrice).toBe(100);
expect(result.current.transactions).toHaveLength(1);
expect(result.current.transactions[0].type).toBe('buy');
expect(result.current.transactions[0].total).toBe(1000);
});

it('rejects a buy when the cost exceeds the virtual balance', () => {
const { result } = renderHook(() => usePaperTradingStore());

let outcome: { success: boolean; error?: string } | undefined;
act(() => {
outcome = result.current.buyTokens('prop-1', 'Manhattan Apt', 200, 100);
});

expect(outcome?.success).toBe(false);
expect(outcome?.error).toBe('Insufficient virtual balance');
expect(result.current.positions).toEqual([]);
expect(result.current.virtualBalance).toBe(10000);
});

it('rejects non-positive token amounts', () => {
const { result } = renderHook(() => usePaperTradingStore());

let outcome: { success: boolean; error?: string } | undefined;
act(() => {
outcome = result.current.buyTokens('prop-1', 'Manhattan Apt', 0, 100);
});

expect(outcome?.success).toBe(false);
expect(outcome?.error).toBe('Token amount must be positive');
});

it('averages the buy price when adding to an existing position', () => {
const { result } = renderHook(() => usePaperTradingStore());

act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 100);
});
act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 200);
});

const position = result.current.positions[0];
expect(position.tokensBought).toBe(20);
expect(position.avgBuyPrice).toBe(150); // (100*10 + 200*10) / 20
expect(result.current.virtualBalance).toBe(7000); // 10000 - 1000 - 2000
});

it('sells part of a position and credits the proceeds', () => {
const { result } = renderHook(() => usePaperTradingStore());

act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 100);
});
let outcome: { success: boolean; error?: string } | undefined;
act(() => {
outcome = result.current.sellTokens('prop-1', 4, 150);
});

expect(outcome?.success).toBe(true);
expect(result.current.positions[0].tokensBought).toBe(6);
expect(result.current.virtualBalance).toBe(9600); // 9000 + 600
expect(result.current.transactions[0].type).toBe('sell');
});

it('closes the position entirely when selling all tokens', () => {
const { result } = renderHook(() => usePaperTradingStore());

act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 100);
});
act(() => {
result.current.sellTokens('prop-1', 10, 150);
});

expect(result.current.positions).toEqual([]);
expect(result.current.virtualBalance).toBe(10500); // 9000 + 1500
});

it('rejects selling without a position or more tokens than owned', () => {
const { result } = renderHook(() => usePaperTradingStore());

let noPosition: { success: boolean; error?: string } | undefined;
act(() => {
noPosition = result.current.sellTokens('prop-9', 1, 100);
});
expect(noPosition?.error).toBe('No position found for this property');

act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 5, 100);
});
let tooMany: { success: boolean; error?: string } | undefined;
act(() => {
tooMany = result.current.sellTokens('prop-1', 6, 100);
});
expect(tooMany?.error).toBe('Cannot sell more tokens than owned');
});

it('updates the current price of a position', () => {
const { result } = renderHook(() => usePaperTradingStore());

act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 100);
result.current.updatePrice('prop-1', 120);
});

expect(result.current.positions[0].currentPrice).toBe(120);
});

it('computes portfolio value, total return and position P&L', () => {
const { result } = renderHook(() => usePaperTradingStore());

act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 100);
result.current.updatePrice('prop-1', 120);
});

expect(result.current.getPortfolioValue()).toBe(1200);
expect(result.current.getPositionPnL('prop-1')).toBe(200); // (120 - 100) * 10
expect(result.current.getPositionPnL('prop-9')).toBe(0);
// Total = 9000 balance + 1200 position = 10200 → 2% return over 10000
expect(result.current.getTotalReturn()).toBe(2);
});

it('resetPortfolio restores the starting balance and clears positions', () => {
const { result } = renderHook(() => usePaperTradingStore());

act(() => {
result.current.buyTokens('prop-1', 'Manhattan Apt', 10, 100);
result.current.resetPortfolio();
});

expect(result.current.virtualBalance).toBe(10000);
expect(result.current.positions).toEqual([]);
expect(result.current.transactions).toEqual([]);
});
});
102 changes: 102 additions & 0 deletions src/store/__tests__/recentlyViewedStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { act, renderHook } from '@testing-library/react';
import { useRecentlyViewedStore } from '../recentlyViewedStore';

const mockProperty = (id: string) => ({
id,
name: `Property ${id}`,
location: 'Testville, TS',
price: 100000,
image: `/properties/${id}.jpg`,
});

describe('recentlyViewedStore', () => {
beforeEach(() => {
localStorage.clear();
useRecentlyViewedStore.getState().clearHistory();
});

it('starts with an empty history', () => {
const { result } = renderHook(() => useRecentlyViewedStore());
expect(result.current.getProperties()).toEqual([]);
});

it('adds a property with a viewedAt timestamp', () => {
const { result } = renderHook(() => useRecentlyViewedStore());

act(() => {
result.current.addProperty(mockProperty('prop-1'));
});

const properties = result.current.getProperties();
expect(properties).toHaveLength(1);
expect(properties[0].id).toBe('prop-1');
expect(properties[0].viewedAt).toBeGreaterThan(0);
});

it('deduplicates properties by id and moves the re-viewed one to the front', () => {
const { result } = renderHook(() => useRecentlyViewedStore());

act(() => {
result.current.addProperty(mockProperty('prop-1'));
result.current.addProperty(mockProperty('prop-2'));
result.current.addProperty(mockProperty('prop-1'));
});

const properties = result.current.getProperties();
expect(properties).toHaveLength(2);
expect(properties[0].id).toBe('prop-1'); // most recent first
expect(properties[1].id).toBe('prop-2');
});

it('caps the history at 10 entries', () => {
const { result } = renderHook(() => useRecentlyViewedStore());

act(() => {
for (let i = 1; i <= 12; i++) {
result.current.addProperty(mockProperty(`prop-${i}`));
}
});

const properties = result.current.getProperties();
expect(properties).toHaveLength(10);
expect(properties[0].id).toBe('prop-12');
expect(properties[9].id).toBe('prop-3');
});

it('removes a single property', () => {
const { result } = renderHook(() => useRecentlyViewedStore());

act(() => {
result.current.addProperty(mockProperty('prop-1'));
result.current.addProperty(mockProperty('prop-2'));
result.current.removeProperty('prop-1');
});

const properties = result.current.getProperties();
expect(properties).toHaveLength(1);
expect(properties[0].id).toBe('prop-2');
});

it('clearHistory empties the list', () => {
const { result } = renderHook(() => useRecentlyViewedStore());

act(() => {
result.current.addProperty(mockProperty('prop-1'));
result.current.clearHistory();
});

expect(result.current.getProperties()).toEqual([]);
});

it('persists the history across store instances', () => {
const { result } = renderHook(() => useRecentlyViewedStore());

act(() => {
result.current.addProperty(mockProperty('prop-1'));
});

const { result: result2 } = renderHook(() => useRecentlyViewedStore());
expect(result2.current.getProperties()).toHaveLength(1);
expect(result2.current.getProperties()[0].id).toBe('prop-1');
});
});
Loading