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
78
frontend/src/__tests__/e2e/filter-stream-display.test.tsx
Normal file
78
frontend/src/__tests__/e2e/filter-stream-display.test.tsx
Normal 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%' });
|
||||
});
|
||||
});
|
||||
89
frontend/src/__tests__/helpers.ts
Normal file
89
frontend/src/__tests__/helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
77
frontend/src/__tests__/mocks/handlers.ts
Normal file
77
frontend/src/__tests__/mocks/handlers.ts
Normal 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',
|
||||
});
|
||||
}),
|
||||
];
|
||||
4
frontend/src/__tests__/mocks/server.ts
Normal file
4
frontend/src/__tests__/mocks/server.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
59
frontend/src/__tests__/setup.ts
Normal file
59
frontend/src/__tests__/setup.ts
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue