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
56 changes: 56 additions & 0 deletions src/components/dashboard/__tests__/IncomeTracker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IncomeTracker } from '@/components/dashboard/IncomeTracker';

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('IncomeTracker', () => {
it('renders the section title and subtitle', () => {
render(<IncomeTracker />);
expect(screen.getByText('Rental Income')).toBeInTheDocument();
expect(
screen.getByText('Monthly income from all properties'),
).toBeInTheDocument();
});

it('aggregates and renders the latest month income', () => {
render(<IncomeTracker />);
// Latest month (Jan) income is 18240 from the static fixture.
expect(screen.getByText('$18,240')).toBeInTheDocument();
});

it('aggregates and renders the 6-month average income', () => {
render(<IncomeTracker />);
// (15200 + 16800 + 17500 + 16200 + 18900 + 18240) / 6 = 17140
expect(screen.getByText('$17,140')).toBeInTheDocument();
});

it('renders the month-over-month change percentage', () => {
render(<IncomeTracker />);
// ((18240 - 18900) / 18900) * 100 = -3.5 (rounded to one decimal)
expect(screen.getByText(/-3\.5%/)).toBeInTheDocument();
});

it('renders the chart legend with actual and projected labels', () => {
render(<IncomeTracker />);
expect(screen.getByText('Actual Income')).toBeInTheDocument();
expect(screen.getByText('Projected')).toBeInTheDocument();
});

it('renders a chart container with the income bar chart', () => {
const { container } = render(<IncomeTracker />);
expect(container.querySelector('.recharts-responsive-container')).not.toBeNull();
});
});
100 changes: 100 additions & 0 deletions src/hooks/__tests__/usePerformanceMonitoring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { renderHook, act } from '@testing-library/react';
import { usePerformanceMonitoring, resetPerformanceMonitoring } from '../usePerformanceMonitoring';

const mockCleanup = jest.fn();
const mockSetupPerformanceMonitoring = jest.fn(() => mockCleanup);

jest.mock('@/lib/mobile-optimizer', () => ({
setupPerformanceMonitoring: (...args: unknown[]) =>
mockSetupPerformanceMonitoring(...args),
}));

const sampleMetrics = {
fcp: 120,
lcp: 350,
cls: 0.02,
fid: 45,
tti: 800,
tbt: 90,
jsSize: 120000,
cssSize: 30000,
imageSize: 40000,
totalSize: 190000,
connectionType: 'wifi',
effectiveType: '4g',
downlink: 10,
rtt: 25,
};

describe('usePerformanceMonitoring', () => {
beforeEach(() => {
jest.clearAllMocks();
resetPerformanceMonitoring();
});

it('starts performance monitoring on mount', () => {
renderHook(() => usePerformanceMonitoring(jest.fn()));
expect(mockSetupPerformanceMonitoring).toHaveBeenCalledTimes(1);
expect(typeof mockSetupPerformanceMonitoring.mock.calls[0][0]).toBe('function');
});

it('reports captured metrics through the callback', () => {
const onMetrics = jest.fn();
renderHook(() => usePerformanceMonitoring(onMetrics));

const emit = mockSetupPerformanceMonitoring.mock.calls[0][0];
act(() => {
emit(sampleMetrics);
});

expect(onMetrics).toHaveBeenCalledWith(sampleMetrics);
});

it('cleans up monitoring on unmount', () => {
const { unmount } = renderHook(() => usePerformanceMonitoring(jest.fn()));
unmount();

expect(mockCleanup).toHaveBeenCalled();
});

it('cleans up the previous monitor when a new one is mounted', () => {
const first = renderHook(() => usePerformanceMonitoring(jest.fn()));
const second = renderHook(() => usePerformanceMonitoring(jest.fn()));

expect(first.result.current).toBeDefined();
expect(second.result.current).toBeDefined();

// Mounting the second monitor cleans up the first setup.
expect(mockCleanup).toHaveBeenCalledTimes(1);
expect(mockSetupPerformanceMonitoring).toHaveBeenCalledTimes(2);
});

it('resetPerformanceMonitoring tears down the active monitor', () => {
renderHook(() => usePerformanceMonitoring(jest.fn()));

act(() => {
resetPerformanceMonitoring();
});

expect(mockCleanup).toHaveBeenCalled();
});

it('always reports through the latest callback reference', () => {
const firstCallback = jest.fn();
const secondCallback = jest.fn();
const { rerender } = renderHook(
({ cb }) => usePerformanceMonitoring(cb),
{ initialProps: { cb: firstCallback } },
);

rerender({ cb: secondCallback });

const emit = mockSetupPerformanceMonitoring.mock.calls[0][0];
act(() => {
emit(sampleMetrics);
});

expect(firstCallback).not.toHaveBeenCalled();
expect(secondCallback).toHaveBeenCalledWith(sampleMetrics);
});
});
95 changes: 95 additions & 0 deletions src/hooks/__tests__/useSafeInfo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useSafeInfo } from '../useSafeInfo';

