Add comprehensive test suite: 219 new tests across backend and frontend
Backend (103 tests): - Unit tests for listing_service, export_service, district_service - Regression tests for API response contracts and query parameter validation - Integration tests for API workflows, Redis listing cache, listing processor pipeline, and repository advanced queries - E2E tests for streaming with filters, batching, caching, and task management Frontend (116 tests): - Service tests for apiClient, streamingService, taskService, listingService, healthService - Hook tests for useTaskProgress (WebSocket + polling) - Component tests for PropertyCard, FilterPanel, Header, ListView, TaskProgressDrawer, TaskIndicator, StreamingProgressBar, HealthIndicator - E2E tests for filter-stream-display flow Infrastructure: - Add pytest-xdist and test markers (regression, integration, e2e) - Add conftest fixtures: fake_redis, rent_listing_factory, seeded_repository - Add vitest + testing-library + MSW for frontend testing
This commit is contained in:
parent
a3ac9cc060
commit
8d22c97320
36 changed files with 5447 additions and 19 deletions
227
frontend/src/hooks/__tests__/useTaskProgress.test.ts
Normal file
227
frontend/src/hooks/__tests__/useTaskProgress.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
||||
import { mockUser, createMockTaskState } from '@/__tests__/helpers';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onerror: ((e: unknown) => void) | null = null;
|
||||
readyState = 0;
|
||||
url: string;
|
||||
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
|
||||
simulateOpen() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
simulateMessage(data: object) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
simulateClose() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Mock the services module to prevent actual API calls during polling
|
||||
vi.mock('@/services', () => ({
|
||||
fetchTasksForUser: vi.fn().mockResolvedValue([]),
|
||||
fetchTaskStatus: vi.fn().mockResolvedValue({
|
||||
task_id: 'test',
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
progress: null,
|
||||
processed: null,
|
||||
total: null,
|
||||
message: null,
|
||||
error: null,
|
||||
traceback: null,
|
||||
}),
|
||||
cancelTask: vi.fn().mockResolvedValue({ success: true, message: 'Cancelled' }),
|
||||
clearAllTasks: vi.fn().mockResolvedValue({ success: true, count: 0, message: 'Cleared' }),
|
||||
}));
|
||||
|
||||
describe('useTaskProgress', () => {
|
||||
let originalWebSocket: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
originalWebSocket = globalThis.WebSocket;
|
||||
// @ts-expect-error mock WebSocket
|
||||
globalThis.WebSocket = MockWebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('initializes with empty tasks when user is null', () => {
|
||||
const { result } = renderHook(() => useTaskProgress(null));
|
||||
expect(result.current.tasks).toEqual({});
|
||||
});
|
||||
|
||||
it('initializes with isConnected false', () => {
|
||||
const { result } = renderHook(() => useTaskProgress(null));
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('creates WebSocket when user is provided', () => {
|
||||
renderHook(() => useTaskProgress(mockUser()));
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(1);
|
||||
expect(MockWebSocket.instances[0].url).toContain('token=test-access-token');
|
||||
});
|
||||
|
||||
it('populates tasks from WebSocket init message', async () => {
|
||||
const { result } = renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
act(() => {
|
||||
ws.simulateOpen();
|
||||
});
|
||||
|
||||
const taskState = createMockTaskState({ task_id: 'task-1', status: 'STARTED' });
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'init', tasks: [taskState] });
|
||||
});
|
||||
|
||||
expect(result.current.tasks['task-1']).toBeDefined();
|
||||
expect(result.current.tasks['task-1'].status).toBe('STARTED');
|
||||
});
|
||||
|
||||
it('updates task state from WebSocket task_update message', async () => {
|
||||
const { result } = renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
act(() => {
|
||||
ws.simulateOpen();
|
||||
ws.simulateMessage({
|
||||
type: 'init',
|
||||
tasks: [createMockTaskState({ task_id: 'task-1', status: 'PENDING' })],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({
|
||||
type: 'task_update',
|
||||
task_id: 'task-1',
|
||||
status: 'STARTED',
|
||||
progress: 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.tasks['task-1'].status).toBe('STARTED');
|
||||
});
|
||||
|
||||
it('sends subscribe message via WebSocket', async () => {
|
||||
const { result } = renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
// The first MockWebSocket instance is created by connect()
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
await act(async () => {
|
||||
ws.simulateOpen();
|
||||
// Let microtasks from startPolling/fetchAndPoll settle
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
// Clear any send calls from onopen (pending subscriptions, keepalive)
|
||||
ws.send.mockClear();
|
||||
|
||||
// Queue the subscription as pending, then simulate a fresh WS open to flush it
|
||||
// Actually, let's test the pending subscription path instead:
|
||||
// If WS isn't OPEN, subscribe queues the task ID.
|
||||
// When WS opens, pending subscriptions are sent.
|
||||
const ws2result = renderHook(() => useTaskProgress(mockUser()));
|
||||
const ws2 = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
|
||||
// Subscribe before WS is open (readyState = 0)
|
||||
act(() => {
|
||||
ws2result.result.current.subscribe('task-pending');
|
||||
});
|
||||
|
||||
// Now open the WS — pending subscriptions should be flushed
|
||||
await act(async () => {
|
||||
ws2.simulateOpen();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(ws2.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'subscribe', task_id: 'task-pending' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes WebSocket on unmount', () => {
|
||||
const { unmount } = renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
act(() => {
|
||||
ws.simulateOpen();
|
||||
});
|
||||
|
||||
unmount();
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers polling that calls fetch for task status', async () => {
|
||||
const { fetchTasksForUser } = await import('@/services');
|
||||
|
||||
renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
// The hook starts polling immediately — advance past the first poll interval
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
// fetchTasksForUser should have been called at least once (initial poll)
|
||||
expect(fetchTasksForUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cancelTask service', async () => {
|
||||
const { cancelTask: mockCancel } = await import('@/services');
|
||||
|
||||
const { result } = renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancelTask('task-1');
|
||||
});
|
||||
|
||||
expect(mockCancel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ accessToken: 'test-access-token' }),
|
||||
'task-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls clearAllTasks service', async () => {
|
||||
const { clearAllTasks: mockClearAll } = await import('@/services');
|
||||
|
||||
const { result } = renderHook(() => useTaskProgress(mockUser()));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearAllTasks();
|
||||
});
|
||||
|
||||
expect(mockClearAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ accessToken: 'test-access-token' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue