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

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,10 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
@ -65,6 +68,12 @@
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^3.0.0",
"@testing-library/react": "^16.3.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/user-event": "^14.6.0",
"jsdom": "^25.0.0",
"msw": "^2.7.0"
}
}

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';

View file

@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterPanel, ListingType, Metric, DEFAULT_FILTER_VALUES } from '@/components/FilterPanel';
// Mock the POIManager to avoid its dependencies
vi.mock('@/components/POIManager', () => ({
POIManager: () => <div data-testid="poi-manager">POIManager</div>,
}));
const defaultProps = {
onSubmit: vi.fn(),
currentMetric: Metric.qmprice,
isLoading: false,
};
describe('FilterPanel', () => {
it('renders listing type tabs (Rent and Buy)', () => {
render(<FilterPanel {...defaultProps} />);
expect(screen.getByText('Rent')).toBeInTheDocument();
expect(screen.getByText('Buy')).toBeInTheDocument();
});
it('renders price range slider', () => {
render(<FilterPanel {...defaultProps} />);
expect(screen.getByText(/Price/)).toBeInTheDocument();
});
it('renders bedroom range slider', () => {
render(<FilterPanel {...defaultProps} />);
expect(screen.getByText('Bedrooms')).toBeInTheDocument();
});
it('calls onSubmit when Apply Filters is clicked', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<FilterPanel {...defaultProps} onSubmit={onSubmit} />);
const applyBtn = screen.getByText('Apply Filters');
await user.click(applyBtn);
expect(onSubmit).toHaveBeenCalledWith('visualize', expect.objectContaining({
listing_type: ListingType.RENT,
}));
});
it('disables submit button when loading', () => {
render(<FilterPanel {...defaultProps} isLoading />);
const applyBtn = screen.getByText('Loading...');
expect(applyBtn.closest('button')).toBeDisabled();
});
it('shows furnish types only for rent', async () => {
const user = userEvent.setup();
render(<FilterPanel {...defaultProps} />);
// Open the Advanced Filters accordion
const advancedTrigger = screen.getByText('Advanced Filters');
await user.click(advancedTrigger);
// Furnish options should be visible for RENT
expect(screen.getByText('Furnished')).toBeInTheDocument();
});
it('renders min size input', () => {
render(<FilterPanel {...defaultProps} />);
expect(screen.getByText(/Min Size/)).toBeInTheDocument();
});
it('renders last seen days in advanced filters', async () => {
const user = userEvent.setup();
render(<FilterPanel {...defaultProps} />);
await user.click(screen.getByText('Advanced Filters'));
expect(screen.getByText(/Last Seen/)).toBeInTheDocument();
});
it('initializes with default values', () => {
render(<FilterPanel {...defaultProps} />);
// Default listing type is RENT, so Rent tab should be active
// The Rent tab should exist
expect(screen.getByText('Rent')).toBeInTheDocument();
});
it('renders available from picker in advanced for rent', async () => {
const user = userEvent.setup();
render(<FilterPanel {...defaultProps} />);
await user.click(screen.getByText('Advanced Filters'));
expect(screen.getByText('Available From')).toBeInTheDocument();
});
it('renders district input in advanced filters', async () => {
const user = userEvent.setup();
render(<FilterPanel {...defaultProps} />);
await user.click(screen.getByText('Advanced Filters'));
expect(screen.getByText('District')).toBeInTheDocument();
});
it('renders price per sqm fields in advanced filters', async () => {
const user = userEvent.setup();
render(<FilterPanel {...defaultProps} />);
await user.click(screen.getByText('Advanced Filters'));
expect(screen.getByText('Min £/m²')).toBeInTheDocument();
expect(screen.getByText('Max £/m²')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Header } from '@/components/Header';
import { mockUser, createMockTaskState } from '@/__tests__/helpers';
// Mock auth services to prevent actual calls
vi.mock('@/auth/authService', () => ({
logout: vi.fn(async () => {}),
}));
vi.mock('@/auth/passkeyService', () => ({
clearPasskeyUser: vi.fn(),
}));
// Mock HealthIndicator to avoid async health check calls
vi.mock('@/components/HealthIndicator', () => ({
HealthIndicator: () => <div data-testid="health-indicator">HealthIndicator</div>,
}));
const defaultProps = {
user: mockUser({ email: 'alice@example.com' }),
tasks: {} as Record<string, import('@/types').TaskState>,
activeTaskId: null,
isConnected: true,
onCancelTask: vi.fn(async () => true),
onClearAllTasks: vi.fn(async () => true),
};
describe('Header', () => {
it('renders user email', () => {
render(<Header {...defaultProps} />);
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('renders Logout button', () => {
render(<Header {...defaultProps} />);
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('calls logout on button click', async () => {
const user = userEvent.setup();
const { logout } = await import('@/auth/authService');
render(<Header {...defaultProps} />);
await user.click(screen.getByText('Logout'));
expect(logout).toHaveBeenCalled();
});
it('renders health indicator', () => {
render(<Header {...defaultProps} />);
expect(screen.getByTestId('health-indicator')).toBeInTheDocument();
});
it('renders task indicator when tasks are present', () => {
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
const { container } = render(
<Header {...defaultProps} tasks={tasks} activeTaskId="t1" />,
);
// TaskIndicator renders animate-spin for running tasks
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
});
it('shows active filter count badge', () => {
render(
<Header
{...defaultProps}
activeFilterCount={5}
showFilterToggle
onToggleFilters={vi.fn()}
/>,
);
expect(screen.getByText('5')).toBeInTheDocument();
});
it('renders brand name', () => {
render(<Header {...defaultProps} />);
expect(screen.getByText('Wrongmove')).toBeInTheDocument();
});
it('shows mobile filter toggle when enabled', () => {
const onToggle = vi.fn();
const { container } = render(
<Header {...defaultProps} showFilterToggle onToggleFilters={onToggle} />,
);
// The filter toggle button has sm:hidden class
const filterButton = container.querySelector('.sm\\:hidden');
expect(filterButton).toBeInTheDocument();
});
});

View file

@ -0,0 +1,61 @@
import { render, screen, waitFor, act } from '@testing-library/react';
import { HealthIndicator } from '@/components/HealthIndicator';
vi.mock('@/services', async () => {
const actual = await vi.importActual('@/services');
return { ...actual, checkBackendHealth: vi.fn() };
});
import { checkBackendHealth } from '@/services';
const mockCheck = vi.mocked(checkBackendHealth);
describe('HealthIndicator', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('shows Checking... initially', () => {
// Never resolve so the component stays in "checking" state
mockCheck.mockReturnValue(new Promise(() => {}));
render(<HealthIndicator />);
expect(screen.getByText('Checking...')).toBeInTheDocument();
});
it('shows Connected when healthy', async () => {
mockCheck.mockResolvedValue({ status: 'healthy', latencyMs: 50 });
render(<HealthIndicator />);
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
});
it('shows Disconnected when unhealthy', async () => {
mockCheck.mockResolvedValue({ status: 'unhealthy', error: 'timeout' });
render(<HealthIndicator />);
await waitFor(() => {
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
});
it('checks health periodically', async () => {
vi.useFakeTimers();
mockCheck.mockResolvedValue({ status: 'healthy', latencyMs: 10 });
render(<HealthIndicator interval={1000} />);
// Initial call
expect(mockCheck).toHaveBeenCalledTimes(1);
// Advance past one interval
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(mockCheck).toHaveBeenCalledTimes(2);
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(mockCheck).toHaveBeenCalledTimes(3);
});
});

View file

@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ListView } from '@/components/ListView';
import { createMockFeatureCollection, createMockFeature } from '@/__tests__/helpers';
// Mock react-virtuoso since it needs a real DOM with dimensions
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('ListView', () => {
it('renders listing count', () => {
const data = createMockFeatureCollection(3);
render(<ListView listingData={data} />);
expect(screen.getByText(/3\s*properties/)).toBeInTheDocument();
});
it('renders property cards for each feature', () => {
const data = createMockFeatureCollection(3);
render(<ListView listingData={data} />);
// Each PropertyCard renders a price with £ sign
const prices = screen.getAllByText(/£/);
expect(prices.length).toBeGreaterThanOrEqual(3);
});
it('renders sort controls', () => {
const data = createMockFeatureCollection(1);
render(<ListView listingData={data} />);
expect(screen.getByText('Sort:')).toBeInTheDocument();
expect(screen.getByText('Price')).toBeInTheDocument();
expect(screen.getByText('£/m²')).toBeInTheDocument();
expect(screen.getByText('Size')).toBeInTheDocument();
});
it('highlights selected property', () => {
const feature = createMockFeature({ url: 'https://rightmove.co.uk/selected' });
const data = { type: 'FeatureCollection' as const, features: [feature] };
const { container } = render(
<ListView listingData={data} highlightedPropertyUrl="https://rightmove.co.uk/selected" />,
);
expect(container.querySelector('.ring-2')).toBeInTheDocument();
});
it('shows empty state for zero features', () => {
const data = createMockFeatureCollection(0);
render(<ListView listingData={data} />);
expect(screen.getByText(/0\s*properties/)).toBeInTheDocument();
});
it('changes sort order when clicking a sort button', async () => {
const user = userEvent.setup();
const data = createMockFeatureCollection(3);
render(<ListView listingData={data} />);
const priceButton = screen.getByText('Price');
await user.click(priceButton);
// The button should now be active (secondary variant)
// Clicking toggles sort - this doesn't crash, which validates the sort logic
await user.click(priceButton);
});
it('uses compact variant for property cards', () => {
const data = createMockFeatureCollection(1);
const { container } = render(<ListView listingData={data} />);
// Compact cards have the flex gap-3 p-3 layout
expect(container.querySelector('.flex.gap-3.p-3')).toBeInTheDocument();
});
it('calculates avg price per sqm for deal badges', () => {
// Create properties where one is clearly a good deal
const features = [
createMockFeature({ qmprice: 20, total_price: 1000 }),
createMockFeature({ url: 'https://rightmove.co.uk/2', qmprice: 50, total_price: 3000 }),
createMockFeature({ url: 'https://rightmove.co.uk/3', qmprice: 50, total_price: 3000 }),
];
// avg = 40, so 20 < 40*0.9=36 → Good deal
const data = { type: 'FeatureCollection' as const, features };
render(<ListView listingData={data} />);
expect(screen.getByText('Good deal')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PropertyCard } from '@/components/PropertyCard';
import { createMockProperty } from '@/__tests__/helpers';
describe('PropertyCard', () => {
it('renders rent price with /mo suffix', () => {
const property = createMockProperty({ listing_type: 'RENT', total_price: 2500 });
render(<PropertyCard property={property} />);
expect(screen.getByText('/mo')).toBeInTheDocument();
expect(screen.getByText(/2,500/)).toBeInTheDocument();
});
it('renders buy price without /mo suffix', () => {
const property = createMockProperty({ listing_type: 'BUY', total_price: 500000 });
render(<PropertyCard property={property} />);
expect(screen.getByText(/500,000/)).toBeInTheDocument();
expect(screen.queryByText('/mo')).not.toBeInTheDocument();
});
it('renders bedrooms count', () => {
const property = createMockProperty({ rooms: 3 });
render(<PropertyCard property={property} />);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('renders size with m\u00B2', () => {
const property = createMockProperty({ qm: 65 });
render(<PropertyCard property={property} />);
expect(screen.getByText(/65\s*m\u00B2/)).toBeInTheDocument();
});
it('renders price per sqm', () => {
const property = createMockProperty({ qmprice: 38 });
render(<PropertyCard property={property} />);
expect(screen.getByText(/38\/m\u00B2/)).toBeInTheDocument();
});
it('renders agency name', () => {
const property = createMockProperty({ agency: 'Foxtons' });
render(<PropertyCard property={property} />);
expect(screen.getByText('Foxtons')).toBeInTheDocument();
});
it('renders photo thumbnail when present', () => {
const property = createMockProperty({ photo_thumbnail: 'https://example.com/img.jpg' });
render(<PropertyCard property={property} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'https://example.com/img.jpg');
});
it('shows Good deal badge when qmprice is below 90% of average', () => {
const property = createMockProperty({ qmprice: 40 });
render(<PropertyCard property={property} avgPricePerSqm={50} />);
expect(screen.getByText('Good deal')).toBeInTheDocument();
});
it('shows Above avg badge when qmprice exceeds 110% of average', () => {
const property = createMockProperty({ qmprice: 40 });
render(<PropertyCard property={property} avgPricePerSqm={30} />);
expect(screen.getByText('Above avg')).toBeInTheDocument();
});
it('shows no badge when qmprice is near average', () => {
const property = createMockProperty({ qmprice: 38 });
render(<PropertyCard property={property} avgPricePerSqm={40} />);
expect(screen.queryByText('Good deal')).not.toBeInTheDocument();
expect(screen.queryByText('Above avg')).not.toBeInTheDocument();
});
it('calls onClick and opens window on click', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const property = createMockProperty({ url: 'https://rightmove.co.uk/123' });
render(<PropertyCard property={property} onClick={onClick} />);
await user.click(screen.getByText(/2,500/));
expect(openSpy).toHaveBeenCalledWith('https://rightmove.co.uk/123', '_blank', 'noopener,noreferrer');
expect(onClick).toHaveBeenCalled();
openSpy.mockRestore();
});
it('applies ring-2 class when highlighted', () => {
const property = createMockProperty();
const { container } = render(<PropertyCard property={property} isHighlighted />);
expect(container.querySelector('.ring-2')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react';
import { StreamingProgressBar } from '@/components/StreamingProgressBar';
import type { StreamingProgress } from '@/services';
describe('StreamingProgressBar', () => {
it('returns null when not loading', () => {
const { container } = render(
<StreamingProgressBar progress={null} isLoading={false} />,
);
expect(container.firstChild).toBeNull();
});
it('shows loading text when loading with no progress', () => {
render(<StreamingProgressBar progress={null} isLoading />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows count and total when both provided', () => {
const progress: StreamingProgress = { count: 25, total: 100 };
render(<StreamingProgressBar progress={progress} isLoading />);
expect(screen.getByText(/25/)).toBeInTheDocument();
expect(screen.getByText(/100/)).toBeInTheDocument();
});
it('shows count without total', () => {
const progress: StreamingProgress = { count: 25 };
render(<StreamingProgressBar progress={progress} isLoading />);
expect(screen.getByText(/25/)).toBeInTheDocument();
expect(screen.getByText(/loaded/)).toBeInTheDocument();
});
it('sets progress bar width based on count/total ratio', () => {
const progress: StreamingProgress = { count: 50, total: 100 };
const { container } = render(
<StreamingProgressBar progress={progress} isLoading />,
);
const progressBar = container.querySelector('.bg-primary.transition-all');
expect(progressBar).toHaveStyle({ width: '50%' });
});
});

View file

@ -0,0 +1,108 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TaskIndicator } from '@/components/TaskIndicator';
import { createMockTaskState } from '@/__tests__/helpers';
import type { TaskState } from '@/types';
const defaultProps = {
isConnected: true,
onCancelTask: vi.fn(async () => true),
onClearAllTasks: vi.fn(async () => true),
};
function renderIndicator(tasks: Record<string, TaskState>, activeTaskId: string | null, extra = {}) {
return render(
<TaskIndicator tasks={tasks} activeTaskId={activeTaskId} {...defaultProps} {...extra} />,
);
}
describe('TaskIndicator', () => {
it('returns null when no active task', () => {
const { container } = renderIndicator({}, null);
expect(container.firstChild).toBeNull();
});
it('returns null when activeTaskId has no matching task', () => {
const { container } = renderIndicator({}, 'missing-id');
expect(container.firstChild).toBeNull();
});
it('shows spinner when task is running', () => {
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
const { container } = renderIndicator(tasks, 't1');
// Loader2 renders an svg with animate-spin class
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
});
it('shows check icon on success', () => {
const tasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) };
const { container } = renderIndicator(tasks, 't1');
expect(container.querySelector('.text-green-500')).toBeInTheDocument();
});
it('shows X icon on failure', () => {
const tasks = { 't1': createMockTaskState({ status: 'FAILURE' }) };
const { container } = renderIndicator(tasks, 't1');
expect(container.querySelector('.text-red-500')).toBeInTheDocument();
});
it('shows progress text', () => {
const tasks = {
't1': createMockTaskState({ status: 'STARTED', progress: 0.5, processed: 50, total: 100, phase: 'processing' }),
};
renderIndicator(tasks, 't1');
expect(screen.getByText('50 / 100')).toBeInTheDocument();
});
it('opens drawer on click', async () => {
const user = userEvent.setup();
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
renderIndicator(tasks, 't1');
// Click the task indicator area
const clickable = screen.getByText(/50/).closest('[class*="cursor-pointer"]');
if (clickable) {
await user.click(clickable);
}
// The drawer (Sheet) should now be open - SheetTitle renders "Job Progress"
await waitFor(() => {
expect(screen.getByText(/Job Progress/)).toBeInTheDocument();
});
});
it('shows task count badge when multiple active tasks', () => {
const tasks = {
't1': createMockTaskState({ task_id: 't1', status: 'STARTED' }),
't2': createMockTaskState({ task_id: 't2', status: 'STARTED' }),
};
renderIndicator(tasks, 't1');
expect(screen.getByText('2')).toBeInTheDocument();
});
it('fires onTaskCompleted when active task transitions to SUCCESS', async () => {
const onTaskCompleted = vi.fn();
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
const { rerender } = render(
<TaskIndicator
tasks={tasks}
activeTaskId="t1"
{...defaultProps}
onTaskCompleted={onTaskCompleted}
/>,
);
const updatedTasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) };
rerender(
<TaskIndicator
tasks={updatedTasks}
activeTaskId="t1"
{...defaultProps}
onTaskCompleted={onTaskCompleted}
/>,
);
expect(onTaskCompleted).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,162 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TaskProgressDrawer } from '@/components/TaskProgressDrawer';
import { createMockTaskState } from '@/__tests__/helpers';
import { TaskStatus } from '@/types';
import type { TaskResult, TaskState } from '@/types';
const baseProps = {
open: true,
onOpenChange: vi.fn(),
taskID: 'test-task-123',
onCancel: vi.fn(),
isCancelling: false,
};
function makeResult(overrides: Partial<TaskResult> = {}): TaskResult {
return {
progress: 0.5,
processed: 50,
total: 100,
phase: 'fetching',
...overrides,
};
}
describe('TaskProgressDrawer', () => {
it('renders phase timeline labels for scrape task', () => {
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult({ phase: 'fetching' })}
taskStatus={TaskStatus.STARTED}
/>,
);
expect(screen.getByText('Splitting queries')).toBeInTheDocument();
// "Fetching & processing" appears in both the timeline and phase details
expect(screen.getAllByText('Fetching & processing').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Processing remaining')).toBeInTheDocument();
});
it('shows Running badge when task is in progress', () => {
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult()}
taskStatus={TaskStatus.STARTED}
/>,
);
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('shows Complete badge on success', () => {
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult({ phase: 'processing' })}
taskStatus={TaskStatus.SUCCESS}
/>,
);
expect(screen.getByText('Complete')).toBeInTheDocument();
});
it('shows Cancelled badge when revoked', () => {
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult()}
taskStatus={TaskStatus.REVOKED}
/>,
);
expect(screen.getByText('Cancelled')).toBeInTheDocument();
});
it('shows Failed badge on failure', () => {
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult()}
taskStatus={TaskStatus.FAILURE}
/>,
);
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('shows processing metrics when in fetching phase', () => {
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult({
phase: 'fetching',
details_fetched: 25,
images_downloaded: 10,
ocr_completed: 5,
total: 100,
})}
taskStatus={TaskStatus.STARTED}
/>,
);
expect(screen.getByText('Details fetched')).toBeInTheDocument();
expect(screen.getByText('Images downloaded')).toBeInTheDocument();
expect(screen.getByText('OCR completed')).toBeInTheDocument();
});
it('shows Cancel Job button when task is running', async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult()}
taskStatus={TaskStatus.STARTED}
onCancel={onCancel}
/>,
);
const cancelBtn = screen.getByText('Cancel Job');
expect(cancelBtn).toBeInTheDocument();
await user.click(cancelBtn);
expect(onCancel).toHaveBeenCalled();
});
it('infers POI task type from computing phase', () => {
const tasks: Record<string, TaskState> = {
'poi-1': createMockTaskState({
task_id: 'poi-1',
status: 'STARTED',
phase: 'computing',
distances_computed: 10,
}),
};
render(
<TaskProgressDrawer
{...baseProps}
taskResult={null}
taskStatus={TaskStatus.STARTED}
taskID="poi-1"
tasks={tasks}
selectedTaskId="poi-1"
/>,
);
// Title should say "POI Distances Job Progress"
expect(screen.getByText('POI Distances Job Progress')).toBeInTheDocument();
// POI phase details section
expect(screen.getByText('Computing distances')).toBeInTheDocument();
});
it('shows ETA when eta_seconds is provided', () => {
render(
<TaskProgressDrawer
{...baseProps}
taskResult={makeResult({
phase: 'processing',
progress: 0.75,
processed: 75,
total: 100,
eta_seconds: 120,
})}
taskStatus={TaskStatus.STARTED}
/>,
);
expect(screen.getByText(/2m.*remaining/)).toBeInTheDocument();
});
});

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

View file

@ -0,0 +1,145 @@
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '@/__tests__/mocks/server';
import { http, HttpResponse } from 'msw';
import { mockUser } from '@/__tests__/helpers';
import { apiRequest } from '@/services/apiClient';
import { ApiError } from '@/types';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('apiClient', () => {
it('makes GET requests and returns JSON', async () => {
server.use(
http.get('/api/status', () => {
return HttpResponse.json({ status: 'OK' });
}),
);
const result = await apiRequest<{ status: string }>(mockUser(), '/api/status');
expect(result).toEqual({ status: 'OK' });
});
it('includes Authorization header', async () => {
let receivedAuth: string | null = null;
server.use(
http.get('/api/test-auth', ({ request }) => {
receivedAuth = request.headers.get('Authorization');
return HttpResponse.json({ ok: true });
}),
);
const user = mockUser({ accessToken: 'my-secret-token' });
await apiRequest(user, '/api/test-auth');
expect(receivedAuth).toBe('Bearer my-secret-token');
});
it('builds URL with query params', async () => {
let requestUrl = '';
server.use(
http.get('/api/search', ({ request }) => {
requestUrl = request.url;
return HttpResponse.json({ results: [] });
}),
);
await apiRequest(mockUser(), '/api/search', {
params: { limit: 5, active: true },
});
const url = new URL(requestUrl);
expect(url.searchParams.get('limit')).toBe('5');
expect(url.searchParams.get('active')).toBe('true');
});
it('sends POST requests with JSON body', async () => {
let receivedMethod = '';
let receivedBody: unknown = null;
server.use(
http.post('/api/create', async ({ request }) => {
receivedMethod = request.method;
receivedBody = await request.json();
return HttpResponse.json({ id: 1 });
}),
);
const result = await apiRequest(mockUser(), '/api/create', {
method: 'POST',
body: { key: 'value' },
});
expect(receivedMethod).toBe('POST');
expect(receivedBody).toEqual({ key: 'value' });
expect(result).toEqual({ id: 1 });
});
it('throws ApiError on 404', async () => {
server.use(
http.get('/api/missing', () => {
return new HttpResponse(null, { status: 404 });
}),
);
await expect(apiRequest(mockUser(), '/api/missing')).rejects.toThrow(ApiError);
await expect(apiRequest(mockUser(), '/api/missing')).rejects.toMatchObject({
statusCode: 404,
});
});
it('throws ApiError on 500', async () => {
server.use(
http.get('/api/error', () => {
return new HttpResponse(null, { status: 500 });
}),
);
await expect(apiRequest(mockUser(), '/api/error')).rejects.toThrow(ApiError);
await expect(apiRequest(mockUser(), '/api/error')).rejects.toMatchObject({
statusCode: 500,
});
});
it('excludes undefined params from URL', async () => {
let requestUrl = '';
server.use(
http.get('/api/filter', ({ request }) => {
requestUrl = request.url;
return HttpResponse.json({});
}),
);
await apiRequest(mockUser(), '/api/filter', {
params: { limit: 10, offset: undefined, name: 'test' },
});
const url = new URL(requestUrl);
expect(url.searchParams.get('limit')).toBe('10');
expect(url.searchParams.get('name')).toBe('test');
expect(url.searchParams.has('offset')).toBe(false);
});
it('serializes Date params as ISO strings', async () => {
let requestUrl = '';
server.use(
http.get('/api/dated', ({ request }) => {
requestUrl = request.url;
return HttpResponse.json({});
}),
);
const date = new Date('2024-06-15T12:00:00.000Z');
await apiRequest(mockUser(), '/api/dated', {
params: { since: date },
});
const url = new URL(requestUrl);
expect(url.searchParams.get('since')).toBe('2024-06-15T12:00:00.000Z');
});
});

View file

@ -0,0 +1,81 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { checkBackendHealth } from '@/services/healthService';
// In jsdom, AbortSignal.timeout() may not exist or may produce signals that
// internal fetch rejects. We mock fetch directly for all health service tests.
describe('healthService', () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('returns healthy when backend responds with OK', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'OK' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const result = await checkBackendHealth();
expect(result.status).toBe('healthy');
expect(result.error).toBeUndefined();
});
it('returns unhealthy on non-ok HTTP response', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 500 }),
);
const result = await checkBackendHealth();
expect(result.status).toBe('unhealthy');
expect(result.error).toBe('HTTP 500');
});
it('returns unhealthy with unexpected response body', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'FAIL' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const result = await checkBackendHealth();
expect(result.status).toBe('unhealthy');
expect(result.error).toBe('Unexpected response');
});
it('measures latency as a non-negative number', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'OK' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const result = await checkBackendHealth();
expect(result.latencyMs).toBeDefined();
expect(typeof result.latencyMs).toBe('number');
expect(result.latencyMs!).toBeGreaterThanOrEqual(0);
});
it('returns unhealthy on network error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
const result = await checkBackendHealth();
expect(result.status).toBe('unhealthy');
expect(result.error).toBe('Failed to fetch');
expect(result.latencyMs).toBeDefined();
});
it('handles AbortError as timeout', async () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
globalThis.fetch = vi.fn().mockRejectedValue(abortError);
const result = await checkBackendHealth();
expect(result.status).toBe('unhealthy');
expect(result.error).toBe('Request timeout');
});
});

