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,78 @@
import { render, screen } from '@testing-library/react';
import { StreamingProgressBar } from '@/components/StreamingProgressBar';
import { ListView } from '@/components/ListView';
import { createMockFeatureCollection } from '@/__tests__/helpers';
import type { StreamingProgress } from '@/services';
// Mock react-virtuoso for ListView
vi.mock('react-virtuoso', () => ({
Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (index: number, item: unknown) => React.ReactNode }) => (
<div data-testid="virtuoso">
{data.map((item, index) => (
<div key={index}>{itemContent(index, item)}</div>
))}
</div>
),
}));
describe('Filter → Stream → Display Integration', () => {
it('shows StreamingProgressBar when isLoading is true', () => {
render(
<StreamingProgressBar progress={null} isLoading />,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('ListView shows listings after stream completes', () => {
const data = createMockFeatureCollection(5);
render(<ListView listingData={data} />);
expect(screen.getByText(/5\s*properties/)).toBeInTheDocument();
});
it('listing count updates when features change', () => {
const data2 = createMockFeatureCollection(2);
const data7 = createMockFeatureCollection(7);
const { rerender } = render(<ListView listingData={data2} />);
expect(screen.getByText(/2\s*properties/)).toBeInTheDocument();
rerender(<ListView listingData={data7} />);
expect(screen.getByText(/7\s*properties/)).toBeInTheDocument();
});
it('empty state renders when no features', () => {
const empty = createMockFeatureCollection(0);
render(<ListView listingData={empty} />);
expect(screen.getByText(/0\s*properties/)).toBeInTheDocument();
});
it('StreamingProgressBar hides when loading completes', () => {
const { rerender, container } = render(
<StreamingProgressBar progress={{ count: 50, total: 100 }} isLoading />,
);
expect(screen.getByText('Loading listings...')).toBeInTheDocument();
rerender(
<StreamingProgressBar progress={{ count: 100, total: 100 }} isLoading={false} />,
);
// Component returns null when not loading
expect(container.firstChild).toBeNull();
});
it('progress updates are reflected in StreamingProgressBar', () => {
const progress1: StreamingProgress = { count: 10, total: 100 };
const progress2: StreamingProgress = { count: 75, total: 100 };
const { rerender, container } = render(
<StreamingProgressBar progress={progress1} isLoading />,
);
let bar = container.querySelector('.bg-primary.transition-all');
expect(bar).toHaveStyle({ width: '10%' });
rerender(
<StreamingProgressBar progress={progress2} isLoading />,
);
bar = container.querySelector('.bg-primary.transition-all');
expect(bar).toHaveStyle({ width: '75%' });
});
});

View file

@ -0,0 +1,89 @@
import type { AuthUser } from '@/auth/types';
import type { PropertyProperties, PropertyFeature, GeoJSONFeatureCollection, TaskState } from '@/types';
/**
* Create a mock AuthUser for testing
*/
export function mockUser(overrides: Partial<AuthUser> = {}): AuthUser {
return {
sub: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
accessToken: 'test-access-token',
provider: 'oidc',
...overrides,
};
}
/**
* Create a mock PropertyProperties object
*/
export function createMockProperty(overrides: Partial<PropertyProperties> = {}): PropertyProperties {
return {
url: 'https://www.rightmove.co.uk/properties/12345678',
city: 'London',
country: 'UK',
qm: 65,
qmprice: 38.46,
total_price: 2500,
rooms: 2,
agency: 'Test Agency',
available_from: '2024-06-01',
last_seen: new Date().toISOString(),
photo_thumbnail: 'https://example.com/photo.jpg',
price_history: [],
listing_type: 'RENT',
...overrides,
};
}
/**
* Create a mock GeoJSON Feature
*/
export function createMockFeature(overrides: Partial<PropertyProperties> = {}): PropertyFeature {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-0.1276, 51.5074],
},
properties: createMockProperty(overrides),
};
}
/**
* Create a mock FeatureCollection
*/
export function createMockFeatureCollection(
count: number = 3,
propertyOverrides: Partial<PropertyProperties> = {},
): GeoJSONFeatureCollection {
return {
type: 'FeatureCollection',
features: Array.from({ length: count }, (_, i) =>
createMockFeature({
url: `https://www.rightmove.co.uk/properties/${1000 + i}`,
total_price: 1500 + i * 500,
rooms: (i % 3) + 1,
qm: 30 + i * 15,
...propertyOverrides,
}),
),
};
}
/**
* Create a mock TaskState
*/
export function createMockTaskState(overrides: Partial<TaskState> = {}): TaskState {
return {
task_id: 'test-task-123',
status: 'STARTED',
progress: 0.5,
processed: 50,
total: 100,
phase: 'fetching',
message: 'Fetching listings',
...overrides,
};
}

