bd09523e94
- README: add 前端 v2 (React SPA) section (dev/build/codegen/hosting/gates), update directory listing, drop stale Jinja descriptions - architecture-overview: retire '不引入前后端分离' constraint; reflect SPA + JSON API - roadmap: mark M2 done - remove orphaned jinja2 dependency (recompile requirements*.txt; no other churn) - delete empty tests/test_auth.py stub; drop dead _extract_csrf_token in test_api_data - verified image still builds and app imports with the slimmer deps
605 lines
17 KiB
Python
605 lines
17 KiB
Python
"""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"}
|