const mockGetBytecode = jest.fn();
const mockSafeCreate = jest.fn();

jest.mock('viem', () => ({
createPublicClient: jest.fn(() => ({ getBytecode: (...args: unknown[]) => mockGetBytecode(...args) })),
http: jest.fn(),
}));

jest.mock('viem/chains', () => ({
mainnet: {},
}));

jest.mock('ethers', () => ({
providers: {
Web3Provider: jest.fn(() => ({ getSigner: () => ({}) })),
},
}));

jest.mock('@safe-global/protocol-kit', () => ({
EthersAdapter: jest.fn(() => ({})),
default: { create: (...args: unknown[]) => mockSafeCreate(...args) },
}));

jest.mock('@/utils/logger', () => ({
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
}));

// Master copy address used by the hook, lowercased hex without 0x prefix.
const MASTER_COPY_HEX = '6851d6f8adc5e91a94aab91f358a4f3d4293504a';

const mockSafeSdk = {
getOwners: jest.fn().mockResolvedValue(['0xOwner1', '0xOwner2']),
getThreshold: jest.fn().mockResolvedValue(2),
getPendingTransactions: jest.fn().mockResolvedValue([]),
getContractVersion: jest.fn().mockResolvedValue('1.3.0'),
};

describe('useSafeInfo', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSafeCreate.mockResolvedValue(mockSafeSdk);
});

it('starts in the loading state while checking', () => {
mockGetBytecode.mockResolvedValue('0x1234');
const { result } = renderHook(() => useSafeInfo('0xabc...123'));

expect(result.current.loading).toBe(true);
expect(result.current.isSafe).toBe(false);
expect(result.current.safeInfo).toBeNull();
});

it('stops loading immediately when no address is provided', async () => {
const { result } = renderHook(() => useSafeInfo(undefined));

await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isSafe).toBe(false);
expect(result.current.safeInfo).toBeNull();
expect(mockGetBytecode).not.toHaveBeenCalled();
});

it('reports a non-safe address when the bytecode does not match a Safe master copy', async () => {
mockGetBytecode.mockResolvedValue('0x0000000000000000000000000000000000000000');
const { result } = renderHook(() => useSafeInfo('0xabc...123'));

await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isSafe).toBe(false);
expect(result.current.safeInfo).toBeNull();
});

it('loads safe info when the address holds a Safe master copy', async () => {
mockGetBytecode.mockResolvedValue(`0x${MASTER_COPY_HEX}00`);
const { result } = renderHook(() => useSafeInfo('0xabc...123'));

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.isSafe).toBe(true);
expect(result.current.safeInfo).not.toBeNull();
expect(result.current.safeInfo.owners).toEqual(['0xOwner1', '0xOwner2']);
expect(result.current.safeInfo.threshold).toBe(2);
expect(result.current.safeInfo.version).toBe('1.3.0');
});

it('handles lookup errors gracefully', async () => {
mockGetBytecode.mockRejectedValue(new Error('RPC unavailable'));
const { result } = renderHook(() => useSafeInfo('0xabc...123'));

await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isSafe).toBe(false);
expect(result.current.safeInfo).toBeNull();
});
});
Loading
Loading