View file

@ -0,0 +1,85 @@
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '@/__tests__/mocks/server';
import { http, HttpResponse } from 'msw';
import { mockUser, createMockFeatureCollection } from '@/__tests__/helpers';
import { fetchListingGeoJSON, refreshListings } from '@/services/listingService';
import type { ParameterValues } from '@/components/FilterPanel';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const defaultParams: ParameterValues = {
metric: 'qmprice',
listing_type: 'RENT',
min_bedrooms: 1,
max_bedrooms: 3,
min_price: 1000,
max_price: 5000,
district: '',
};
describe('listingService', () => {
it('fetchListingGeoJSON returns a FeatureCollection', async () => {
const collection = createMockFeatureCollection(3);
server.use(
http.get('/api/listing_geojson', () => {
return HttpResponse.json(collection);
}),
);
const result = await fetchListingGeoJSON(mockUser(), defaultParams);
expect(result.type).toBe('FeatureCollection');
expect(result.features).toHaveLength(3);
});
it('fetchListingGeoJSON sends query parameters', async () => {
let requestUrl = '';
server.use(
http.get('/api/listing_geojson', ({ request }) => {
requestUrl = request.url;
return HttpResponse.json({ type: 'FeatureCollection', features: [] });
}),
);
await fetchListingGeoJSON(mockUser(), defaultParams);
const url = new URL(requestUrl);
expect(url.searchParams.get('listing_type')).toBe('RENT');
expect(url.searchParams.get('min_bedrooms')).toBe('1');
expect(url.searchParams.get('max_bedrooms')).toBe('3');
expect(url.searchParams.get('min_price')).toBe('1000');
expect(url.searchParams.get('max_price')).toBe('5000');
});
it('refreshListings returns task_id', async () => {
server.use(
http.post('/api/refresh_listings', () => {
return HttpResponse.json({ task_id: 'abc-123' });
}),
);
const result = await refreshListings(mockUser(), defaultParams);
expect(result.task_id).toBe('abc-123');
});
it('refreshListings sends query parameters', async () => {
let requestUrl = '';
server.use(
http.post('/api/refresh_listings', ({ request }) => {
requestUrl = request.url;
return HttpResponse.json({ task_id: 'xyz' });
}),
);
await refreshListings(mockUser(), defaultParams);
const url = new URL(requestUrl);
expect(url.searchParams.get('listing_type')).toBe('RENT');
expect(url.searchParams.get('min_bedrooms')).toBe('1');
expect(url.searchParams.get('min_price')).toBe('1000');
});
});

