Skip to content

Commit e016db1

Browse files
committed
Add integration tests for geo service; migrate api.py to lifespan handler
- tests/test_geo.py: 8 tests covering ingest, idempotency, GeoJSON shape, point count, 3-D coordinates, 404 on unknown flight - src/geo/api.py: replace deprecated on_event("shutdown") with lifespan context manager (FastAPI docs recommendation) - pyproject.toml: add httpx>=0.27 to [geo] extra (required by TestClient)
1 parent 993fff4 commit e016db1

3 files changed

Lines changed: 127 additions & 8 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ geo = [
2626
"uvicorn[standard]>=0.29",
2727
"asyncpg>=0.29",
2828
"psycopg[binary]>=3.1",
29+
"httpx>=0.27",
2930
]
3031

3132
[build-system]

src/geo/api.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import json
22
import os
3+
from contextlib import asynccontextmanager
34

45
import asyncpg
56
from fastapi import FastAPI, HTTPException
67
from fastapi.responses import JSONResponse
78

8-
app = FastAPI(title="flightlog geo API")
9-
109
_pool: asyncpg.Pool | None = None
1110

1211

12+
@asynccontextmanager
13+
async def lifespan(app: FastAPI):
14+
yield
15+
if _pool:
16+
await _pool.close()
17+
18+
19+
app = FastAPI(title="flightlog geo API", lifespan=lifespan)
20+
21+
1322
async def get_pool() -> asyncpg.Pool:
1423
global _pool
1524
if _pool is None:
@@ -18,12 +27,6 @@ async def get_pool() -> asyncpg.Pool:
1827
return _pool
1928

2029

21-
@app.on_event("shutdown")
22-
async def shutdown() -> None:
23-
if _pool:
24-
await _pool.close()
25-
26-
2730
@app.get("/health")
2831
async def health() -> JSONResponse:
2932
"""Liveness + readiness: verifies the DB connection is actually usable."""

tests/test_geo.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Integration tests for the geospatial track service.
3+
4+
Requires a running PostGIS instance. Set DATABASE_URL to point at it, or the
5+
whole module is skipped. The sample flight CSV is copied to a temp path for
6+
each test session so the ingest deduplication key is unique and does not
7+
collide with any manually-ingested data.
8+
9+
Run with:
10+
DATABASE_URL=postgresql://geo:geopassword@localhost:5432/flightlog \
11+
.venv/bin/pytest tests/test_geo.py -v
12+
"""
13+
14+
import csv
15+
import os
16+
import shutil
17+
from pathlib import Path
18+
19+
import pytest
20+
from fastapi.testclient import TestClient
21+
22+
from geo.ingest import ingest
23+
24+
DATABASE_URL = os.environ.get("DATABASE_URL", "")
25+
DATA_CSV = Path(__file__).parent.parent / "data" / "sample_flight.csv"
26+
27+
pytestmark = pytest.mark.skipif(
28+
not DATABASE_URL,
29+
reason="DATABASE_URL not set — start PostGIS with docker compose -f docker-compose.geo.yml up",
30+
)
31+
32+
33+
@pytest.fixture(scope="module")
34+
def csv_path(tmp_path_factory):
35+
"""A temp copy of the sample CSV so the source_file key is test-session-unique."""
36+
dest = tmp_path_factory.mktemp("geo") / "test_flight.csv"
37+
shutil.copy(DATA_CSV, dest)
38+
return dest
39+
40+
41+
@pytest.fixture(scope="module")
42+
def expected_points(csv_path):
43+
"""Count data rows in the CSV (excluding header)."""
44+
with open(csv_path, newline="") as fh:
45+
return sum(1 for _ in csv.DictReader(fh))
46+
47+
48+
@pytest.fixture(scope="module")
49+
def flight_id(csv_path):
50+
return ingest(str(csv_path), DATABASE_URL)
51+
52+
53+
@pytest.fixture(scope="module")
54+
def client():
55+
os.environ["DATABASE_URL"] = DATABASE_URL
56+
# Import after setting the env var so asyncpg pool creation can read it.
57+
from geo.api import app # noqa: PLC0415
58+
59+
with TestClient(app) as c:
60+
yield c
61+
62+
63+
# --- ingest ---
64+
65+
66+
def test_ingest_returns_positive_id(flight_id):
67+
assert isinstance(flight_id, int)
68+
assert flight_id > 0
69+
70+
71+
def test_ingest_idempotent(csv_path, flight_id):
72+
"""Running ingest twice on the same file must return the same id, not raise."""
73+
second_id = ingest(str(csv_path), DATABASE_URL)
74+
assert second_id == flight_id
75+
76+
77+
# --- API ---
78+
79+
80+
def test_health(client):
81+
resp = client.get("/health")
82+
assert resp.status_code == 200
83+
assert resp.json() == {"status": "ok"}
84+
85+
86+
def test_track_is_geojson_feature(client, flight_id):
87+
resp = client.get(f"/flights/{flight_id}/track")
88+
assert resp.status_code == 200
89+
body = resp.json()
90+
assert body["type"] == "Feature"
91+
assert body["properties"]["flight_id"] == flight_id
92+
93+
94+
def test_track_geometry_is_linestring(client, flight_id):
95+
body = client.get(f"/flights/{flight_id}/track").json()
96+
assert body["geometry"]["type"] == "LineString"
97+
98+
99+
def test_track_point_count(client, flight_id, expected_points):
100+
"""Coordinate count must match CSV row count — no duplicates, no drops."""
101+
body = client.get(f"/flights/{flight_id}/track").json()
102+
coords = body["geometry"]["coordinates"]
103+
assert len(coords) == expected_points
104+
105+
106+
def test_track_coordinates_are_3d(client, flight_id):
107+
"""Each coordinate must be [lon, lat, alt] — three values (LineStringZ)."""
108+
body = client.get(f"/flights/{flight_id}/track").json()
109+
coords = body["geometry"]["coordinates"]
110+
assert all(len(c) == 3 for c in coords)
111+
112+
113+
def test_track_unknown_flight_returns_404(client):
114+
resp = client.get("/flights/999999/track")
115+
assert resp.status_code == 404

0 commit comments

Comments
 (0)