Files
ciaovolo/flight-comparator/tests/test_api_endpoints.py
domverse 6421f83ca7 Add flight comparator web app with full scan pipeline
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>
2026-02-26 17:11:51 +01:00

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