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

View file

@ -46,6 +46,7 @@ pytest-cov = "^4.1.0"
httpx = "^0.27.0"
aioresponses = "^0.7.6"
fakeredis = "^2.21.0"
pytest-xdist = "^3.5.0"
mypy = "^1.8.0"
types-requests = "^2.31.0"
types-redis = "^4.6.0"
@ -65,6 +66,11 @@ exclude = ["*.ipynb"]
asyncio_mode = "auto"
testpaths = ["tests"]
asyncio_default_fixture_loop_scope = "function"
markers = [
"regression: locks down existing behavior",
"integration: tests multiple components together",
"e2e: end-to-end workflow tests",
]
[tool.mypy]
python_version = "3.11"

View file

@ -1,7 +1,8 @@
"""Shared pytest fixtures for the test suite."""
from datetime import datetime
from typing import AsyncGenerator, Generator
from typing import Any, AsyncGenerator, Callable, Generator
import pytest
import fakeredis
from sqlalchemy import Engine
from sqlmodel import SQLModel, Session, create_engine
from httpx import ASGITransport, AsyncClient
@ -184,3 +185,59 @@ async def async_client(
# Clean up dependency overrides
app.dependency_overrides.clear()
@pytest.fixture
def fake_redis() -> Generator[fakeredis.FakeRedis, None, None]:
"""Create a fakeredis client, flushed after each test."""
client = fakeredis.FakeRedis(decode_responses=True)
yield client
client.flushall()
@pytest.fixture
def rent_listing_factory() -> Callable[..., RentListing]:
"""Factory function that creates RentListing with overridable defaults."""
_counter = 0
def _create(**overrides: Any) -> RentListing:
nonlocal _counter
_counter += 1
defaults: dict[str, Any] = dict(
id=_counter,
price=2000.0,
number_of_bedrooms=2,
square_meters=55.0,
agency="Test Agency",
council_tax_band="C",
longitude=-0.1276,
latitude=51.5074,
price_history_json="[]",
listing_site=ListingSite.RIGHTMOVE,
last_seen=datetime.now(),
photo_thumbnail="https://example.com/photo.jpg",
floorplan_image_paths=[],
additional_info={"property": {"visible": True}},
routing_info_json=None,
furnish_type=FurnishType.FURNISHED,
available_from=datetime.now(),
)
defaults.update(overrides)
return RentListing(**defaults)
return _create
@pytest.fixture
async def seeded_repository(
in_memory_engine: Engine,
rent_listing_factory: Callable[..., RentListing],
) -> ListingRepository:
"""Repository with 10 pre-seeded listings (varied price/bedrooms/sqm)."""
repo = ListingRepository(engine=in_memory_engine)
listings = [
rent_listing_factory(id=100 + i, price=1000 + i * 300, number_of_bedrooms=(i % 4) + 1, square_meters=30 + i * 10)
for i in range(10)
]
await repo.upsert_listings(listings)
return repo

0
tests/e2e/__init__.py Normal file
View file

View file

@ -0,0 +1,203 @@
"""End-to-end tests for full API workflows."""
import json
from unittest.mock import AsyncMock
import pytest
from httpx import AsyncClient
from sqlalchemy import Engine
from repositories.listing_repository import ListingRepository
pytestmark = pytest.mark.e2e
@pytest.fixture(autouse=True)
def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
import database
import api.app
import api.rate_limiter
monkeypatch.setattr(database, "engine", in_memory_engine)
monkeypatch.setattr(api.app, "engine", in_memory_engine)
# Disable rate limiting for E2E tests
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
@pytest.fixture(autouse=True)
def patch_redis_client(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
def _parse_ndjson(text: str) -> list[dict]:
return [json.loads(line) for line in text.strip().split("\n") if line.strip()]
# ---------- Streaming with filters ----------
@pytest.mark.asyncio
async def test_seed_and_stream_with_filter(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, price=1000 + i * 300, square_meters=40.0)
for i in range(1, 21)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&max_price=3000"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
all_features = [f for b in batches for f in b["features"]]
complete = lines[-1]
for feat in all_features:
assert feat["properties"]["total_price"] <= 3000
assert complete["type"] == "complete"
assert complete["total"] == len(all_features)
@pytest.mark.asyncio
async def test_large_batch_streaming(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, square_meters=40.0)
for i in range(1, 201)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&batch_size=50"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
complete = lines[-1]
assert len(batches) == 4
assert complete["total"] == 200
@pytest.mark.asyncio
async def test_empty_result_set(async_client: AsyncClient) -> None:
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT"
)
lines = _parse_ndjson(resp.text)
assert lines[0]["type"] == "metadata"
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 0
@pytest.mark.asyncio
async def test_refresh_creates_task(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from services.listing_service import RefreshResult
async def fake_refresh(*args, **kwargs):
return RefreshResult(task_id="e2e-task-1", new_listings_count=0, message="started")
monkeypatch.setattr("services.listing_service.refresh_listings", fake_refresh)
monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None)
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
resp = await async_client.post("/api/refresh_listings?listing_type=RENT")
assert resp.status_code == 200
assert resp.json()["task_id"] == "e2e-task-1"
@pytest.mark.asyncio
async def test_cache_populated_on_first_stream(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 6)]
await listing_repository.upsert_listings(listings)
# First stream — cache miss, populated from DB
resp1 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines1 = _parse_ndjson(resp1.text)
assert lines1[0]["cached"] is False
# Second stream — should hit cache
resp2 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines2 = _parse_ndjson(resp2.text)
assert lines2[0]["cached"] is True
@pytest.mark.asyncio
async def test_stream_filter_price(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, price=500 * i, square_meters=40.0)
for i in range(1, 11)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&min_price=1500&max_price=3000"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
all_features = [f for b in batches for f in b["features"]]
for feat in all_features:
assert 1500 <= feat["properties"]["total_price"] <= 3000
@pytest.mark.asyncio
async def test_stream_filter_bedrooms(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [
rent_listing_factory(id=i, number_of_bedrooms=(i % 4) + 1, square_meters=40.0)
for i in range(1, 21)
]
await listing_repository.upsert_listings(listings)
resp = await async_client.get(
"/api/listing_geojson/stream?listing_type=RENT&min_bedrooms=2&max_bedrooms=3"
)
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
all_features = [f for b in batches for f in b["features"]]
for feat in all_features:
assert 2 <= feat["properties"]["rooms"] <= 3
assert len(all_features) > 0
@pytest.mark.asyncio
async def test_complete_total_matches_actual(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 16)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines = _parse_ndjson(resp.text)
batches = [l for l in lines if l["type"] == "batch"]
total_features = sum(len(b["features"]) for b in batches)
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == total_features
assert complete["total"] == 15

View file

@ -0,0 +1,320 @@
"""Integration tests for API workflow endpoints."""
import json
from unittest.mock import AsyncMock
import pytest
from httpx import AsyncClient
from sqlalchemy import Engine
from repositories.listing_repository import ListingRepository
from services.task_service import TaskStatus
@pytest.fixture(autouse=True)
def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch the database engine and disable rate limiting for tests."""
import database
import api.app
import api.rate_limiter
monkeypatch.setattr(database, "engine", in_memory_engine)
monkeypatch.setattr(api.app, "engine", in_memory_engine)
# Disable rate limiting by making _match_endpoint always return None
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
@pytest.fixture(autouse=True)
def patch_redis_for_streaming(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch Redis client used by listing cache so streaming doesn't hit real Redis."""
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
# ---------- Listing query tests ----------
@pytest.mark.asyncio
async def test_listing_returns_inserted_data(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i) for i in range(1, 4)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing?limit=10")
assert resp.status_code == 200
data = resp.json()
assert len(data["listings"]) == 3
@pytest.mark.asyncio
async def test_listing_respects_limit(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i) for i in range(1, 6)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing?limit=2")
assert resp.status_code == 200
assert len(resp.json()["listings"]) == 2
@pytest.mark.asyncio
async def test_geojson_returns_feature_collection(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=50.0) for i in range(1, 4)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "FeatureCollection"
assert len(data["features"]) == 3
@pytest.mark.asyncio
async def test_geojson_features_have_properties(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=1, price=2500, number_of_bedrooms=2, square_meters=60.0)
await listing_repository.upsert_listings([listing])
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert resp.status_code == 200
features = resp.json()["features"]
assert len(features) == 1
feat = features[0]
assert feat["geometry"]["type"] == "Point"
props = feat["properties"]
for key in ("url", "total_price", "rooms", "qm", "qmprice", "agency", "last_seen"):
assert key in props, f"Missing property: {key}"
# ---------- Streaming tests ----------
def _parse_ndjson(text: str) -> list[dict]:
return [json.loads(line) for line in text.strip().split("\n") if line.strip()]
@pytest.mark.asyncio
async def test_stream_metadata_batch_complete(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 4)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
assert resp.status_code == 200
lines = _parse_ndjson(resp.text)
assert lines[0]["type"] == "metadata"
batches = [l for l in lines if l["type"] == "batch"]
assert len(batches) >= 1
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 3
@pytest.mark.asyncio
async def test_stream_respects_limit(
async_client: AsyncClient,
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 11)]
await listing_repository.upsert_listings(listings)
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT&limit=3")
lines = _parse_ndjson(resp.text)
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 3
@pytest.mark.asyncio
async def test_stream_empty_db(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
lines = _parse_ndjson(resp.text)
assert lines[0]["type"] == "metadata"
complete = lines[-1]
assert complete["type"] == "complete"
assert complete["total"] == 0
# ---------- Task management tests ----------
@pytest.mark.asyncio
async def test_refresh_returns_task_id(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from services.listing_service import RefreshResult
async def fake_refresh(*args, **kwargs):
return RefreshResult(task_id="test-123", new_listings_count=0, message="Task started")
import services.listing_service
monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh)
monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None)
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
resp = await async_client.post("/api/refresh_listings?listing_type=RENT")
assert resp.status_code == 200
data = resp.json()
assert data["task_id"] == "test-123"
@pytest.mark.asyncio
async def test_task_tracked_for_user(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from services.listing_service import RefreshResult
async def fake_refresh(*args, **kwargs):
return RefreshResult(task_id="task-abc", new_listings_count=0, message="ok")
import services.listing_service
monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh)
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
calls: list[tuple[str, str]] = []
import services.task_service
monkeypatch.setattr(
services.task_service,
"add_task_for_user",
lambda email, tid: calls.append((email, tid)),
)
await async_client.post("/api/refresh_listings?listing_type=RENT")
assert len(calls) == 1
assert calls[0] == ("test@example.com", "task-abc")
@pytest.mark.asyncio
async def test_task_status_works(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"])
monkeypatch.setattr(
"services.task_service.get_task_status",
lambda task_id: TaskStatus(
task_id="test-123",
status="SUCCESS",
result=None,
progress=1.0,
processed=10,
total=10,
message="Done",
error=None,
traceback=None,
),
)
resp = await async_client.get("/api/task_status?task_id=test-123")
assert resp.status_code == 200
data = resp.json()
assert data["task_id"] == "test-123"
assert data["status"] == "SUCCESS"
assert data["progress"] == 1.0
@pytest.mark.asyncio
async def test_task_not_found_returns_404(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: [])
resp = await async_client.get("/api/task_status?task_id=unknown")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_cancel_task(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"])
monkeypatch.setattr("services.task_service.cancel_task", lambda task_id, user_email=None: True)
resp = await async_client.post("/api/cancel_task?task_id=test-123")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
@pytest.mark.asyncio
async def test_clear_all_tasks(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.clear_all_tasks", lambda email: 3)
resp = await async_client.post("/api/clear_all_tasks")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 3
assert data["success"] is True
# ---------- Additional edge cases ----------
@pytest.mark.asyncio
async def test_listing_empty_db_returns_empty(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/listing?limit=10")
assert resp.status_code == 200
assert resp.json()["listings"] == []
@pytest.mark.asyncio
async def test_geojson_empty_db_returns_empty_collection(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "FeatureCollection"
assert len(data["features"]) == 0
@pytest.mark.asyncio
async def test_cancel_task_not_owned(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: [])
resp = await async_client.post("/api/cancel_task?task_id=not-mine")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
@pytest.mark.asyncio
async def test_tasks_for_user(
async_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["a", "b", "c"])
resp = await async_client.get("/api/tasks_for_user")
assert resp.status_code == 200
assert resp.json() == ["a", "b", "c"]
@pytest.mark.asyncio
async def test_status_endpoint(async_client: AsyncClient) -> None:
resp = await async_client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["status"] == "OK"

View file

@ -0,0 +1,132 @@
"""Integration tests for Redis-based listing cache."""
import pytest
from models.listing import ListingType, QueryParameters
from services.listing_cache import (
begin_cache_population,
cache_features_batch,
cache_features_batch_staged,
delete_staging_key,
finalize_cache_population,
get_cached_count,
get_cached_features,
invalidate_cache,
make_cache_key,
)
@pytest.fixture(autouse=True)
def patch_redis(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
"""Route all cache operations through fakeredis."""
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
def _make_qp(**kwargs) -> QueryParameters:
return QueryParameters(listing_type=ListingType.RENT, **kwargs)
def _sample_features(n: int) -> list[dict]:
return [{"type": "Feature", "id": i, "properties": {"price": 1000 + i}} for i in range(n)]
# ---------- Basic read/write ----------
def test_cache_miss_returns_none() -> None:
qp = _make_qp()
assert get_cached_count(qp) is None
def test_cache_write_then_read() -> None:
qp = _make_qp()
features = _sample_features(5)
cache_features_batch(qp, features)
count = get_cached_count(qp)
assert count == 5
def test_batch_retrieval() -> None:
qp = _make_qp()
cache_features_batch(qp, _sample_features(10))
batches = list(get_cached_features(qp, batch_size=3))
sizes = [len(b) for b in batches]
assert sizes == [3, 3, 3, 1]
# ---------- Cache key behaviour ----------
def test_cache_key_deterministic() -> None:
qp1 = _make_qp()
qp2 = _make_qp()
assert make_cache_key(qp1) == make_cache_key(qp2)
def test_cache_key_different_for_different_params() -> None:
rent = _make_qp()
buy = QueryParameters(listing_type=ListingType.BUY)
assert make_cache_key(rent) != make_cache_key(buy)
# ---------- Staged population ----------
def test_staged_population_begin() -> None:
qp = _make_qp()
staging_key = begin_cache_population(qp)
assert isinstance(staging_key, str)
assert "staging" in staging_key
def test_staged_write_then_finalize() -> None:
qp = _make_qp()
staging_key = begin_cache_population(qp)
cache_features_batch_staged(staging_key, _sample_features(4))
finalize_cache_population(staging_key, qp)
assert get_cached_count(qp) == 4
def test_staging_key_deleted_on_cleanup(fake_redis) -> None:
qp = _make_qp()
staging_key = begin_cache_population(qp)
cache_features_batch_staged(staging_key, _sample_features(2))
delete_staging_key(staging_key)
assert fake_redis.exists(staging_key) == 0
# ---------- Invalidation ----------
def test_invalidation() -> None:
qp = _make_qp()
cache_features_batch(qp, _sample_features(5))
assert get_cached_count(qp) == 5
invalidate_cache()
assert get_cached_count(qp) is None
# ---------- Edge cases ----------
def test_empty_features_batch_noop() -> None:
qp = _make_qp()
cache_features_batch(qp, [])
assert get_cached_count(qp) is None
def test_multiple_batches_accumulate() -> None:
qp = _make_qp()
cache_features_batch(qp, _sample_features(3))
cache_features_batch(qp, _sample_features(4))
assert get_cached_count(qp) == 7
def test_get_cached_features_empty() -> None:
qp = _make_qp()
batches = list(get_cached_features(qp))
assert batches == []

View file

@ -0,0 +1,186 @@
"""Integration tests for ListingProcessor and processing steps."""
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import Engine
from listing_processor import (
DetectFloorplanStep,
FetchImagesStep,
FetchListingDetailsStep,
ListingProcessor,
)
from models.listing import ListingType
from repositories.listing_repository import ListingRepository
# ---------- Processor structure tests ----------
def test_processor_has_three_steps(listing_repository: ListingRepository) -> None:
processor = ListingProcessor(listing_repository)
assert len(processor.process_steps) == 3
def test_step_order(listing_repository: ListingRepository) -> None:
processor = ListingProcessor(listing_repository)
types = [type(s) for s in processor.process_steps]
assert types == [FetchListingDetailsStep, FetchImagesStep, DetectFloorplanStep]
# ---------- Processing flow ----------
@pytest.mark.asyncio
async def test_process_calls_steps_in_order(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
# Seed a listing so mark_seen doesn't fail
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
call_order: list[str] = []
for step in processor.process_steps:
name = type(step).__name__
step.needs_processing = AsyncMock(return_value=True)
step.process = AsyncMock(
side_effect=lambda lid, n=name: call_order.append(n) or listing
)
result = await processor.process_listing(42)
assert result is not None
assert call_order == [
"FetchListingDetailsStep",
"FetchImagesStep",
"DetectFloorplanStep",
]
@pytest.mark.asyncio
async def test_step_failure_stops_pipeline(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
processor.process_steps[0].needs_processing = AsyncMock(return_value=True)
processor.process_steps[0].process = AsyncMock(side_effect=RuntimeError("boom"))
processor.process_steps[1].needs_processing = AsyncMock(return_value=True)
processor.process_steps[1].process = AsyncMock()
processor.process_steps[2].needs_processing = AsyncMock(return_value=True)
processor.process_steps[2].process = AsyncMock()
result = await processor.process_listing(42)
assert result is None
# Second and third steps should not have been called
processor.process_steps[1].process.assert_not_called()
processor.process_steps[2].process.assert_not_called()
@pytest.mark.asyncio
async def test_callback_fired_per_step(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
for step in processor.process_steps:
step.needs_processing = AsyncMock(return_value=True)
step.process = AsyncMock(return_value=listing)
callback_args: list[str] = []
await processor.process_listing(42, on_step_complete=lambda name: callback_args.append(name))
assert callback_args == ["details", "images", "ocr"]
@pytest.mark.asyncio
async def test_step_skipped_when_not_needed(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
listing = rent_listing_factory(id=42)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
for step in processor.process_steps:
step.needs_processing = AsyncMock(return_value=False)
step.process = AsyncMock()
await processor.process_listing(42)
for step in processor.process_steps:
step.process.assert_not_called()
# ---------- Individual step tests ----------
@pytest.mark.asyncio
async def test_fetch_details_creates_listing(
listing_repository: ListingRepository,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sample_detail = {
"property": {
"price": 2000,
"bedrooms": 2,
"branch": {"brandName": "Test Agency"},
"councilTaxInfo": {"content": [{"value": "C"}]},
"longitude": -0.1,
"latitude": 51.5,
"photos": [{"thumbnailUrl": "https://example.com/photo.jpg"}],
"floorplans": [],
"letFurnishType": "furnished",
"letDateAvailable": "Now",
"visible": True,
}
}
monkeypatch.setattr("listing_processor.detail_query", AsyncMock(return_value=sample_detail))
step = FetchListingDetailsStep(listing_repository, ListingType.RENT)
result = await step.process(999)
assert result is not None
assert result.id == 999
assert result.price == 2000
# Verify it was persisted
stored = await listing_repository.get_listings(only_ids=[999])
assert len(stored) == 1
@pytest.mark.asyncio
async def test_processor_marks_seen(
listing_repository: ListingRepository,
rent_listing_factory,
) -> None:
from datetime import datetime, timedelta
old_time = datetime(2020, 1, 1)
listing = rent_listing_factory(id=50, last_seen=old_time)
await listing_repository.upsert_listings([listing])
processor = ListingProcessor(listing_repository)
# Skip all steps so we only test mark_seen
for step in processor.process_steps:
step.needs_processing = AsyncMock(return_value=False)
step.process = AsyncMock()
await processor.process_listing(50)
updated = await listing_repository.get_listings(only_ids=[50])
assert len(updated) == 1
# last_seen should have been updated to roughly now
assert updated[0].last_seen > old_time

View file

@ -0,0 +1,122 @@
"""Advanced integration tests for ListingRepository."""
import asyncio
import pytest
from sqlalchemy import Engine
from models.listing import FurnishType, ListingType, QueryParameters
from repositories.listing_repository import ListingRepository
# ---------- Count and basic queries ----------
@pytest.mark.asyncio
async def test_count_matches_get_listings(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT)
count = seeded_repository.count_listings(qp)
listings = await seeded_repository.get_listings(query_parameters=qp)
assert count == len(listings)
@pytest.mark.asyncio
async def test_stream_with_small_page_size(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT)
rows = list(seeded_repository.stream_listings_optimized(qp, page_size=3))
assert len(rows) == 10
# ---------- Filter tests ----------
@pytest.mark.asyncio
async def test_filter_by_bedrooms(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2, max_bedrooms=2)
listings = await seeded_repository.get_listings(query_parameters=qp)
for listing in listings:
assert listing.number_of_bedrooms == 2
assert len(listings) > 0
@pytest.mark.asyncio
async def test_filter_by_price_range(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT, min_price=1500, max_price=2500)
listings = await seeded_repository.get_listings(query_parameters=qp)
for listing in listings:
assert 1500 <= listing.price <= 2500
assert len(listings) > 0
@pytest.mark.asyncio
async def test_filter_by_max_sqm(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT, max_sqm=50)
listings = await seeded_repository.get_listings(query_parameters=qp)
for listing in listings:
assert listing.square_meters is not None
assert listing.square_meters <= 50
assert len(listings) > 0
@pytest.mark.asyncio
async def test_filter_by_furnish_type(
in_memory_engine: Engine,
rent_listing_factory,
) -> None:
repo = ListingRepository(engine=in_memory_engine)
furnished = rent_listing_factory(id=1, furnish_type=FurnishType.FURNISHED)
unfurnished = rent_listing_factory(id=2, furnish_type=FurnishType.UNFURNISHED)
await repo.upsert_listings([furnished, unfurnished])
qp = QueryParameters(
listing_type=ListingType.RENT,
furnish_types=[FurnishType.FURNISHED],
)
listings = await repo.get_listings(query_parameters=qp)
assert len(listings) == 1
assert listings[0].furnish_type == FurnishType.FURNISHED
# ---------- Concurrency ----------
@pytest.mark.asyncio
async def test_concurrent_upserts(
in_memory_engine: Engine,
rent_listing_factory,
) -> None:
repo = ListingRepository(engine=in_memory_engine)
async def upsert_batch(start_id: int) -> None:
listings = [rent_listing_factory(id=start_id + i) for i in range(5)]
await repo.upsert_listings(listings)
await asyncio.gather(
upsert_batch(1000),
upsert_batch(2000),
upsert_batch(3000),
upsert_batch(4000),
upsert_batch(5000),
)
qp = QueryParameters(listing_type=ListingType.RENT)
total = repo.count_listings(qp)
assert total == 25
# ---------- Streaming ----------
@pytest.mark.asyncio
async def test_stream_optimized_returns_dicts(seeded_repository: ListingRepository) -> None:
qp = QueryParameters(listing_type=ListingType.RENT)
rows = list(seeded_repository.stream_listings_optimized(qp))
assert len(rows) > 0
for row in rows:
assert isinstance(row, dict)
assert "id" in row
assert "price" in row
assert "number_of_bedrooms" in row
assert "square_meters" in row
assert "longitude" in row
assert "latitude" in row

View file

View file

@ -0,0 +1,135 @@
"""Regression tests for API response contracts.
These tests lock down the shape of API responses to prevent
accidental breaking changes.
"""
import json
import pytest
from httpx import ASGITransport, AsyncClient
from unittest.mock import MagicMock, patch, AsyncMock
pytestmark = pytest.mark.regression
@pytest.fixture(autouse=True)
def disable_rate_limiting(monkeypatch):
"""Disable rate limiting for all regression tests."""
import api.rate_limiter
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
@pytest.fixture
async def unauthenticated_client(in_memory_engine):
"""Client without mock auth — requests should be rejected."""
from api.app import app
import database
original_engine = database.engine
database.engine = in_memory_engine
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
database.engine = original_engine
app.dependency_overrides.clear()
class TestStatusEndpoint:
@pytest.mark.asyncio
async def test_status_returns_ok(self, async_client):
response = await async_client.get("/api/status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "OK"
class TestListingEndpoint:
@pytest.mark.asyncio
async def test_listing_has_listings_key(self, async_client):
response = await async_client.get("/api/listing?limit=5")
assert response.status_code == 200
data = response.json()
assert "listings" in data
class TestListingGeojsonEndpoint:
@pytest.mark.asyncio
async def test_listing_geojson_has_feature_collection_shape(self, async_client):
response = await async_client.get("/api/listing_geojson?listing_type=RENT")
assert response.status_code == 200
data = response.json()
assert data.get("type") == "FeatureCollection"
assert "features" in data
class TestStreamEndpoint:
@pytest.mark.asyncio
async def test_stream_first_line_is_metadata(self, async_client):
response = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
assert response.status_code == 200
lines = [line for line in response.text.strip().split("\n") if line.strip()]
assert len(lines) >= 1
first = json.loads(lines[0])
assert first.get("type") == "metadata"
assert "batch_size" in first
assert "total_expected" in first
assert "cached" in first
@pytest.mark.asyncio
async def test_stream_last_line_is_complete(self, async_client):
response = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
assert response.status_code == 200
lines = [line for line in response.text.strip().split("\n") if line.strip()]
assert len(lines) >= 1
last = json.loads(lines[-1])
assert last.get("type") == "complete"
assert "total" in last
class TestTaskStatusEndpoint:
@pytest.mark.asyncio
async def test_task_status_response_shape(self, async_client):
from services.task_service import TaskStatus
mock_status = TaskStatus(
task_id="test-123",
status="SUCCESS",
result=None,
progress=1.0,
processed=10,
total=10,
message="Done",
error=None,
traceback=None,
)
with patch("services.task_service.get_task_status", return_value=mock_status), \
patch("services.task_service.get_user_tasks", return_value=["test-123"]):
response = await async_client.get("/api/task_status?task_id=test-123")
assert response.status_code == 200
data = response.json()
for key in ["task_id", "status", "result", "progress", "processed", "total", "message", "error", "traceback"]:
assert key in data, f"Missing key: {key}"
class TestUnauthenticatedAccess:
@pytest.mark.asyncio
@pytest.mark.parametrize("method,path", [
("GET", "/api/listing"),
("GET", "/api/listing_geojson?listing_type=RENT"),
("GET", "/api/listing_geojson/stream?listing_type=RENT"),
("GET", "/api/task_status?task_id=test"),
("GET", "/api/tasks_for_user"),
("POST", "/api/refresh_listings?listing_type=RENT"),
("POST", "/api/cancel_task?task_id=test"),
("POST", "/api/clear_all_tasks"),
])
async def test_unauthenticated_endpoints_return_error(
self, unauthenticated_client, method, path
):
if method == "GET":
response = await unauthenticated_client.get(path)
else:
response = await unauthenticated_client.post(path)
assert response.status_code in (401, 403), (
f"{method} {path} returned {response.status_code}, expected 401 or 403"
)

View file

@ -0,0 +1,75 @@
"""Regression tests for QueryParameters model and API query parsing."""
import pytest
from datetime import datetime, timezone
from httpx import ASGITransport, AsyncClient
from unittest.mock import patch
from models.listing import QueryParameters, ListingType, FurnishType
pytestmark = pytest.mark.regression
class TestQueryParametersModel:
def test_defaults_applied(self):
params = QueryParameters(listing_type=ListingType.RENT)
assert params.min_bedrooms == 1
assert params.max_bedrooms == 999
assert params.listing_type == ListingType.RENT
def test_datetime_z_suffix_parsing(self):
params = QueryParameters(
listing_type=ListingType.RENT,
let_date_available_from="2024-01-15T00:00:00Z",
)
assert params.let_date_available_from is not None
assert isinstance(params.let_date_available_from, datetime)
def test_datetime_offset_parsing(self):
params = QueryParameters(
listing_type=ListingType.RENT,
let_date_available_from="2024-01-15T00:00:00+00:00",
)
assert params.let_date_available_from is not None
assert isinstance(params.let_date_available_from, datetime)
def test_min_price_greater_than_max_raises(self):
with pytest.raises((ValueError, Exception)):
QueryParameters(
listing_type=ListingType.RENT,
min_price=5000,
max_price=1000,
)
def test_min_bedrooms_greater_than_max_raises(self):
with pytest.raises((ValueError, Exception)):
QueryParameters(
listing_type=ListingType.RENT,
min_bedrooms=5,
max_bedrooms=2,
)
class TestQueryParametersApiParsing:
@pytest.mark.asyncio
async def test_comma_separated_furnish_types(self, async_client):
response = await async_client.get(
"/api/listing?listing_type=RENT&furnish_types=furnished,unfurnished"
)
# If the endpoint accepts the param, it should return 200
assert response.status_code == 200
@pytest.mark.asyncio
async def test_comma_separated_district_names(self, async_client):
response = await async_client.get(
"/api/listing?listing_type=RENT&district_names=London,Camden"
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_invalid_listing_type_returns_422(self, async_client):
response = await async_client.get(
"/api/listing_geojson?listing_type=INVALID_TYPE"
)
assert response.status_code == 422

View file

@ -0,0 +1,34 @@
"""Unit tests for services/district_service.py."""
import pytest
from services import district_service
class TestGetAllDistricts:
def test_get_all_districts_returns_dict(self):
result = district_service.get_all_districts()
assert isinstance(result, dict)
assert len(result) > 0
class TestGetDistrictNames:
def test_get_district_names_returns_list(self):
result = district_service.get_district_names()
assert isinstance(result, list)
assert len(result) > 0
class TestValidateDistricts:
def test_validate_districts_all_valid(self):
result = district_service.validate_districts(["London", "Westminster"])
assert result == []
def test_validate_districts_returns_invalid(self):
result = district_service.validate_districts(["London", "Narnia"])
assert "Narnia" in result
def test_known_districts_present(self):
names = district_service.get_district_names()
for district in ["London", "Westminster", "Camden"]:
assert district in names

View file

@ -0,0 +1,87 @@
"""Unit tests for services/export_service.py."""
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from models.listing import QueryParameters, ListingType
from services import export_service
class TestExportToCsv:
async def test_csv_export_calls_exporter(self, listing_repository, tmp_path):
output_path = tmp_path / "output.csv"
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
mock_csv.return_value = None
result = await export_service.export_to_csv(listing_repository, output_path)
mock_csv.assert_called_once()
assert result.success
async def test_csv_export_returns_correct_record_count(self, listing_repository, sample_rent_listings, tmp_path):
await listing_repository.upsert_listings(sample_rent_listings)
output_path = tmp_path / "output.csv"
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
mock_csv.return_value = None
result = await export_service.export_to_csv(listing_repository, output_path)
assert result.record_count == len(sample_rent_listings)
async def test_csv_export_passes_query_parameters(self, listing_repository, tmp_path):
output_path = tmp_path / "output.csv"
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2)
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
mock_csv.return_value = None
result = await export_service.export_to_csv(
listing_repository, output_path, query_parameters=params
)
assert result.success
assert str(output_path) in result.output_path
class TestExportToGeojson:
async def test_geojson_in_memory_returns_data(self, listing_repository):
fake_geojson = {"type": "FeatureCollection", "features": [{"type": "Feature"}]}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(listing_repository)
assert result.data is not None
assert result.data["type"] == "FeatureCollection"
async def test_geojson_file_export_returns_path(self, listing_repository, tmp_path):
output_path = tmp_path / "output.geojson"
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(
listing_repository, output_path=output_path
)
assert result.output_path is not None
assert result.data is None
async def test_geojson_with_filters(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2)
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(
listing_repository, query_parameters=params
)
assert result.success
mock_export.assert_called_once()
async def test_geojson_with_limit(self, listing_repository):
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(
listing_repository, limit=5
)
assert result.success
_, kwargs = mock_export.call_args
assert kwargs.get("limit") == 5
async def test_geojson_empty_data(self, listing_repository):
fake_geojson = {"type": "FeatureCollection", "features": []}
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
mock_export.return_value = fake_geojson
result = await export_service.export_to_geojson(listing_repository)
assert result.record_count == 0

View file

@ -0,0 +1,129 @@
"""Unit tests for services/listing_service.py."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from models.listing import QueryParameters, ListingType
from services import listing_service
class TestGetListings:
async def test_empty_db_returns_zero(self, listing_repository):
result = await listing_service.get_listings(listing_repository)
assert result.total_count == 0
assert result.listings == []
async def test_with_results_returns_correct_count(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
result = await listing_service.get_listings(listing_repository)
assert result.total_count == len(sample_rent_listings)
assert len(result.listings) == len(sample_rent_listings)
async def test_with_query_parameters_filters(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=3)
result = await listing_service.get_listings(listing_repository, query_parameters=params)
for listing in result.listings:
assert listing.number_of_bedrooms >= 3
async def test_limit_works(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
result = await listing_service.get_listings(listing_repository, limit=1)
assert len(result.listings) <= 1
async def test_only_ids_works(self, listing_repository, sample_rent_listings):
await listing_repository.upsert_listings(sample_rent_listings)
target_ids = [sample_rent_listings[0].id]
result = await listing_service.get_listings(listing_repository, only_ids=target_ids)
assert all(l.id in target_ids for l in result.listings)
class TestRefreshListings:
async def test_async_mode_dispatches_celery_task(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("tasks.listing_tasks.dump_listings_task") as mock_task:
mock_task.apply_async.return_value = MagicMock(id="fake-task-id")
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=True, user_email="test@example.com"
)
mock_task.apply_async.assert_called_once()
assert result.task_id == "fake-task-id"
async def test_sync_mode_calls_fetcher(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump:
mock_dump.return_value = []
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=False
)
mock_dump.assert_called_once()
assert result.task_id is None
async def test_full_mode_calls_dump_listings_full(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("services.listing_fetcher.dump_listings_full", new_callable=AsyncMock) as mock_full:
mock_full.return_value = []
result = await listing_service.refresh_listings(
listing_repository, params, full=True, async_mode=False
)
mock_full.assert_called_once()
async def test_sync_returns_new_listings_count(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
fake_listings = [MagicMock(), MagicMock(), MagicMock()]
with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump:
mock_dump.return_value = fake_listings
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=False
)
assert result.new_listings_count == 3
async def test_async_result_has_message(self, listing_repository):
params = QueryParameters(listing_type=ListingType.RENT)
with patch("tasks.listing_tasks.dump_listings_task") as mock_task:
mock_task.apply_async.return_value = MagicMock(id="fake-task-id")
result = await listing_service.refresh_listings(
listing_repository, params, async_mode=True
)
assert result.message is not None
assert len(result.message) > 0
class TestDownloadImages:
async def test_calls_image_fetcher(self, listing_repository):
with patch("services.image_fetcher.dump_images", new_callable=AsyncMock) as mock_dump:
mock_dump.return_value = None
result = await listing_service.download_images(listing_repository)
mock_dump.assert_called_once()
class TestDetectFloorplans:
async def test_calls_floorplan_detector(self, listing_repository):
with patch("services.floorplan_detector.detect_floorplan", new_callable=AsyncMock) as mock_detect:
mock_detect.return_value = None
result = await listing_service.detect_floorplans(listing_repository)
mock_detect.assert_called_once()
class TestCalculateRoutes:
async def test_passes_correct_travel_mode(self, listing_repository):
with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc:
mock_calc.return_value = None
result = await listing_service.calculate_routes(
listing_repository,
destination_address="London Bridge",
travel_mode="TRANSIT",
limit=10,
)
mock_calc.assert_called_once()
async def test_passes_limit(self, listing_repository):
with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc:
mock_calc.return_value = None
result = await listing_service.calculate_routes(
listing_repository,
destination_address="Kings Cross",
travel_mode="TRANSIT",
limit=5,
)
assert result == 5