Skip to content
Closed
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"msw": "^2.7.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
"vitest": "^2.1.8",
"@testing-library/dom": "^10.0.0"
}
}
28 changes: 15 additions & 13 deletions apps/frontend/src/__tests__/components/ToastNotification.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* Tests toast display, dismissal, and interaction behavior
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import React from 'react';

// Mock Toast component for testing
Expand Down Expand Up @@ -77,6 +77,10 @@ describe('Toast Notification Component', () => {
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

it('should render toast with message', () => {
render(
<Toast
Expand Down Expand Up @@ -151,13 +155,11 @@ describe('Toast Notification Component', () => {
expect(screen.getByText('Auto-dismiss message')).toBeInTheDocument();

// Fast-forward time
vi.advanceTimersByTime(3000);

await waitFor(() => {
expect(mockOnClose).toHaveBeenCalledWith('1');
act(() => {
vi.advanceTimersByTime(3000);
});

vi.useRealTimers();
expect(mockOnClose).toHaveBeenCalledWith('1');
});

it('should not auto-dismiss when duration is 0', async () => {
Expand Down Expand Up @@ -282,9 +284,9 @@ describe('Toast Container', () => {

render(
<div>
<Toast id="1" type="success" message="Toast 1" onClose={mockOnClose} duration={0} />
<Toast id="2" type="error" message="Toast 2" onClose={mockOnClose} duration={0} />
<Toast id="3" type="warning" message="Toast 3" onClose={mockOnClose} duration={0} />
<Toast key="1" id="1" type="success" message="Toast 1" onClose={mockOnClose} duration={0} />
<Toast key="2" id="2" type="error" message="Toast 2" onClose={mockOnClose} duration={0} />
<Toast key="3" id="3" type="warning" message="Toast 3" onClose={mockOnClose} duration={0} />
</div>
);

Expand All @@ -298,8 +300,8 @@ describe('Toast Container', () => {

const { rerender } = render(
<div>
<Toast id="1" type="success" message="Toast 1" onClose={mockOnClose} duration={0} />
<Toast id="2" type="error" message="Toast 2" onClose={mockOnClose} duration={0} />
<Toast key="1" id="1" type="success" message="Toast 1" onClose={mockOnClose} duration={0} />
<Toast key="2" id="2" type="error" message="Toast 2" onClose={mockOnClose} duration={0} />
</div>
);

Expand All @@ -313,7 +315,7 @@ describe('Toast Container', () => {
// Rerender without first toast
rerender(
<div>
<Toast id="2" type="error" message="Toast 2" onClose={mockOnClose} duration={0} />
<Toast key="2" id="2" type="error" message="Toast 2" onClose={mockOnClose} duration={0} />
</div>
);

Expand Down
32 changes: 9 additions & 23 deletions apps/frontend/src/__tests__/hooks/useDebounce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from '@/hooks/useDebounce';

describe('useDebounce', () => {
Expand Down Expand Up @@ -41,9 +41,7 @@ describe('useDebounce', () => {
});

// Should now be updated
await waitFor(() => {
expect(result.current).toBe('updated');
});
expect(result.current).toBe('updated');
});

it('should cancel previous debounce on rapid updates', async () => {
Expand Down Expand Up @@ -75,9 +73,7 @@ describe('useDebounce', () => {
vi.advanceTimersByTime(300); // Total 500ms from last update
});

await waitFor(() => {
expect(result.current).toBe('value4');
});
expect(result.current).toBe('value4');
});

it('should work with different data types', async () => {
Expand Down Expand Up @@ -111,9 +107,7 @@ describe('useDebounce', () => {
vi.advanceTimersByTime(0);
});

await waitFor(() => {
expect(result.current).toBe('updated');
});
expect(result.current).toBe('updated');
});

it('should cleanup on unmount', () => {
Expand Down Expand Up @@ -162,9 +156,7 @@ describe('useDebounce', () => {
vi.advanceTimersByTime(1);
});

await waitFor(() => {
expect(result.current).toBe('updated');
});
expect(result.current).toBe('updated');
}
});
});
Expand Down Expand Up @@ -197,7 +189,7 @@ describe('useDebounce - Real-world scenarios', () => {
'blockchain',
];

letters.forEach((query, index) => {
letters.forEach((query) => {
rerender({ query });
act(() => {
vi.advanceTimersByTime(50); // User types quickly
Expand All @@ -212,9 +204,7 @@ describe('useDebounce - Real-world scenarios', () => {
vi.advanceTimersByTime(300);
});

await waitFor(() => {
expect(result.current).toBe('blockchain');
});
expect(result.current).toBe('blockchain');
});

it('should work for window resize scenario', async () => {
Expand All @@ -240,9 +230,7 @@ describe('useDebounce - Real-world scenarios', () => {
vi.advanceTimersByTime(250);
});

await waitFor(() => {
expect(result.current).toBe(1700);
});
expect(result.current).toBe(1700);
});

it('should work for form validation scenario', async () => {
Expand Down Expand Up @@ -275,8 +263,6 @@ describe('useDebounce - Real-world scenarios', () => {
vi.advanceTimersByTime(400);
});

await waitFor(() => {
expect(result.current).toBe('test@example.com');
});
expect(result.current).toBe('test@example.com');
});
});
78 changes: 42 additions & 36 deletions apps/frontend/src/__tests__/hooks/usePolling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,24 @@
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react';
import { usePolling } from '@/hooks/usePolling';

const MOCK_DATA = { value: 42 };

async function flushPromises() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}

async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}

describe('usePolling', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand All @@ -25,19 +38,19 @@ describe('usePolling', () => {
// ---------------------------------------------------------------------------
// Initial fetch
// ---------------------------------------------------------------------------
it('calls fetchFn immediately when immediate=true (default)', async () => {
it('calls fetchFn immediately when immediate=true (default)', () => {
const fetchFn = vi.fn().mockResolvedValue(MOCK_DATA);
renderHook(() => usePolling(fetchFn, { interval: 5000 }));

await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
expect(fetchFn).toHaveBeenCalledTimes(1);
});

it('does NOT call fetchFn immediately when immediate=false', async () => {
const fetchFn = vi.fn().mockResolvedValue(MOCK_DATA);
renderHook(() => usePolling(fetchFn, { interval: 5000, immediate: false }));

// Give a tick for any accidental immediate call
await act(async () => {});
await flushPromises();
expect(fetchFn).toHaveBeenCalledTimes(0);
});

Expand All @@ -49,18 +62,14 @@ describe('usePolling', () => {
renderHook(() => usePolling(fetchFn, { interval: 1000 }));

// First immediate call
await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
expect(fetchFn).toHaveBeenCalledTimes(1);

// Advance two full intervals
await act(async () => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2));
await advance(1000);
expect(fetchFn).toHaveBeenCalledTimes(2);

await act(async () => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(3));
await advance(1000);
expect(fetchFn).toHaveBeenCalledTimes(3);
});

// ---------------------------------------------------------------------------
Expand All @@ -70,15 +79,13 @@ describe('usePolling', () => {
const fetchFn = vi.fn().mockResolvedValue(MOCK_DATA);
const { unmount } = renderHook(() => usePolling(fetchFn, { interval: 1000 }));

await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
expect(fetchFn).toHaveBeenCalledTimes(1);

// Unmount while interval is still running
unmount();

// Advance timer — interval should already be cleared
await act(async () => {
vi.advanceTimersByTime(3000);
});
await advance(3000);

// Must still be exactly 1 — no additional calls after unmount
expect(fetchFn).toHaveBeenCalledTimes(1);
Expand All @@ -90,24 +97,22 @@ describe('usePolling', () => {
initialProps: { interval: 1000 },
});

await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
expect(fetchFn).toHaveBeenCalledTimes(1);

// Change the interval — the old setInterval should have been cleared
rerender({ interval: 2000 });

// immediate=true triggers a fresh call on re-mount
await flushPromises();
expect(fetchFn).toHaveBeenCalledTimes(2);

// Advance only 1000ms — old interval would have fired, new one should not yet
await act(async () => {
vi.advanceTimersByTime(1000);
});
// immediate=true triggers a fresh call on re-mount, so we get call #2 from that
// Then at 2000ms the new interval fires — we just assert it didn't fire at 1000ms
const callCountAt1s = fetchFn.mock.calls.length;
await advance(1000);
expect(fetchFn).toHaveBeenCalledTimes(2);

await act(async () => {
vi.advanceTimersByTime(1000);
});
// Now 2000ms elapsed since rerender — new interval fires
expect(fetchFn.mock.calls.length).toBeGreaterThan(callCountAt1s);
await advance(1000);
expect(fetchFn).toHaveBeenCalledTimes(3);

unmount();
});
Expand All @@ -119,9 +124,7 @@ describe('usePolling', () => {
const fetchFn = vi.fn().mockResolvedValue(MOCK_DATA);
renderHook(() => usePolling(fetchFn, { interval: 1000, paused: true }));

await act(async () => {
vi.advanceTimersByTime(5000);
});
await advance(5000);

expect(fetchFn).toHaveBeenCalledTimes(0);
});
Expand All @@ -133,15 +136,17 @@ describe('usePolling', () => {
const fetchFn = vi.fn().mockRejectedValue(new Error('Network down'));
const { result } = renderHook(() => usePolling(fetchFn, { interval: 5000 }));

await waitFor(() => expect(result.current.error).toBe('Network down'));
await flushPromises();
expect(result.current.error).toBe('Network down');
expect(result.current.data).toBeNull();
});

it('exposes fallback error string for non-Error rejections', async () => {
const fetchFn = vi.fn().mockRejectedValue('raw string');
const { result } = renderHook(() => usePolling(fetchFn, { interval: 5000 }));

await waitFor(() => expect(result.current.error).toBe('Polling failed'));
await flushPromises();
expect(result.current.error).toBe('Polling failed');
});

// ---------------------------------------------------------------------------
Expand All @@ -151,14 +156,14 @@ describe('usePolling', () => {
const fetchFn = vi.fn().mockResolvedValue(MOCK_DATA);
const { result } = renderHook(() => usePolling(fetchFn, { interval: 60_000 }));

await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
expect(fetchFn).toHaveBeenCalledTimes(1);

// Manually refresh before interval fires
await act(async () => {
result.current.refresh();
});

await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2));
expect(fetchFn).toHaveBeenCalledTimes(2);
});

// ---------------------------------------------------------------------------
Expand All @@ -168,7 +173,8 @@ describe('usePolling', () => {
const fetchFn = vi.fn().mockResolvedValue(MOCK_DATA);
const { result } = renderHook(() => usePolling(fetchFn, { interval: 5000 }));

await waitFor(() => expect(result.current.data).toEqual(MOCK_DATA));
await flushPromises();
expect(result.current.data).toEqual(MOCK_DATA);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
Expand Down
Loading
Loading