# ABOUTME: Unit tests for /api/facets endpoint. # ABOUTME: Tests dynamic facet count retrieval based on filter. 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 TestApiFacetsEndpoint: """Test GET /api/facets endpoint.""" def test_facets_endpoint_exists(self, client, ducks_at_strip1): """Verify the facets endpoint responds.""" response = client.get("/api/facets") assert response.status_code == 200 def test_facets_returns_html_partial(self, client, ducks_at_strip1): """Facets endpoint returns HTML partial for HTMX swap.""" response = client.get("/api/facets") assert response.status_code == 200 content = response.text # Should be HTML with facet pills structure assert 'id="dsl-facet-pills"' in content assert "Species" in content def test_facets_respects_filter(self, client, ducks_at_strip1, geese_at_strip2): """Facets endpoint applies filter and shows filtered counts.""" # Get facets filtered to ducks only response = client.get("/api/facets?filter=species:duck") assert response.status_code == 200 content = response.text # Should show sex facets for ducks (5 female) assert "female" in content.lower() # Should not show goose sex (male) since we filtered to ducks # (actually it might show male=0 or not at all) def test_facets_shows_count_for_alive_animals(self, client, ducks_at_strip1): """Facets show counts for alive animals by default.""" response = client.get("/api/facets") assert response.status_code == 200 content = response.text # Should show species with counts assert "duck" in content.lower() or "Duck" in content # Count 5 should appear assert "5" in content def test_facets_with_empty_filter(self, client, ducks_at_strip1, geese_at_strip2): """Empty filter returns all alive animals' facets.""" response = client.get("/api/facets?filter=") assert response.status_code == 200 content = response.text # Should have facet pills assert 'id="dsl-facet-pills"' in content def test_facets_with_location_filter(self, client, ducks_at_strip1, geese_at_strip2): """Location filter shows facets for that location only.""" response = client.get('/api/facets?filter=location:"Strip 1"') assert response.status_code == 200 content = response.text # Should show ducks (at Strip 1) assert "duck" in content.lower() or "Duck" in content def test_facets_includes_htmx_swap_attributes(self, client, ducks_at_strip1): """Returned HTML has proper ID for HTMX swap targeting.""" response = client.get("/api/facets") assert response.status_code == 200 content = response.text # Must have same ID for outerHTML swap to work assert 'id="dsl-facet-pills"' in content class TestApiFacetsWithSelectionPreview: """Test facets endpoint integrates with selection preview workflow.""" def test_facets_and_preview_use_same_filter(self, client, ducks_at_strip1, geese_at_strip2): """Both endpoints interpret the same filter consistently.""" filter_str = "species:duck" # Get facets facets_resp = client.get(f"/api/facets?filter={filter_str}") assert facets_resp.status_code == 200 # Get selection preview preview_resp = client.get(f"/api/selection-preview?filter={filter_str}") assert preview_resp.status_code == 200 # Both should work with the same filter