"""Tests for M2-T03: GET /api/locations, GET /api/poo, GET /api/public-ip.""" from __future__ import annotations 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 _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"}