View file

@ -0,0 +1,77 @@
import { http, HttpResponse } from 'msw';
export const handlers = [
// Health check
http.get('/api/status', () => {
return HttpResponse.json({ status: 'OK' });
}),
// Get listings
http.get('/api/listing', () => {
return HttpResponse.json({ listings: [] });
}),
// Get listing GeoJSON
http.get('/api/listing_geojson', () => {
return HttpResponse.json({
type: 'FeatureCollection',
features: [],
});
}),
// Stream listing GeoJSON
http.get('/api/listing_geojson/stream', () => {
const lines = [
JSON.stringify({ type: 'metadata', batch_size: 50, total_expected: 0, cached: false }),
JSON.stringify({ type: 'complete', total: 0 }),
].join('\n') + '\n';
return new HttpResponse(lines, {
headers: { 'Content-Type': 'application/x-ndjson' },
});
}),
// Refresh listings
http.post('/api/refresh_listings', () => {
return HttpResponse.json({ task_id: 'test-task-123', message: 'Task started' });
}),
// Task status
http.get('/api/task_status', () => {
return HttpResponse.json({
task_id: 'test-task-123',
status: 'PENDING',
result: null,
progress: null,
processed: null,
total: null,
message: null,
error: null,
traceback: null,
});
}),
// Tasks for user
http.get('/api/tasks_for_user', () => {
return HttpResponse.json([]);
}),
// Cancel task
http.post('/api/cancel_task', () => {
return HttpResponse.json({ success: true, message: 'Task cancelled' });
}),
// Clear all tasks
http.post('/api/clear_all_tasks', () => {
return HttpResponse.json({ success: true, count: 0, message: 'Cleared 0 tasks' });
}),
// Districts
http.get('/api/get_districts', () => {
return HttpResponse.json({
London: 'REGION^87490',
Westminster: 'REGION^93980',
Camden: 'REGION^93941',
});
}),
];

View file

@ -0,0 +1,4 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

View file

@ -0,0 +1,59 @@
import '@testing-library/jest-dom/vitest';
// Polyfill ResizeObserver for jsdom (used by Radix UI components)
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}
// Mock mapbox-gl (requires WebGL which jsdom doesn't support)
vi.mock('mapbox-gl', () => ({
default: {
Map: vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
addLayer: vi.fn(),
getSource: vi.fn(),
getLayer: vi.fn(),
removeLayer: vi.fn(),
removeSource: vi.fn(),
setLayoutProperty: vi.fn(),
setPaintProperty: vi.fn(),
flyTo: vi.fn(),
fitBounds: vi.fn(),
resize: vi.fn(),
getCanvas: vi.fn(() => ({ style: {} })),
})),
NavigationControl: vi.fn(),
Popup: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
setDOMContent: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
})),
Marker: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
})),
LngLatBounds: vi.fn(() => ({
extend: vi.fn().mockReturnThis(),
isEmpty: vi.fn(() => false),
})),
},
Map: vi.fn(),
NavigationControl: vi.fn(),
}));
// Mock import.meta.env
if (!import.meta.env) {
// @ts-expect-error setting up test env
import.meta.env = {};
}
import.meta.env.VITE_MAPBOX_TOKEN = 'test-token';