Full-stack flight price scanner built on fast-flights v3 (SOCS cookie bypass): Backend (FastAPI + SQLite): - REST API with rate limiting, Pydantic v2 validation, paginated responses - Scan pipeline: resolves airports, queries every day in the window, saves individual flights + aggregate route stats to SQLite - Background async scan processor with real-time progress tracking - Airport search endpoint backed by OpenFlights dataset - Daily scan window (all dates, not monthly samples) Frontend (React 19 + TypeScript + Tailwind CSS v4): - Dashboard with live scan status and recent scans - Create scan form: country mode or specific airports (searchable dropdown) - Scan detail page with expandable route rows showing individual flights (date, airline, departure, arrival, price) loaded on demand - AirportSearch component with debounced live search and multi-select Database: - scans → routes → flights schema with FK cascade and auto-update triggers - Migrations for schema evolution (relaxed country constraint) Tests: - 74 tests: unit + integration, isolated per-test SQLite DB - Confirmed flight fixtures in tests/confirmed_flights.json (50 real flights, BDS→FMM Ryanair + BDS→DUS Eurowings, scraped Feb 2026) - Integration tests parametrized from confirmed routes Docker: - Multi-stage builds, Compose orchestration, Nginx reverse proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
364 lines
12 KiB
Python
364 lines
12 KiB
Python
"""
|
|
Unit tests for API endpoints.
|
|
|
|
Tests all API endpoints with various scenarios including success cases,
|
|
error cases, validation, pagination, and edge cases.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.api
|
|
class TestHealthEndpoint:
|
|
"""Tests for the health check endpoint."""
|
|
|
|
def test_health_endpoint(self, client: TestClient):
|
|
"""Test health endpoint returns 200 OK."""
|
|
response = client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "healthy", "version": "2.0.0"}
|
|
|
|
def test_health_no_rate_limit(self, client: TestClient):
|
|
"""Test health endpoint is excluded from rate limiting."""
|
|
response = client.get("/health")
|
|
|
|
assert "x-ratelimit-limit" not in response.headers
|
|
assert "x-ratelimit-remaining" not in response.headers
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.api
|
|
class TestAirportEndpoints:
|
|
"""Tests for airport search endpoints."""
|
|
|
|
def test_search_airports_valid(self, client: TestClient):
|
|
"""Test airport search with valid query."""
|
|
response = client.get("/api/v1/airports?q=MUC")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert "data" in data
|
|
assert "pagination" in data
|
|
assert isinstance(data["data"], list)
|
|
assert len(data["data"]) > 0
|
|
|
|
# Check first result
|
|
airport = data["data"][0]
|
|
assert "iata" in airport
|
|
assert "name" in airport
|
|
assert "MUC" in airport["iata"]
|
|
|
|
def test_search_airports_pagination(self, client: TestClient):
|
|
"""Test airport search pagination."""
|
|
response = client.get("/api/v1/airports?q=airport&page=1&limit=5")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["pagination"]["page"] == 1
|
|
assert data["pagination"]["limit"] == 5
|
|
assert len(data["data"]) <= 5
|
|
|
|
def test_search_airports_invalid_query_too_short(self, client: TestClient):
|
|
"""Test airport search with query too short."""
|
|
response = client.get("/api/v1/airports?q=M")
|
|
|
|
assert response.status_code == 422
|
|
error = response.json()
|
|
assert error["error"] == "validation_error"
|
|
|
|
def test_search_airports_rate_limit_headers(self, client: TestClient):
|
|
"""Test airport search includes rate limit headers."""
|
|
response = client.get("/api/v1/airports?q=MUC")
|
|
|
|
assert response.status_code == 200
|
|
assert "x-ratelimit-limit" in response.headers
|
|
assert "x-ratelimit-remaining" in response.headers
|
|
assert "x-ratelimit-reset" in response.headers
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.api
|
|
@pytest.mark.database
|
|
class TestScanEndpoints:
|
|
"""Tests for scan management endpoints."""
|
|
|
|
def test_create_scan_valid(self, client: TestClient, sample_scan_data):
|
|
"""Test creating a scan with valid data."""
|
|
response = client.post("/api/v1/scans", json=sample_scan_data)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["status"] == "pending"
|
|
assert data["id"] > 0
|
|
assert data["scan"]["origin"] == sample_scan_data["origin"]
|
|
assert data["scan"]["country"] == sample_scan_data["country"]
|
|
|
|
def test_create_scan_with_defaults(self, client: TestClient):
|
|
"""Test creating a scan with default dates."""
|
|
data = {
|
|
"origin": "MUC",
|
|
"country": "IT",
|
|
"window_months": 3
|
|
}
|
|
|
|
response = client.post("/api/v1/scans", json=data)
|
|
|
|
assert response.status_code == 200
|
|
scan = response.json()["scan"]
|
|
|
|
assert "start_date" in scan
|
|
assert "end_date" in scan
|
|
assert scan["seat_class"] == "economy"
|
|
assert scan["adults"] == 1
|
|
|
|
def test_create_scan_invalid_origin(self, client: TestClient):
|
|
"""Test creating a scan with invalid origin."""
|
|
data = {
|
|
"origin": "INVALID", # Too long
|
|
"country": "DE"
|
|
}
|
|
|
|
response = client.post("/api/v1/scans", json=data)
|
|
|
|
assert response.status_code == 422
|
|
error = response.json()
|
|
assert error["error"] == "validation_error"
|
|
|
|
def test_create_scan_invalid_country(self, client: TestClient):
|
|
"""Test creating a scan with invalid country."""
|
|
data = {
|
|
"origin": "BDS",
|
|
"country": "DEU" # Too long
|
|
}
|
|
|
|
response = client.post("/api/v1/scans", json=data)
|
|
|
|
assert response.status_code == 422
|
|
|
|
def test_list_scans_empty(self, client: TestClient):
|
|
"""Test listing scans when database is empty."""
|
|
response = client.get("/api/v1/scans")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["data"] == []
|
|
assert data["pagination"]["total"] == 0
|
|
|
|
def test_list_scans_with_data(self, client: TestClient, create_test_scan):
|
|
"""Test listing scans with data."""
|
|
# Create test scans
|
|
create_test_scan(origin="BDS", country="DE")
|
|
create_test_scan(origin="MUC", country="IT")
|
|
|
|
response = client.get("/api/v1/scans")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert len(data["data"]) == 2
|
|
assert data["pagination"]["total"] == 2
|
|
|
|
def test_list_scans_pagination(self, client: TestClient, create_test_scan):
|
|
"""Test scan list pagination."""
|
|
# Create 5 scans
|
|
for i in range(5):
|
|
create_test_scan(origin="BDS", country="DE")
|
|
|
|
response = client.get("/api/v1/scans?page=1&limit=2")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert len(data["data"]) == 2
|
|
assert data["pagination"]["total"] == 5
|
|
assert data["pagination"]["pages"] == 3
|
|
assert data["pagination"]["has_next"] is True
|
|
|
|
def test_list_scans_filter_by_status(self, client: TestClient, create_test_scan):
|
|
"""Test filtering scans by status."""
|
|
create_test_scan(status="pending")
|
|
create_test_scan(status="completed")
|
|
create_test_scan(status="pending")
|
|
|
|
response = client.get("/api/v1/scans?status=pending")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert len(data["data"]) == 2
|
|
assert all(scan["status"] == "pending" for scan in data["data"])
|
|
|
|
def test_get_scan_by_id(self, client: TestClient, create_test_scan):
|
|
"""Test getting a specific scan by ID."""
|
|
scan_id = create_test_scan(origin="FRA", country="ES")
|
|
|
|
response = client.get(f"/api/v1/scans/{scan_id}")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["id"] == scan_id
|
|
assert data["origin"] == "FRA"
|
|
assert data["country"] == "ES"
|
|
|
|
def test_get_scan_not_found(self, client: TestClient):
|
|
"""Test getting a non-existent scan."""
|
|
response = client.get("/api/v1/scans/999")
|
|
|
|
assert response.status_code == 404
|
|
error = response.json()
|
|
assert error["error"] == "not_found"
|
|
assert "999" in error["message"]
|
|
|
|
def test_get_scan_routes_empty(self, client: TestClient, create_test_scan):
|
|
"""Test getting routes for a scan with no routes."""
|
|
scan_id = create_test_scan()
|
|
|
|
response = client.get(f"/api/v1/scans/{scan_id}/routes")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["data"] == []
|
|
assert data["pagination"]["total"] == 0
|
|
|
|
def test_get_scan_routes_with_data(self, client: TestClient, create_test_scan, create_test_route):
|
|
"""Test getting routes for a scan with data."""
|
|
scan_id = create_test_scan()
|
|
create_test_route(scan_id=scan_id, destination="MUC", min_price=100)
|
|
create_test_route(scan_id=scan_id, destination="FRA", min_price=50)
|
|
|
|
response = client.get(f"/api/v1/scans/{scan_id}/routes")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert len(data["data"]) == 2
|
|
# Routes should be ordered by price (cheapest first)
|
|
assert data["data"][0]["destination"] == "FRA"
|
|
assert data["data"][0]["min_price"] == 50
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.api
|
|
class TestLogEndpoints:
|
|
"""Tests for log viewer endpoints."""
|
|
|
|
def test_get_logs_empty(self, client: TestClient):
|
|
"""Test getting logs when buffer is empty."""
|
|
response = client.get("/api/v1/logs")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# May have some startup logs
|
|
assert "data" in data
|
|
assert "pagination" in data
|
|
|
|
def test_get_logs_with_level_filter(self, client: TestClient):
|
|
"""Test filtering logs by level."""
|
|
response = client.get("/api/v1/logs?level=INFO")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
if data["data"]:
|
|
assert all(log["level"] == "INFO" for log in data["data"])
|
|
|
|
def test_get_logs_invalid_level(self, client: TestClient):
|
|
"""Test filtering logs with invalid level."""
|
|
response = client.get("/api/v1/logs?level=INVALID")
|
|
|
|
assert response.status_code == 400
|
|
error = response.json()
|
|
assert error["error"] == "bad_request"
|
|
|
|
def test_get_logs_search(self, client: TestClient):
|
|
"""Test searching logs by text."""
|
|
response = client.get("/api/v1/logs?search=startup")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
if data["data"]:
|
|
assert all("startup" in log["message"].lower() for log in data["data"])
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.api
|
|
class TestErrorHandling:
|
|
"""Tests for error handling."""
|
|
|
|
def test_request_id_in_error(self, client: TestClient):
|
|
"""Test that errors include request ID."""
|
|
response = client.get("/api/v1/scans/999")
|
|
|
|
assert response.status_code == 404
|
|
error = response.json()
|
|
|
|
assert "request_id" in error
|
|
assert len(error["request_id"]) == 8 # UUID shortened to 8 chars
|
|
|
|
def test_request_id_in_headers(self, client: TestClient):
|
|
"""Test that request ID is in headers."""
|
|
response = client.get("/api/v1/scans")
|
|
|
|
assert "x-request-id" in response.headers
|
|
assert len(response.headers["x-request-id"]) == 8
|
|
|
|
def test_validation_error_format(self, client: TestClient):
|
|
"""Test validation error response format."""
|
|
response = client.post("/api/v1/scans", json={"origin": "TOOLONG", "country": "DE"})
|
|
|
|
assert response.status_code == 422
|
|
error = response.json()
|
|
|
|
assert error["error"] == "validation_error"
|
|
assert "errors" in error
|
|
assert isinstance(error["errors"], list)
|
|
assert len(error["errors"]) > 0
|
|
assert "field" in error["errors"][0]
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.api
|
|
class TestRateLimiting:
|
|
"""Tests for rate limiting."""
|
|
|
|
def test_rate_limit_headers_present(self, client: TestClient):
|
|
"""Test that rate limit headers are present."""
|
|
response = client.get("/api/v1/airports?q=MUC")
|
|
|
|
assert "x-ratelimit-limit" in response.headers
|
|
assert "x-ratelimit-remaining" in response.headers
|
|
assert "x-ratelimit-reset" in response.headers
|
|
|
|
def test_rate_limit_decreases(self, client: TestClient):
|
|
"""Test that rate limit remaining decreases."""
|
|
response1 = client.get("/api/v1/airports?q=MUC")
|
|
remaining1 = int(response1.headers["x-ratelimit-remaining"])
|
|
|
|
response2 = client.get("/api/v1/airports?q=MUC")
|
|
remaining2 = int(response2.headers["x-ratelimit-remaining"])
|
|
|
|
assert remaining2 < remaining1
|
|
|
|
def test_rate_limit_exceeded(self, client: TestClient):
|
|
"""Test rate limit exceeded response."""
|
|
# Make requests until limit is reached (scans endpoint has limit of 10)
|
|
for i in range(12):
|
|
response = client.post("/api/v1/scans", json={"origin": "BDS", "country": "DE"})
|
|
|
|
# Should get 429 eventually
|
|
assert response.status_code == 429
|
|
error = response.json()
|
|
assert error["error"] == "rate_limit_exceeded"
|
|
assert "retry_after" in error
|