"""Tests for M2-T03: GET /api/locations, GET /api/poo, GET /api/public-ip.""" from __future__ import annotations import re from datetime import UTC, datetime from fastapi.testclient import TestClient from sqlalchemy import insert from sqlalchemy.engine import Engine from app.models.location import Location from app.models.poo import PooRecord from app.models.public_ip import PublicIPHistory, PublicIPState # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _extract_csrf_token(html: str) -> str: match = re.search(r'name="csrf_token" value="([^"]+)"', html) assert match is not None, "csrf_token not found in HTML" return match.group(1) def _api_login(client: TestClient) -> None: """Log in via POST /api/auth/login so the TestClient has a session cookie.""" resp = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 200, f"Login failed: {resp.status_code}" def _seed_locations(engine: Engine, rows: list[dict]) -> None: with engine.begin() as conn: conn.execute(insert(Location), rows) def _seed_poo(engine: Engine, rows: list[dict]) -> None: with engine.begin() as conn: conn.execute(insert(PooRecord), rows) def _seed_public_ip(engine: Engine) -> None: now = datetime.now(UTC) with engine.begin() as conn: conn.execute( insert(PublicIPState), [ { "id": 1, "current_ipv4": "1.2.3.4", "previous_ipv4": "1.2.3.3", "first_seen_at": now, "last_checked_at": now, "last_changed_at": now, "last_check_status": "changed", "last_check_error": None, "last_provider": "ipify", } ], ) conn.execute( insert(PublicIPHistory), [ { "ipv4": "1.2.3.3", "observed_at": datetime(2026, 1, 1, tzinfo=UTC), "change_type": "first_seen", "provider": "ipify", }, { "ipv4": "1.2.3.4", "observed_at": now, "change_type": "changed", "provider": "ipify", }, ], ) # --------------------------------------------------------------------------- # Unauthenticated → 401 # --------------------------------------------------------------------------- def test_locations_unauthenticated_returns_401(client: TestClient) -> None: response = client.get("/api/locations") assert response.status_code == 401 def test_poo_unauthenticated_returns_401(client: TestClient) -> None: response = client.get("/api/poo") assert response.status_code == 401 def test_public_ip_unauthenticated_returns_401(client: TestClient) -> None: response = client.get("/api/public-ip") assert response.status_code == 401 # --------------------------------------------------------------------------- # GET /api/locations — basic # --------------------------------------------------------------------------- def test_locations_empty_returns_empty_list(location_client) -> None: client, _engine = location_client _api_login(client) resp = client.get("/api/locations") assert resp.status_code == 200 body = resp.json() assert body["items"] == [] assert body["limit"] == 1000 assert body["offset"] == 0 def test_locations_returns_seeded_rows(location_client) -> None: client, engine = location_client _seed_locations( engine, [ { "person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 51.5, "longitude": -0.1, "altitude": None, }, { "person": "bob", "datetime": "2026-06-02T12:00:00Z", "latitude": 48.8, "longitude": 2.3, "altitude": 35.0, }, ], ) _api_login(client) resp = client.get("/api/locations") assert resp.status_code == 200 items = resp.json()["items"] assert len(items) == 2 # ordered by datetime ascending assert items[0]["datetime"] == "2026-06-01T10:00:00Z" assert items[1]["datetime"] == "2026-06-02T12:00:00Z" # altitude nullable assert items[0]["altitude"] is None assert items[1]["altitude"] == 35.0 def test_locations_returns_all_fields(location_client) -> None: client, engine = location_client _seed_locations( engine, [ { "person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 51.5, "longitude": -0.1, "altitude": 10.0, } ], ) _api_login(client) resp = client.get("/api/locations") item = resp.json()["items"][0] assert set(item.keys()) == {"person", "datetime", "latitude", "longitude", "altitude"} assert item["person"] == "alice" assert item["latitude"] == 51.5 assert item["longitude"] == -0.1 assert item["altitude"] == 10.0 # --------------------------------------------------------------------------- # GET /api/locations — pagination # --------------------------------------------------------------------------- def test_locations_limit_and_offset(location_client) -> None: client, engine = location_client _seed_locations( engine, [ { "person": "alice", "datetime": f"2026-06-{i:02d}T10:00:00Z", "latitude": 51.0 + i, "longitude": -0.1, "altitude": None, } for i in range(1, 6) ], ) _api_login(client) resp = client.get("/api/locations", params={"limit": 2, "offset": 1}) assert resp.status_code == 200 body = resp.json() assert body["limit"] == 2 assert body["offset"] == 1 items = body["items"] assert len(items) == 2 # offset=1 skips the first row (2026-06-01), so we start at 2026-06-02 assert items[0]["datetime"] == "2026-06-02T10:00:00Z" def test_locations_limit_at_cap_returns_200(location_client) -> None: client, _engine = location_client _api_login(client) resp = client.get("/api/locations", params={"limit": 5000}) assert resp.status_code == 200 assert resp.json()["limit"] == 5000 def test_locations_limit_exceeds_cap_returns_422(location_client) -> None: client, _engine = location_client _api_login(client) resp = client.get("/api/locations", params={"limit": 5001}) assert resp.status_code == 422 def test_locations_limit_zero_returns_422(location_client) -> None: client, _engine = location_client _api_login(client) resp = client.get("/api/locations", params={"limit": 0}) assert resp.status_code == 422 def test_locations_negative_offset_returns_422(location_client) -> None: client, _engine = location_client _api_login(client) resp = client.get("/api/locations", params={"offset": -1}) assert resp.status_code == 422 # --------------------------------------------------------------------------- # GET /api/locations — time-window filtering (inclusive bounds) # --------------------------------------------------------------------------- def test_locations_start_filter_inclusive(location_client) -> None: client, engine = location_client _seed_locations( engine, [ { "person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 51.0, "longitude": -0.1, "altitude": None, }, { "person": "alice", "datetime": "2026-06-02T10:00:00Z", "latitude": 52.0, "longitude": -0.1, "altitude": None, }, { "person": "alice", "datetime": "2026-06-03T10:00:00Z", "latitude": 53.0, "longitude": -0.1, "altitude": None, }, ], ) _api_login(client) # start is inclusive: 2026-06-02 should be included resp = client.get("/api/locations", params={"start": "2026-06-02T10:00:00Z"}) assert resp.status_code == 200 items = resp.json()["items"] datetimes = [it["datetime"] for it in items] assert "2026-06-02T10:00:00Z" in datetimes # inclusive assert "2026-06-03T10:00:00Z" in datetimes assert "2026-06-01T10:00:00Z" not in datetimes def test_locations_end_filter_inclusive(location_client) -> None: client, engine = location_client _seed_locations( engine, [ { "person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 51.0, "longitude": -0.1, "altitude": None, }, { "person": "alice", "datetime": "2026-06-02T10:00:00Z", "latitude": 52.0, "longitude": -0.1, "altitude": None, }, { "person": "alice", "datetime": "2026-06-03T10:00:00Z", "latitude": 53.0, "longitude": -0.1, "altitude": None, }, ], ) _api_login(client) # end is inclusive: 2026-06-02 should be included resp = client.get("/api/locations", params={"end": "2026-06-02T10:00:00Z"}) assert resp.status_code == 200 items = resp.json()["items"] datetimes = [it["datetime"] for it in items] assert "2026-06-01T10:00:00Z" in datetimes assert "2026-06-02T10:00:00Z" in datetimes # inclusive assert "2026-06-03T10:00:00Z" not in datetimes def test_locations_start_and_end_filter(location_client) -> None: client, engine = location_client _seed_locations( engine, [ { "person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 51.0, "longitude": -0.1, "altitude": None, }, { "person": "alice", "datetime": "2026-06-02T10:00:00Z", "latitude": 52.0, "longitude": -0.1, "altitude": None, }, { "person": "alice", "datetime": "2026-06-03T10:00:00Z", "latitude": 53.0, "longitude": -0.1, "altitude": None, }, ], ) _api_login(client) resp = client.get( "/api/locations", params={"start": "2026-06-02T10:00:00Z", "end": "2026-06-02T10:00:00Z"}, ) assert resp.status_code == 200 items = resp.json()["items"] assert len(items) == 1 assert items[0]["datetime"] == "2026-06-02T10:00:00Z" # --------------------------------------------------------------------------- # GET /api/poo — basic # --------------------------------------------------------------------------- def test_poo_empty_returns_empty_list(poo_client) -> None: client, _engine = poo_client _api_login(client) resp = client.get("/api/poo") assert resp.status_code == 200 body = resp.json() assert body["items"] == [] assert body["limit"] == 100 assert body["offset"] == 0 def test_poo_returns_seeded_rows_desc(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [ { "timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 51.0, "longitude": -0.1, }, { "timestamp": "2026-06-03T10:00Z", "status": "fail", "latitude": 52.0, "longitude": -0.2, }, { "timestamp": "2026-06-02T10:00Z", "status": "success", "latitude": 53.0, "longitude": -0.3, }, ], ) _api_login(client) resp = client.get("/api/poo") assert resp.status_code == 200 items = resp.json()["items"] assert len(items) == 3 # ordered by timestamp desc assert items[0]["timestamp"] == "2026-06-03T10:00Z" assert items[1]["timestamp"] == "2026-06-02T10:00Z" assert items[2]["timestamp"] == "2026-06-01T10:00Z" def test_poo_returns_all_fields(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [ { "timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 51.5, "longitude": -0.1, } ], ) _api_login(client) resp = client.get("/api/poo") item = resp.json()["items"][0] assert set(item.keys()) == {"timestamp", "status", "latitude", "longitude"} assert item["status"] == "success" # --------------------------------------------------------------------------- # GET /api/poo — pagination # --------------------------------------------------------------------------- def test_poo_limit_and_offset(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [ { "timestamp": f"2026-06-{i:02d}T10:00Z", "status": "success", "latitude": 51.0, "longitude": -0.1, } for i in range(1, 6) ], ) _api_login(client) resp = client.get("/api/poo", params={"limit": 2, "offset": 1}) assert resp.status_code == 200 body = resp.json() assert body["limit"] == 2 assert body["offset"] == 1 items = body["items"] assert len(items) == 2 # desc order: rows are 06-05, 06-04, 06-03, 06-02, 06-01 # offset=1 skips 06-05, so first item should be 06-04 assert items[0]["timestamp"] == "2026-06-04T10:00Z" def test_poo_limit_at_cap_returns_200(poo_client) -> None: client, _engine = poo_client _api_login(client) resp = client.get("/api/poo", params={"limit": 1000}) assert resp.status_code == 200 assert resp.json()["limit"] == 1000 def test_poo_limit_exceeds_cap_returns_422(poo_client) -> None: client, _engine = poo_client _api_login(client) resp = client.get("/api/poo", params={"limit": 1001}) assert resp.status_code == 422 def test_poo_limit_zero_returns_422(poo_client) -> None: client, _engine = poo_client _api_login(client) resp = client.get("/api/poo", params={"limit": 0}) assert resp.status_code == 422 def test_poo_negative_offset_returns_422(poo_client) -> None: client, _engine = poo_client _api_login(client) resp = client.get("/api/poo", params={"offset": -1}) assert resp.status_code == 422 # --------------------------------------------------------------------------- # GET /api/public-ip # --------------------------------------------------------------------------- def test_public_ip_empty_returns_null_state_and_empty_history(client: TestClient) -> None: _api_login(client) resp = client.get("/api/public-ip") assert resp.status_code == 200 body = resp.json() assert body["state"] is None assert body["history"] == [] def test_public_ip_returns_state_and_history(location_client) -> None: client, engine = location_client _seed_public_ip(engine) _api_login(client) resp = client.get("/api/public-ip") assert resp.status_code == 200 body = resp.json() state = body["state"] assert state is not None assert state["current_ipv4"] == "1.2.3.4" assert state["previous_ipv4"] == "1.2.3.3" assert state["last_check_status"] == "changed" history = body["history"] assert len(history) == 2 # ordered by observed_at desc — more recent item first assert history[0]["ipv4"] == "1.2.3.4" assert history[1]["ipv4"] == "1.2.3.3" def test_public_ip_history_limit_at_cap_returns_200(client: TestClient) -> None: _api_login(client) resp = client.get("/api/public-ip", params={"limit": 1000}) assert resp.status_code == 200 def test_public_ip_history_limit_exceeds_cap_returns_422(client: TestClient) -> None: _api_login(client) resp = client.get("/api/public-ip", params={"limit": 1001}) assert resp.status_code == 422 def test_public_ip_history_limit_zero_returns_422(client: TestClient) -> None: _api_login(client) resp = client.get("/api/public-ip", params={"limit": 0}) assert resp.status_code == 422 def test_public_ip_state_has_expected_fields(location_client) -> None: client, engine = location_client _seed_public_ip(engine) _api_login(client) resp = client.get("/api/public-ip") state = resp.json()["state"] expected_keys = { "id", "current_ipv4", "previous_ipv4", "first_seen_at", "last_checked_at", "last_changed_at", "last_check_status", "last_check_error", "last_provider", } assert set(state.keys()) == expected_keys def test_public_ip_history_has_expected_fields(location_client) -> None: client, engine = location_client _seed_public_ip(engine) _api_login(client) resp = client.get("/api/public-ip") h = resp.json()["history"][0] assert set(h.keys()) == {"id", "ipv4", "observed_at", "change_type", "provider"}