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:
Viktor Barzin 2026-02-10 21:59:45 +00:00
parent a3ac9cc060
commit 8d22c97320
No known key found for this signature in database
GPG key ID: 0EB088298288D958
36 changed files with 5447 additions and 19 deletions

View 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' }),
);
});
});