View file

@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mockUser, createMockFeature } from '@/__tests__/helpers';
import { streamListingGeoJSON } from '@/services/streamingService';
import { ApiError } from '@/types';
import type { ParameterValues } from '@/components/FilterPanel';
const defaultParams: ParameterValues = {
metric: 'qmprice',
listing_type: 'RENT',
min_bedrooms: 1,
max_bedrooms: 3,
min_price: 1000,
max_price: 5000,
district: '',
};
function createMockResponse(lines: string[]) {
const body = lines.join('\n') + '\n';
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(body));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { 'Content-Type': 'application/x-ndjson' },
});
}
describe('streamingService', () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('yields feature batches', async () => {
const features = [createMockFeature(), createMockFeature({ rooms: 3 })];
globalThis.fetch = vi.fn().mockResolvedValue(
createMockResponse([
JSON.stringify({ type: 'metadata', total_expected: 2 }),
JSON.stringify({ type: 'batch', features }),
JSON.stringify({ type: 'complete', total: 2 }),
]),
);
const batches: unknown[][] = [];
for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) {
batches.push(batch);
}
expect(batches).toHaveLength(1);
expect(batches[0]).toHaveLength(2);
});
it('calls onProgress with metadata', async () => {
const onProgress = vi.fn();
globalThis.fetch = vi.fn().mockResolvedValue(
createMockResponse([
JSON.stringify({ type: 'metadata', total_expected: 42 }),
JSON.stringify({ type: 'complete', total: 0 }),
]),
);
const gen = streamListingGeoJSON(mockUser(), defaultParams, onProgress);
// Consume the generator
for await (const _ of gen) { /* drain */ }
expect(onProgress).toHaveBeenCalledWith({ count: 0, total: 42 });
});
it('calls onProgress with incrementing count on batches', async () => {
const features1 = [createMockFeature()];
const features2 = [createMockFeature(), createMockFeature()];
const onProgress = vi.fn();
globalThis.fetch = vi.fn().mockResolvedValue(
createMockResponse([
JSON.stringify({ type: 'metadata', total_expected: 3 }),
JSON.stringify({ type: 'batch', features: features1 }),
JSON.stringify({ type: 'batch', features: features2 }),
JSON.stringify({ type: 'complete', total: 3 }),
]),
);
for await (const _ of streamListingGeoJSON(mockUser(), defaultParams, onProgress)) { /* drain */ }
// metadata: count=0, batch1: count=1, batch2: count=3, complete: count=3
expect(onProgress).toHaveBeenCalledWith({ count: 1 });
expect(onProgress).toHaveBeenCalledWith({ count: 3 });
});
it('calls onProgress on complete message', async () => {
const onProgress = vi.fn();
globalThis.fetch = vi.fn().mockResolvedValue(
createMockResponse([
JSON.stringify({ type: 'metadata', total_expected: 5 }),
JSON.stringify({ type: 'complete', total: 5 }),
]),
);
for await (const _ of streamListingGeoJSON(mockUser(), defaultParams, onProgress)) { /* drain */ }
expect(onProgress).toHaveBeenCalledWith({ count: 5, total: 5 });
});
it('handles multiple batches and yields each separately', async () => {
const batch1 = [createMockFeature({ rooms: 1 })];
const batch2 = [createMockFeature({ rooms: 2 })];
globalThis.fetch = vi.fn().mockResolvedValue(
createMockResponse([
JSON.stringify({ type: 'metadata', total_expected: 2 }),
JSON.stringify({ type: 'batch', features: batch1 }),
JSON.stringify({ type: 'batch', features: batch2 }),
JSON.stringify({ type: 'complete', total: 2 }),
]),
);
const batches: unknown[][] = [];
for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) {
batches.push(batch);
}
expect(batches).toHaveLength(2);
expect(batches[0]).toHaveLength(1);
expect(batches[1]).toHaveLength(1);
});
it('handles abort signal', async () => {
const controller = new AbortController();
controller.abort();
globalThis.fetch = vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError'));
const batches: unknown[][] = [];
try {
for await (const batch of streamListingGeoJSON(mockUser(), defaultParams, undefined, { signal: controller.signal })) {
batches.push(batch);
}
} catch {
// Expected: fetch throws on aborted signal
}
expect(batches).toHaveLength(0);
});
it('handles malformed JSON lines without crashing', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const features = [createMockFeature()];
globalThis.fetch = vi.fn().mockResolvedValue(
createMockResponse([
JSON.stringify({ type: 'metadata', total_expected: 1 }),
'this is not valid json{{{',
JSON.stringify({ type: 'batch', features }),
JSON.stringify({ type: 'complete', total: 1 }),
]),
);
const batches: unknown[][] = [];
for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) {
batches.push(batch);
}
// Should still yield the valid batch
expect(batches).toHaveLength(1);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('handles empty stream with no batches', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockResponse([
JSON.stringify({ type: 'metadata', total_expected: 0 }),
JSON.stringify({ type: 'complete', total: 0 }),
]),
);
const batches: unknown[][] = [];
for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) {
batches.push(batch);
}
expect(batches).toHaveLength(0);
});
it('throws ApiError on HTTP error response', async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 401 }),
);
await expect(async () => {
for await (const _ of streamListingGeoJSON(mockUser(), defaultParams)) { /* drain */ }
}).rejects.toThrow(ApiError);
});
it('throws when response body is null', async () => {
const response = new Response(null, { status: 200 });
// Force body to null by creating a response without a body that reports ok
Object.defineProperty(response, 'body', { value: null });
globalThis.fetch = vi.fn().mockResolvedValue(response);
await expect(async () => {
for await (const _ of streamListingGeoJSON(mockUser(), defaultParams)) { /* drain */ }
}).rejects.toThrow('No response body');
});
});

