# ABOUTME: Tests for Location management web routes. # ABOUTME: Covers GET /locations list and POST /actions/location-* (admin-only). import json import os import time import pytest from starlette.testclient import TestClient from animaltrack.models.reference import User, UserRole from animaltrack.repositories.users import UserRepository def make_test_settings( csrf_secret: str = "test-secret", trusted_proxy_ips: str = "127.0.0.1", dev_mode: bool = True, ): """Create Settings for testing by setting env vars temporarily.""" from animaltrack.config import Settings old_env = os.environ.copy() try: os.environ["CSRF_SECRET"] = csrf_secret os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips os.environ["DEV_MODE"] = str(dev_mode).lower() return Settings() finally: os.environ.clear() os.environ.update(old_env) @pytest.fixture def admin_client(seeded_db): """Test client with admin role (dev mode - bypasses CSRF, auto-admin auth).""" from animaltrack.web.app import create_app settings = make_test_settings(trusted_proxy_ips="testclient", dev_mode=True) app, rt = create_app(settings=settings, db=seeded_db) return TestClient(app, raise_server_exceptions=True) @pytest.fixture def user_client(seeded_db): """Test client with regular (recorder) role.""" from animaltrack.web.app import create_app # Create recorder user in database user_repo = UserRepository(seeded_db) now = int(time.time() * 1000) user = User( username="recorder", display_name="Recorder User", role=UserRole.RECORDER, active=True, created_at_utc=now, updated_at_utc=now, ) user_repo.upsert(user) # Use dev_mode=False to enable real auth settings = make_test_settings(trusted_proxy_ips="testclient", dev_mode=False) app, rt = create_app(settings=settings, db=seeded_db) client = TestClient(app, raise_server_exceptions=True) # Set header to simulate trusted proxy auth client.headers["X-Oidc-Username"] = "recorder" return client # ============================================================================= # GET /locations Tests # ============================================================================= class TestLocationListRendering: """Tests for GET /locations list rendering.""" def test_locations_page_renders_for_admin(self, admin_client): """GET /locations returns 200 for admin.""" resp = admin_client.get("/locations") assert resp.status_code == 200 def test_locations_page_shows_seeded_locations(self, admin_client): """Locations page shows seeded locations.""" resp = admin_client.get("/locations") assert resp.status_code == 200 assert "Strip 1" in resp.text assert "Strip 2" in resp.text assert "Nursery 1" in resp.text def test_locations_page_returns_403_for_recorder(self, user_client): """GET /locations returns 403 for non-admin.""" resp = user_client.get("/locations") assert resp.status_code == 403 # ============================================================================= # POST /actions/location-created Tests # ============================================================================= class TestLocationCreatedSuccess: """Tests for successful POST /actions/location-created.""" def test_creates_location(self, admin_client, seeded_db): """POST creates a new location.""" resp = admin_client.post( "/actions/location-created", data={ "name": "New Test Location", "nonce": "test-nonce-loc-1", }, ) assert resp.status_code == 200 # Verify location was created row = seeded_db.execute( "SELECT name, active FROM locations WHERE name = 'New Test Location'" ).fetchone() assert row is not None assert row[0] == "New Test Location" assert row[1] == 1 def test_creates_event(self, admin_client, seeded_db): """POST creates LocationCreated event.""" resp = admin_client.post( "/actions/location-created", data={ "name": "Event Test Location", "nonce": "test-nonce-loc-2", }, ) assert resp.status_code == 200 event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'LocationCreated' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None assert event_row[0] == "LocationCreated" def test_returns_toast_on_success(self, admin_client): """POST returns HX-Trigger with toast message.""" resp = admin_client.post( "/actions/location-created", data={ "name": "Toast Test Location", "nonce": "test-nonce-loc-3", }, ) assert resp.status_code == 200 assert "HX-Trigger" in resp.headers trigger = json.loads(resp.headers["HX-Trigger"]) assert "showToast" in trigger assert trigger["showToast"]["type"] == "success" def test_idempotent_for_existing_name(self, admin_client, seeded_db): """POST is idempotent - returns success for existing name.""" # First create resp1 = admin_client.post( "/actions/location-created", data={ "name": "Idempotent Location", "nonce": "test-nonce-loc-4", }, ) assert resp1.status_code == 200 # Second create with same name - should still succeed (idempotent) resp2 = admin_client.post( "/actions/location-created", data={ "name": "Idempotent Location", "nonce": "test-nonce-loc-5", }, ) assert resp2.status_code == 200 class TestLocationCreatedValidation: """Tests for POST /actions/location-created validation.""" def test_returns_403_for_recorder(self, user_client): """POST returns 403 for non-admin.""" resp = user_client.post( "/actions/location-created", data={ "name": "Unauthorized Location", "nonce": "test-nonce-loc-6", }, ) assert resp.status_code == 403 def test_returns_422_for_empty_name(self, admin_client): """POST returns 422 for empty name.""" resp = admin_client.post( "/actions/location-created", data={ "name": "", "nonce": "test-nonce-loc-7", }, ) assert resp.status_code == 422 def test_returns_422_for_whitespace_name(self, admin_client): """POST returns 422 for whitespace-only name.""" resp = admin_client.post( "/actions/location-created", data={ "name": " ", "nonce": "test-nonce-loc-8", }, ) assert resp.status_code == 422 # ============================================================================= # POST /actions/location-renamed Tests # ============================================================================= class TestLocationRenamedSuccess: """Tests for successful POST /actions/location-renamed.""" def test_renames_location(self, admin_client, seeded_db): """POST renames an existing location.""" # Get an existing location ID row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() location_id = row[0] resp = admin_client.post( "/actions/location-renamed", data={ "location_id": location_id, "new_name": "Renamed Strip 1", "nonce": "test-nonce-loc-9", }, ) assert resp.status_code == 200 # Verify rename row = seeded_db.execute( "SELECT name FROM locations WHERE id = ?", (location_id,) ).fetchone() assert row[0] == "Renamed Strip 1" def test_creates_event(self, admin_client, seeded_db): """POST creates LocationRenamed event.""" row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone() location_id = row[0] resp = admin_client.post( "/actions/location-renamed", data={ "location_id": location_id, "new_name": "Renamed Strip 2", "nonce": "test-nonce-loc-10", }, ) assert resp.status_code == 200 event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'LocationRenamed' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None class TestLocationRenamedValidation: """Tests for POST /actions/location-renamed validation.""" def test_returns_403_for_recorder(self, user_client, seeded_db): """POST returns 403 for non-admin.""" row = seeded_db.execute("SELECT id FROM locations LIMIT 1").fetchone() location_id = row[0] resp = user_client.post( "/actions/location-renamed", data={ "location_id": location_id, "new_name": "New Name", "nonce": "test-nonce-loc-11", }, ) assert resp.status_code == 403 def test_returns_422_for_nonexistent_location(self, admin_client): """POST returns 422 for non-existent location.""" fake_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV" resp = admin_client.post( "/actions/location-renamed", data={ "location_id": fake_id, "new_name": "New Name", "nonce": "test-nonce-loc-12", }, ) assert resp.status_code == 422 def test_returns_422_for_duplicate_name(self, admin_client, seeded_db): """POST returns 422 when renaming to existing name.""" row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 3'").fetchone() location_id = row[0] # Try to rename to an existing name resp = admin_client.post( "/actions/location-renamed", data={ "location_id": location_id, "new_name": "Strip 4", # Already exists "nonce": "test-nonce-loc-13", }, ) assert resp.status_code == 422 # ============================================================================= # POST /actions/location-archived Tests # ============================================================================= class TestLocationArchivedSuccess: """Tests for successful POST /actions/location-archived.""" def test_archives_location(self, admin_client, seeded_db): """POST archives an existing location.""" # First create a location to archive admin_client.post( "/actions/location-created", data={"name": "To Be Archived", "nonce": "test-nonce-loc-14"}, ) row = seeded_db.execute("SELECT id FROM locations WHERE name = 'To Be Archived'").fetchone() location_id = row[0] resp = admin_client.post( "/actions/location-archived", data={ "location_id": location_id, "nonce": "test-nonce-loc-15", }, ) assert resp.status_code == 200 # Verify archived row = seeded_db.execute( "SELECT active FROM locations WHERE id = ?", (location_id,) ).fetchone() assert row[0] == 0 def test_creates_event(self, admin_client, seeded_db): """POST creates LocationArchived event.""" # Create a location to archive admin_client.post( "/actions/location-created", data={"name": "Archive Event Test", "nonce": "test-nonce-loc-16"}, ) row = seeded_db.execute( "SELECT id FROM locations WHERE name = 'Archive Event Test'" ).fetchone() location_id = row[0] resp = admin_client.post( "/actions/location-archived", data={ "location_id": location_id, "nonce": "test-nonce-loc-17", }, ) assert resp.status_code == 200 event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'LocationArchived' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None class TestLocationArchivedValidation: """Tests for POST /actions/location-archived validation.""" def test_returns_403_for_recorder(self, user_client, seeded_db): """POST returns 403 for non-admin.""" row = seeded_db.execute("SELECT id FROM locations LIMIT 1").fetchone() location_id = row[0] resp = user_client.post( "/actions/location-archived", data={ "location_id": location_id, "nonce": "test-nonce-loc-18", }, ) assert resp.status_code == 403 def test_returns_422_for_nonexistent_location(self, admin_client): """POST returns 422 for non-existent location.""" fake_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV" resp = admin_client.post( "/actions/location-archived", data={ "location_id": fake_id, "nonce": "test-nonce-loc-19", }, ) assert resp.status_code == 422 def test_returns_422_for_already_archived(self, admin_client, seeded_db): """POST returns 422 when archiving already archived location.""" # Create and archive a location admin_client.post( "/actions/location-created", data={"name": "Double Archive Test", "nonce": "test-nonce-loc-20"}, ) row = seeded_db.execute( "SELECT id FROM locations WHERE name = 'Double Archive Test'" ).fetchone() location_id = row[0] # Archive once admin_client.post( "/actions/location-archived", data={"location_id": location_id, "nonce": "test-nonce-loc-21"}, ) # Try to archive again resp = admin_client.post( "/actions/location-archived", data={ "location_id": location_id, "nonce": "test-nonce-loc-22", }, ) assert resp.status_code == 422