Add comprehensive test suite: 219 new tests across backend and frontend
Backend (103 tests): - Unit tests for listing_service, export_service, district_service - Regression tests for API response contracts and query parameter validation - Integration tests for API workflows, Redis listing cache, listing processor pipeline, and repository advanced queries - E2E tests for streaming with filters, batching, caching, and task management Frontend (116 tests): - Service tests for apiClient, streamingService, taskService, listingService, healthService - Hook tests for useTaskProgress (WebSocket + polling) - Component tests for PropertyCard, FilterPanel, Header, ListView, TaskProgressDrawer, TaskIndicator, StreamingProgressBar, HealthIndicator - E2E tests for filter-stream-display flow Infrastructure: - Add pytest-xdist and test markers (regression, integration, e2e) - Add conftest fixtures: fake_redis, rent_listing_factory, seeded_repository - Add vitest + testing-library + MSW for frontend testing
This commit is contained in:
parent
a3ac9cc060
commit
8d22c97320
36 changed files with 5447 additions and 19 deletions
2050
frontend/package-lock.json
generated
2050
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
frontend/src/__tests__/e2e/filter-stream-display.test.tsx
Normal file
78
frontend/src/__tests__/e2e/filter-stream-display.test.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { StreamingProgressBar } from '@/components/StreamingProgressBar';
|
||||
import { ListView } from '@/components/ListView';
|
||||
import { createMockFeatureCollection } from '@/__tests__/helpers';
|
||||
import type { StreamingProgress } from '@/services';
|
||||
|
||||
// Mock react-virtuoso for ListView
|
||||
vi.mock('react-virtuoso', () => ({
|
||||
Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (index: number, item: unknown) => React.ReactNode }) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data.map((item, index) => (
|
||||
<div key={index}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('Filter → Stream → Display Integration', () => {
|
||||
it('shows StreamingProgressBar when isLoading is true', () => {
|
||||
render(
|
||||
<StreamingProgressBar progress={null} isLoading />,
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ListView shows listings after stream completes', () => {
|
||||
const data = createMockFeatureCollection(5);
|
||||
render(<ListView listingData={data} />);
|
||||
expect(screen.getByText(/5\s*properties/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('listing count updates when features change', () => {
|
||||
const data2 = createMockFeatureCollection(2);
|
||||
const data7 = createMockFeatureCollection(7);
|
||||
|
||||
const { rerender } = render(<ListView listingData={data2} />);
|
||||
expect(screen.getByText(/2\s*properties/)).toBeInTheDocument();
|
||||
|
||||
rerender(<ListView listingData={data7} />);
|
||||
expect(screen.getByText(/7\s*properties/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('empty state renders when no features', () => {
|
||||
const empty = createMockFeatureCollection(0);
|
||||
render(<ListView listingData={empty} />);
|
||||
expect(screen.getByText(/0\s*properties/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('StreamingProgressBar hides when loading completes', () => {
|
||||
const { rerender, container } = render(
|
||||
<StreamingProgressBar progress={{ count: 50, total: 100 }} isLoading />,
|
||||
);
|
||||
expect(screen.getByText('Loading listings...')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<StreamingProgressBar progress={{ count: 100, total: 100 }} isLoading={false} />,
|
||||
);
|
||||
// Component returns null when not loading
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('progress updates are reflected in StreamingProgressBar', () => {
|
||||
const progress1: StreamingProgress = { count: 10, total: 100 };
|
||||
const progress2: StreamingProgress = { count: 75, total: 100 };
|
||||
|
||||
const { rerender, container } = render(
|
||||
<StreamingProgressBar progress={progress1} isLoading />,
|
||||
);
|
||||
let bar = container.querySelector('.bg-primary.transition-all');
|
||||
expect(bar).toHaveStyle({ width: '10%' });
|
||||
|
||||
rerender(
|
||||
<StreamingProgressBar progress={progress2} isLoading />,
|
||||
);
|
||||
bar = container.querySelector('.bg-primary.transition-all');
|
||||
expect(bar).toHaveStyle({ width: '75%' });
|
||||
});
|
||||
});
|
||||
89
frontend/src/__tests__/helpers.ts
Normal file
89
frontend/src/__tests__/helpers.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { AuthUser } from '@/auth/types';
|
||||
import type { PropertyProperties, PropertyFeature, GeoJSONFeatureCollection, TaskState } from '@/types';
|
||||
|
||||
/**
|
||||
* Create a mock AuthUser for testing
|
||||
*/
|
||||
export function mockUser(overrides: Partial<AuthUser> = {}): AuthUser {
|
||||
return {
|
||||
sub: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
accessToken: 'test-access-token',
|
||||
provider: 'oidc',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock PropertyProperties object
|
||||
*/
|
||||
export function createMockProperty(overrides: Partial<PropertyProperties> = {}): PropertyProperties {
|
||||
return {
|
||||
url: 'https://www.rightmove.co.uk/properties/12345678',
|
||||
city: 'London',
|
||||
country: 'UK',
|
||||
qm: 65,
|
||||
qmprice: 38.46,
|
||||
total_price: 2500,
|
||||
rooms: 2,
|
||||
agency: 'Test Agency',
|
||||
available_from: '2024-06-01',
|
||||
last_seen: new Date().toISOString(),
|
||||
photo_thumbnail: 'https://example.com/photo.jpg',
|
||||
price_history: [],
|
||||
listing_type: 'RENT',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock GeoJSON Feature
|
||||
*/
|
||||
export function createMockFeature(overrides: Partial<PropertyProperties> = {}): PropertyFeature {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-0.1276, 51.5074],
|
||||
},
|
||||
properties: createMockProperty(overrides),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock FeatureCollection
|
||||
*/
|
||||
export function createMockFeatureCollection(
|
||||
count: number = 3,
|
||||
propertyOverrides: Partial<PropertyProperties> = {},
|
||||
): GeoJSONFeatureCollection {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: Array.from({ length: count }, (_, i) =>
|
||||
createMockFeature({
|
||||
url: `https://www.rightmove.co.uk/properties/${1000 + i}`,
|
||||
total_price: 1500 + i * 500,
|
||||
rooms: (i % 3) + 1,
|
||||
qm: 30 + i * 15,
|
||||
...propertyOverrides,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock TaskState
|
||||
*/
|
||||
export function createMockTaskState(overrides: Partial<TaskState> = {}): TaskState {
|
||||
return {
|
||||
task_id: 'test-task-123',
|
||||
status: 'STARTED',
|
||||
progress: 0.5,
|
||||
processed: 50,
|
||||
total: 100,
|
||||
phase: 'fetching',
|
||||
message: 'Fetching listings',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
77
frontend/src/__tests__/mocks/handlers.ts
Normal file
77
frontend/src/__tests__/mocks/handlers.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const handlers = [
|
||||
// Health check
|
||||
http.get('/api/status', () => {
|
||||
return HttpResponse.json({ status: 'OK' });
|
||||
}),
|
||||
|
||||
// Get listings
|
||||
http.get('/api/listing', () => {
|
||||
return HttpResponse.json({ listings: [] });
|
||||
}),
|
||||
|
||||
// Get listing GeoJSON
|
||||
http.get('/api/listing_geojson', () => {
|
||||
return HttpResponse.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
});
|
||||
}),
|
||||
|
||||
// Stream listing GeoJSON
|
||||
http.get('/api/listing_geojson/stream', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'metadata', batch_size: 50, total_expected: 0, cached: false }),
|
||||
JSON.stringify({ type: 'complete', total: 0 }),
|
||||
].join('\n') + '\n';
|
||||
|
||||
return new HttpResponse(lines, {
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
});
|
||||
}),
|
||||
|
||||
// Refresh listings
|
||||
http.post('/api/refresh_listings', () => {
|
||||
return HttpResponse.json({ task_id: 'test-task-123', message: 'Task started' });
|
||||
}),
|
||||
|
||||
// Task status
|
||||
http.get('/api/task_status', () => {
|
||||
return HttpResponse.json({
|
||||
task_id: 'test-task-123',
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
progress: null,
|
||||
processed: null,
|
||||
total: null,
|
||||
message: null,
|
||||
error: null,
|
||||
traceback: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// Tasks for user
|
||||
http.get('/api/tasks_for_user', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
|
||||
// Cancel task
|
||||
http.post('/api/cancel_task', () => {
|
||||
return HttpResponse.json({ success: true, message: 'Task cancelled' });
|
||||
}),
|
||||
|
||||
// Clear all tasks
|
||||
http.post('/api/clear_all_tasks', () => {
|
||||
return HttpResponse.json({ success: true, count: 0, message: 'Cleared 0 tasks' });
|
||||
}),
|
||||
|
||||
// Districts
|
||||
http.get('/api/get_districts', () => {
|
||||
return HttpResponse.json({
|
||||
London: 'REGION^87490',
|
||||
Westminster: 'REGION^93980',
|
||||
Camden: 'REGION^93941',
|
||||
});
|
||||
}),
|
||||
];
|
||||
4
frontend/src/__tests__/mocks/server.ts
Normal file
4
frontend/src/__tests__/mocks/server.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
59
frontend/src/__tests__/setup.ts
Normal file
59
frontend/src/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Polyfill ResizeObserver for jsdom (used by Radix UI components)
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
// Mock mapbox-gl (requires WebGL which jsdom doesn't support)
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
addSource: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
getSource: vi.fn(),
|
||||
getLayer: vi.fn(),
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
setPaintProperty: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
getCanvas: vi.fn(() => ({ style: {} })),
|
||||
})),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
setDOMContent: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
Marker: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
LngLatBounds: vi.fn(() => ({
|
||||
extend: vi.fn().mockReturnThis(),
|
||||
isEmpty: vi.fn(() => false),
|
||||
})),
|
||||
},
|
||||
Map: vi.fn(),
|
||||
NavigationControl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock import.meta.env
|
||||
if (!import.meta.env) {
|
||||
// @ts-expect-error setting up test env
|
||||
import.meta.env = {};
|
||||
}
|
||||
import.meta.env.VITE_MAPBOX_TOKEN = 'test-token';
|
||||
108
frontend/src/components/__tests__/FilterPanel.test.tsx
Normal file
108
frontend/src/components/__tests__/FilterPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
89
frontend/src/components/__tests__/Header.test.tsx
Normal file
89
frontend/src/components/__tests__/Header.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
61
frontend/src/components/__tests__/HealthIndicator.test.tsx
Normal file
61
frontend/src/components/__tests__/HealthIndicator.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
88
frontend/src/components/__tests__/ListView.test.tsx
Normal file
88
frontend/src/components/__tests__/ListView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
91
frontend/src/components/__tests__/PropertyCard.test.tsx
Normal file
91
frontend/src/components/__tests__/PropertyCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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%' });
|
||||
});
|
||||
});
|
||||
108
frontend/src/components/__tests__/TaskIndicator.test.tsx
Normal file
108
frontend/src/components/__tests__/TaskIndicator.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
162
frontend/src/components/__tests__/TaskProgressDrawer.test.tsx
Normal file
162
frontend/src/components/__tests__/TaskProgressDrawer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
227
frontend/src/hooks/__tests__/useTaskProgress.test.ts
Normal file
227
frontend/src/hooks/__tests__/useTaskProgress.test.ts
Normal 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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
145
frontend/src/services/__tests__/apiClient.test.ts
Normal file
145
frontend/src/services/__tests__/apiClient.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
81
frontend/src/services/__tests__/healthService.test.ts
Normal file
81
frontend/src/services/__tests__/healthService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
85
frontend/src/services/__tests__/listingService.test.ts
Normal file
85
frontend/src/services/__tests__/listingService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
214
frontend/src/services/__tests__/streamingService.test.ts
Normal file
214
frontend/src/services/__tests__/streamingService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
90
frontend/src/services/__tests__/taskService.test.ts
Normal file
90
frontend/src/services/__tests__/taskService.test.ts
Normal 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
19
frontend/vitest.config.ts
Normal 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}'],
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
0
tests/e2e/__init__.py
Normal file
203
tests/e2e/test_full_workflows.py
Normal file
203
tests/e2e/test_full_workflows.py
Normal 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
|
||||
320
tests/integration/test_api_workflow.py
Normal file
320
tests/integration/test_api_workflow.py
Normal 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"
|
||||
132
tests/integration/test_listing_cache.py
Normal file
132
tests/integration/test_listing_cache.py
Normal 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 == []
|
||||
186
tests/integration/test_listing_processor.py
Normal file
186
tests/integration/test_listing_processor.py
Normal 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
|
||||
122
tests/integration/test_repository_advanced.py
Normal file
122
tests/integration/test_repository_advanced.py
Normal 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
|
||||
0
tests/regression/__init__.py
Normal file
0
tests/regression/__init__.py
Normal file
135
tests/regression/test_api_contracts.py
Normal file
135
tests/regression/test_api_contracts.py
Normal 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"
|
||||
)
|
||||
75
tests/regression/test_query_parameters.py
Normal file
75
tests/regression/test_query_parameters.py
Normal 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
|
||||
34
tests/unit/test_district_service.py
Normal file
34
tests/unit/test_district_service.py
Normal 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
|
||||
87
tests/unit/test_export_service.py
Normal file
87
tests/unit/test_export_service.py
Normal 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
|
||||
129
tests/unit/test_listing_service.py
Normal file
129
tests/unit/test_listing_service.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue