M2-T03: add read-only data JSON API

- GET /api/locations (inclusive time window start/end, pagination, cap 5000)
- GET /api/poo (pagination, cap 1000, newest first)
- GET /api/public-ip (current state + recent history, cap 1000)
- all session-protected, read-only, bounded (no full-table export)
- typed response schemas; register router; regenerate openapi/
- tests/test_api_data.py
This commit is contained in:
2026-06-12 23:24:17 +02:00
parent d8303eaa3d
commit 0fba7cfe11
6 changed files with 1602 additions and 0 deletions
+611
View File
@@ -0,0 +1,611 @@
"""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"}