# ABOUTME: Tests for Animal Registry web routes. # ABOUTME: Covers GET /registry rendering, filtering, pagination, and HTMX infinite scroll. import os import time import pytest from starlette.testclient import TestClient from animaltrack.events.payloads import AnimalCohortCreatedPayload from animaltrack.events.store import EventStore from animaltrack.projections import ProjectionRegistry from animaltrack.projections.animal_registry import AnimalRegistryProjection from animaltrack.projections.event_animals import EventAnimalsProjection from animaltrack.projections.intervals import IntervalProjection from animaltrack.services.animal import AnimalService 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 client(seeded_db): """Create a test client for the app.""" from animaltrack.web.app import create_app settings = make_test_settings(trusted_proxy_ips="testclient") app, rt = create_app(settings=settings, db=seeded_db) return TestClient(app, raise_server_exceptions=True) @pytest.fixture def projection_registry(seeded_db): """Create a ProjectionRegistry with animal projections registered.""" registry = ProjectionRegistry() registry.register(AnimalRegistryProjection(seeded_db)) registry.register(EventAnimalsProjection(seeded_db)) registry.register(IntervalProjection(seeded_db)) return registry @pytest.fixture def animal_service(seeded_db, projection_registry): """Create an AnimalService for testing.""" event_store = EventStore(seeded_db) return AnimalService(seeded_db, event_store, projection_registry) @pytest.fixture def location_strip1_id(seeded_db): """Get Strip 1 location ID from seeded data.""" row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() return row[0] @pytest.fixture def location_strip2_id(seeded_db): """Get Strip 2 location ID from seeded data.""" row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone() return row[0] @pytest.fixture def ducks_at_strip1(seeded_db, animal_service, location_strip1_id): """Create 5 female ducks at Strip 1.""" payload = AnimalCohortCreatedPayload( species="duck", count=5, life_stage="adult", sex="female", location_id=location_strip1_id, origin="purchased", ) ts_utc = int(time.time() * 1000) event = animal_service.create_cohort(payload, ts_utc, "test_user") return event.entity_refs["animal_ids"] @pytest.fixture def geese_at_strip2(seeded_db, animal_service, location_strip2_id): """Create 3 male geese at Strip 2.""" payload = AnimalCohortCreatedPayload( species="goose", count=3, life_stage="adult", sex="male", location_id=location_strip2_id, origin="purchased", ) ts_utc = int(time.time() * 1000) event = animal_service.create_cohort(payload, ts_utc, "test_user") return event.entity_refs["animal_ids"] class TestRegistryRendering: """Tests for GET /registry page rendering.""" def test_registry_renders_empty_state(self, client): """GET /registry returns 200 with no animals.""" response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert "Animal Registry" in response.text assert "0 animals" in response.text def test_registry_renders_with_animals(self, client, ducks_at_strip1): """GET /registry shows animals in table.""" response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert "5 animals" in response.text assert "duck" in response.text.lower() assert "Strip 1" in response.text def test_registry_shows_facet_sidebar(self, client, ducks_at_strip1, geese_at_strip2): """Facet sidebar shows species/status counts.""" response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 # Check facet sections exist assert "Status" in response.text assert "Species" in response.text assert "Location" in response.text def test_registry_has_filter_input(self, client): """Page has filter input field.""" response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert 'name="filter"' in response.text def test_registry_is_in_nav(self, client): """Registry link is in navigation.""" response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert 'href="/registry"' in response.text class TestRegistryFiltering: """Tests for filter application.""" def test_filter_by_species(self, client, ducks_at_strip1, geese_at_strip2): """Filter species:duck shows only ducks.""" response = client.get( "/registry?filter=species:duck", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert "5 animals" in response.text # Should not show geese count in the main content area def test_filter_by_location(self, client, ducks_at_strip1, geese_at_strip2): """Filter location:'Strip 1' shows only Strip 1 animals.""" response = client.get( '/registry?filter=location:"Strip 1"', headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert "5 animals" in response.text def test_filter_by_status_all(self, client, seeded_db, ducks_at_strip1): """Filter status:alive|dead shows both statuses.""" # Kill one duck dead_id = ducks_at_strip1[0] seeded_db.execute( "UPDATE animal_registry SET status = 'dead' WHERE animal_id = ?", (dead_id,), ) response = client.get( "/registry?filter=status:alive|dead", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert "5 animals" in response.text # All 5 shown def test_default_shows_only_alive(self, client, seeded_db, ducks_at_strip1): """Default view excludes dead animals.""" # Kill one duck dead_id = ducks_at_strip1[0] seeded_db.execute( "UPDATE animal_registry SET status = 'dead' WHERE animal_id = ?", (dead_id,), ) response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert "4 animals" in response.text # Only alive shown class TestRegistryPagination: """Tests for infinite scroll pagination.""" def test_first_page_has_sentinel_when_more_exist( self, client, seeded_db, animal_service, location_strip1_id ): """First page includes load-more sentinel when more exist.""" # Create 60 animals (more than page size of 50) payload = AnimalCohortCreatedPayload( species="duck", count=60, life_stage="adult", sex="female", location_id=location_strip1_id, origin="purchased", ) ts_utc = int(time.time() * 1000) animal_service.create_cohort(payload, ts_utc, "test_user") response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert response.status_code == 200 assert "60 animals" in response.text assert "load-more-sentinel" in response.text assert "hx-get" in response.text def test_htmx_request_returns_rows_only( self, client, seeded_db, animal_service, location_strip1_id ): """HTMX requests with cursor return just table rows.""" # Create 60 animals payload = AnimalCohortCreatedPayload( species="duck", count=60, life_stage="adult", sex="female", location_id=location_strip1_id, origin="purchased", ) ts_utc = int(time.time() * 1000) animal_service.create_cohort(payload, ts_utc, "test_user") # Get first page to get cursor first_response = client.get( "/registry", headers={"X-Oidc-Username": "test_user"}, ) assert first_response.status_code == 200 # Extract cursor from response (it's in the hx-get attribute) import re cursor_match = re.search(r'cursor=([^&"]+)', first_response.text) assert cursor_match, "Cursor not found in response" cursor = cursor_match.group(1) # Make HTMX request with cursor htmx_response = client.get( f"/registry?cursor={cursor}", headers={ "X-Oidc-Username": "test_user", "HX-Request": "true", }, ) assert htmx_response.status_code == 200 # Should be just table rows, not full page assert "Animal Registry" not in htmx_response.text assert "