View file

@ -0,0 +1,90 @@
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '@/__tests__/mocks/server';
import { http, HttpResponse } from 'msw';
import { mockUser } from '@/__tests__/helpers';
import { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks } from '@/services/taskService';
import { ApiError } from '@/types';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('taskService', () => {
it('fetchTasksForUser returns task IDs', async () => {
server.use(
http.get('/api/tasks_for_user', () => {
return HttpResponse.json(['task-1', 'task-2']);
}),
);
const result = await fetchTasksForUser(mockUser());
expect(result).toEqual(['task-1', 'task-2']);
});
it('fetchTaskStatus returns task status response', async () => {
server.use(
http.get('/api/task_status', () => {
return HttpResponse.json({
task_id: 'task-42',
status: 'STARTED',
result: null,
progress: 0.5,
processed: 25,
total: 50,
message: 'Processing...',
error: null,
traceback: null,
});
}),
);
const result = await fetchTaskStatus(mockUser(), 'task-42');
expect(result.task_id).toBe('task-42');
expect(result.status).toBe('STARTED');
expect(result.progress).toBe(0.5);
expect(result.processed).toBe(25);
expect(result.total).toBe(50);
expect(result.message).toBe('Processing...');
});
it('cancelTask sends POST and returns success', async () => {
let requestUrl = '';
server.use(
http.post('/api/cancel_task', ({ request }) => {
requestUrl = request.url;
return HttpResponse.json({ success: true, message: 'Task cancelled' });
}),
);
const result = await cancelTask(mockUser(), 'task-99');
expect(result.success).toBe(true);
expect(result.message).toBe('Task cancelled');
expect(requestUrl).toContain('task_id=task-99');
});
it('clearAllTasks sends POST and returns count', async () => {
server.use(
http.post('/api/clear_all_tasks', () => {
return HttpResponse.json({ success: true, count: 5, message: 'Cleared 5 tasks' });
}),
);
const result = await clearAllTasks(mockUser());
expect(result.success).toBe(true);
expect(result.count).toBe(5);
});
it('fetchTaskStatus throws ApiError on server error', async () => {
server.use(
http.get('/api/task_status', () => {
return new HttpResponse(null, { status: 500 });
}),
);
await expect(fetchTaskStatus(mockUser(), 'task-fail')).rejects.toThrow(ApiError);
await expect(fetchTaskStatus(mockUser(), 'task-fail')).rejects.toMatchObject({
statusCode: 500,
});
});
});

19
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
css: false,
include: ['src/**/*.test.{ts,tsx}'],
},
});