"""Tests for M2-T04: PATCH/DELETE /api/locations and /api/poo.""" from __future__ import annotations from fastapi.testclient import TestClient from sqlalchemy import insert, select from sqlalchemy.engine import Engine from app.models.location import Location from app.models.poo import PooRecord # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- CSRF_HEADER = {"X-CSRF-Token": "any-value"} def _api_login(client: TestClient) -> None: 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 _count_locations(engine: Engine) -> int: with engine.connect() as conn: return conn.execute(select(Location)).rowcount or len(conn.execute(select(Location)).all()) def _fetch_location(engine: Engine, person: str, dt: str) -> dict | None: with engine.connect() as conn: row = conn.execute( select(Location).where(Location.person == person, Location.datetime == dt) ).one_or_none() if row is None: return None return dict(row._mapping) def _fetch_poo(engine: Engine, timestamp: str) -> dict | None: with engine.connect() as conn: row = conn.execute( select(PooRecord).where(PooRecord.timestamp == timestamp) ).one_or_none() if row is None: return None return dict(row._mapping) def _all_location_count(engine: Engine) -> int: with engine.connect() as conn: return len(conn.execute(select(Location)).all()) def _all_poo_count(engine: Engine) -> int: with engine.connect() as conn: return len(conn.execute(select(PooRecord)).all()) # --------------------------------------------------------------------------- # PATCH /api/locations/{person}/{datetime} — authentication / CSRF guards # --------------------------------------------------------------------------- def test_patch_location_unauthenticated_returns_401(location_client) -> None: client, engine = location_client _seed_locations( engine, [{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}], ) resp = client.patch( "/api/locations/alice/2026-06-01T10:00:00Z", json={"latitude": 9.9}, headers=CSRF_HEADER, ) assert resp.status_code == 401 def test_patch_location_missing_csrf_returns_403(location_client) -> None: client, engine = location_client _seed_locations( engine, [{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}], ) _api_login(client) resp = client.patch( "/api/locations/alice/2026-06-01T10:00:00Z", json={"latitude": 9.9}, ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # PATCH /api/locations/{person}/{datetime} — 404 for nonexistent PK # --------------------------------------------------------------------------- def test_patch_location_nonexistent_pk_returns_404(location_client) -> None: client, _engine = location_client _api_login(client) resp = client.patch( "/api/locations/nobody/2099-01-01T00:00:00Z", json={"latitude": 1.0}, headers=CSRF_HEADER, ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # PATCH /api/locations/{person}/{datetime} — updates exactly one row's fields # --------------------------------------------------------------------------- def test_patch_location_updates_single_row_fields(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.2, "altitude": None, }, ], ) _api_login(client) resp = client.patch( "/api/locations/alice/2026-06-01T10:00:00Z", json={"latitude": 99.0, "longitude": 88.0}, headers=CSRF_HEADER, ) assert resp.status_code == 200 body = resp.json() assert body["latitude"] == 99.0 assert body["longitude"] == 88.0 assert body["person"] == "alice" assert body["datetime"] == "2026-06-01T10:00:00Z" # Confirm DB state — row 1 changed, row 2 unchanged row1 = _fetch_location(engine, "alice", "2026-06-01T10:00:00Z") assert row1 is not None assert row1["latitude"] == 99.0 assert row1["longitude"] == 88.0 row2 = _fetch_location(engine, "alice", "2026-06-02T10:00:00Z") assert row2 is not None assert row2["latitude"] == 52.0 # unchanged assert row2["longitude"] == -0.2 # unchanged # Row count unchanged — no spurious rows added/removed assert _all_location_count(engine) == 2 def test_patch_location_partial_update_leaves_other_fields_unchanged(location_client) -> None: client, engine = location_client _seed_locations( engine, [ { "person": "bob", "datetime": "2026-06-10T08:00:00Z", "latitude": 48.8, "longitude": 2.3, "altitude": 100.0, } ], ) _api_login(client) # Only update altitude resp = client.patch( "/api/locations/bob/2026-06-10T08:00:00Z", json={"altitude": 200.0}, headers=CSRF_HEADER, ) assert resp.status_code == 200 body = resp.json() assert body["altitude"] == 200.0 assert body["latitude"] == 48.8 # unchanged assert body["longitude"] == 2.3 # unchanged def test_patch_location_empty_body_is_noop(location_client) -> None: """Sending an empty body should not change the record but still return 200.""" client, engine = location_client _seed_locations( engine, [{"person": "carol", "datetime": "2026-06-05T12:00:00Z", "latitude": 10.0, "longitude": 20.0, "altitude": None}], ) _api_login(client) resp = client.patch( "/api/locations/carol/2026-06-05T12:00:00Z", json={}, headers=CSRF_HEADER, ) assert resp.status_code == 200 row = _fetch_location(engine, "carol", "2026-06-05T12:00:00Z") assert row["latitude"] == 10.0 assert row["longitude"] == 20.0 def test_patch_location_response_has_correct_schema(location_client) -> None: client, engine = location_client _seed_locations( engine, [{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": 5.0}], ) _api_login(client) resp = client.patch( "/api/locations/alice/2026-06-01T10:00:00Z", json={"latitude": 3.0}, headers=CSRF_HEADER, ) assert resp.status_code == 200 keys = set(resp.json().keys()) assert keys == {"person", "datetime", "latitude", "longitude", "altitude"} # --------------------------------------------------------------------------- # DELETE /api/locations/{person}/{datetime} — guards # --------------------------------------------------------------------------- def test_delete_location_unauthenticated_returns_401(location_client) -> None: client, engine = location_client _seed_locations( engine, [{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}], ) resp = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER) assert resp.status_code == 401 def test_delete_location_missing_csrf_returns_403(location_client) -> None: client, engine = location_client _seed_locations( engine, [{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}], ) _api_login(client) resp = client.delete("/api/locations/alice/2026-06-01T10:00:00Z") assert resp.status_code == 403 # --------------------------------------------------------------------------- # DELETE /api/locations/{person}/{datetime} — 404 for nonexistent PK # --------------------------------------------------------------------------- def test_delete_location_nonexistent_pk_returns_404(location_client) -> None: client, _engine = location_client _api_login(client) resp = client.delete("/api/locations/nobody/2099-01-01T00:00:00Z", headers=CSRF_HEADER) assert resp.status_code == 404 # --------------------------------------------------------------------------- # DELETE /api/locations/{person}/{datetime} — deletes exactly one row # --------------------------------------------------------------------------- def test_delete_location_removes_exactly_one_row(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.2, "altitude": None, }, ], ) _api_login(client) before = _all_location_count(engine) assert before == 2 resp = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER) assert resp.status_code == 204 after = _all_location_count(engine) assert after == 1 # exactly one row removed # The deleted row is gone assert _fetch_location(engine, "alice", "2026-06-01T10:00:00Z") is None # The other row still exists assert _fetch_location(engine, "alice", "2026-06-02T10:00:00Z") is not None def test_delete_location_second_delete_returns_404(location_client) -> None: """Deleting the same PK twice must return 404 on the second attempt.""" client, engine = location_client _seed_locations( engine, [{"person": "alice", "datetime": "2026-06-01T10:00:00Z", "latitude": 1.0, "longitude": 2.0, "altitude": None}], ) _api_login(client) resp1 = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER) assert resp1.status_code == 204 resp2 = client.delete("/api/locations/alice/2026-06-01T10:00:00Z", headers=CSRF_HEADER) assert resp2.status_code == 404 # --------------------------------------------------------------------------- # PATCH /api/poo/{timestamp} — guards # --------------------------------------------------------------------------- def test_patch_poo_unauthenticated_returns_401(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}], ) resp = client.patch( "/api/poo/2026-06-01T10:00Z", json={"status": "fail"}, headers=CSRF_HEADER, ) assert resp.status_code == 401 def test_patch_poo_missing_csrf_returns_403(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}], ) _api_login(client) resp = client.patch( "/api/poo/2026-06-01T10:00Z", json={"status": "fail"}, ) assert resp.status_code == 403 # --------------------------------------------------------------------------- # PATCH /api/poo/{timestamp} — 404 # --------------------------------------------------------------------------- def test_patch_poo_nonexistent_pk_returns_404(poo_client) -> None: client, _engine = poo_client _api_login(client) resp = client.patch( "/api/poo/2099-01-01T00:00Z", json={"status": "fail"}, headers=CSRF_HEADER, ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # PATCH /api/poo/{timestamp} — updates single row # --------------------------------------------------------------------------- def test_patch_poo_updates_single_row_fields(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-02T10:00Z", "status": "success", "latitude": 52.0, "longitude": -0.2}, ], ) _api_login(client) resp = client.patch( "/api/poo/2026-06-01T10:00Z", json={"status": "fail", "latitude": 99.0}, headers=CSRF_HEADER, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "fail" assert body["latitude"] == 99.0 assert body["timestamp"] == "2026-06-01T10:00Z" # Other row unchanged row2 = _fetch_poo(engine, "2026-06-02T10:00Z") assert row2 is not None assert row2["status"] == "success" assert row2["latitude"] == 52.0 # Row count unchanged assert _all_poo_count(engine) == 2 def test_patch_poo_partial_update_leaves_other_fields_unchanged(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 51.0, "longitude": -0.1}], ) _api_login(client) resp = client.patch( "/api/poo/2026-06-01T10:00Z", json={"longitude": 99.9}, headers=CSRF_HEADER, ) assert resp.status_code == 200 body = resp.json() assert body["longitude"] == 99.9 assert body["latitude"] == 51.0 # unchanged assert body["status"] == "success" # unchanged def test_patch_poo_response_has_correct_schema(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}], ) _api_login(client) resp = client.patch( "/api/poo/2026-06-01T10:00Z", json={"status": "fail"}, headers=CSRF_HEADER, ) assert resp.status_code == 200 assert set(resp.json().keys()) == {"timestamp", "status", "latitude", "longitude"} # --------------------------------------------------------------------------- # DELETE /api/poo/{timestamp} — guards # --------------------------------------------------------------------------- def test_delete_poo_unauthenticated_returns_401(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}], ) resp = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER) assert resp.status_code == 401 def test_delete_poo_missing_csrf_returns_403(poo_client) -> None: client, engine = poo_client _seed_poo( engine, [{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}], ) _api_login(client) resp = client.delete("/api/poo/2026-06-01T10:00Z") assert resp.status_code == 403 # --------------------------------------------------------------------------- # DELETE /api/poo/{timestamp} — 404 # --------------------------------------------------------------------------- def test_delete_poo_nonexistent_pk_returns_404(poo_client) -> None: client, _engine = poo_client _api_login(client) resp = client.delete("/api/poo/2099-01-01T00:00Z", headers=CSRF_HEADER) assert resp.status_code == 404 # --------------------------------------------------------------------------- # DELETE /api/poo/{timestamp} — deletes exactly one row # --------------------------------------------------------------------------- def test_delete_poo_removes_exactly_one_row(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-02T10:00Z", "status": "fail", "latitude": 52.0, "longitude": -0.2}, ], ) _api_login(client) before = _all_poo_count(engine) assert before == 2 resp = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER) assert resp.status_code == 204 after = _all_poo_count(engine) assert after == 1 # exactly one row removed # Deleted row is gone assert _fetch_poo(engine, "2026-06-01T10:00Z") is None # Other row still exists assert _fetch_poo(engine, "2026-06-02T10:00Z") is not None def test_delete_poo_second_delete_returns_404(poo_client) -> None: """Deleting the same PK twice must return 404 on the second attempt.""" client, engine = poo_client _seed_poo( engine, [{"timestamp": "2026-06-01T10:00Z", "status": "success", "latitude": 1.0, "longitude": 2.0}], ) _api_login(client) resp1 = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER) assert resp1.status_code == 204 resp2 = client.delete("/api/poo/2026-06-01T10:00Z", headers=CSRF_HEADER) assert resp2.status_code == 404