# ABOUTME: Tests for AnimalRepository - list_animals and get_facet_counts. # ABOUTME: Covers filtering, pagination, and facet aggregation. import time import pytest 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.repositories.animals import AnimalListItem, AnimalRepository, PaginatedResult from animaltrack.services.animal import AnimalService @pytest.fixture def event_store(seeded_db): """Create an EventStore for testing.""" return EventStore(seeded_db) @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, event_store, projection_registry): """Create an AnimalService for testing.""" return AnimalService(seeded_db, event_store, projection_registry) @pytest.fixture def valid_location_id(seeded_db): """Get Strip 1 location ID from seeds.""" row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() return row[0] @pytest.fixture def strip2_location_id(seeded_db): """Get Strip 2 location ID from seeds.""" row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone() return row[0] def make_cohort_payload( location_id: str, count: int = 3, species: str = "duck", sex: str = "unknown", life_stage: str = "adult", ) -> AnimalCohortCreatedPayload: """Create a cohort payload for testing.""" return AnimalCohortCreatedPayload( species=species, count=count, life_stage=life_stage, sex=sex, location_id=location_id, origin="purchased", ) class TestAnimalRepositoryListAnimals: """Tests for list_animals method.""" def test_returns_empty_when_no_animals(self, seeded_db): """list_animals returns empty list when no animals exist.""" repo = AnimalRepository(seeded_db) result = repo.list_animals() assert isinstance(result, PaginatedResult) assert result.items == [] assert result.next_cursor is None assert result.total_count == 0 def test_returns_animals_as_list_items(self, seeded_db, animal_service, valid_location_id): """list_animals returns AnimalListItem objects.""" ts_utc = int(time.time() * 1000) payload = make_cohort_payload(valid_location_id, count=3) animal_service.create_cohort(payload, ts_utc, "test_user") repo = AnimalRepository(seeded_db) result = repo.list_animals() assert len(result.items) == 3 for item in result.items: assert isinstance(item, AnimalListItem) assert item.species_code == "duck" assert item.status == "alive" assert item.location_name == "Strip 1" def test_default_filters_to_alive_only(self, seeded_db, animal_service, valid_location_id): """list_animals defaults to showing only alive animals.""" ts_utc = int(time.time() * 1000) payload = make_cohort_payload(valid_location_id, count=3) event = animal_service.create_cohort(payload, ts_utc, "test_user") ids = event.entity_refs["animal_ids"] # Kill one animal dead_id = ids[0] seeded_db.execute( "UPDATE animal_registry SET status = 'dead' WHERE animal_id = ?", (dead_id,), ) repo = AnimalRepository(seeded_db) result = repo.list_animals() assert len(result.items) == 2 assert all(item.status == "alive" for item in result.items) def test_filters_by_species(self, seeded_db, animal_service, valid_location_id): """list_animals with species filter returns only matching species.""" ts_utc = int(time.time() * 1000) # Create ducks duck_payload = make_cohort_payload(valid_location_id, count=3, species="duck") animal_service.create_cohort(duck_payload, ts_utc, "test_user") # Create geese goose_payload = make_cohort_payload(valid_location_id, count=2, species="goose") animal_service.create_cohort(goose_payload, ts_utc + 1, "test_user") repo = AnimalRepository(seeded_db) result = repo.list_animals(filter_str="species:duck") assert len(result.items) == 3 assert all(item.species_code == "duck" for item in result.items) def test_filters_by_status_dead(self, seeded_db, animal_service, valid_location_id): """list_animals with status:dead returns only dead animals.""" ts_utc = int(time.time() * 1000) payload = make_cohort_payload(valid_location_id, count=3) event = animal_service.create_cohort(payload, ts_utc, "test_user") ids = event.entity_refs["animal_ids"] # Kill one animal dead_id = ids[0] seeded_db.execute( "UPDATE animal_registry SET status = 'dead' WHERE animal_id = ?", (dead_id,), ) repo = AnimalRepository(seeded_db) result = repo.list_animals(filter_str="status:dead") assert len(result.items) == 1 assert result.items[0].animal_id == dead_id def test_orders_by_last_event_descending(self, seeded_db, animal_service, valid_location_id): """list_animals returns animals ordered by last_event_utc DESC.""" ts_utc = int(time.time() * 1000) # Create first cohort payload1 = make_cohort_payload(valid_location_id, count=1) event1 = animal_service.create_cohort(payload1, ts_utc, "test_user") # Create second cohort later payload2 = make_cohort_payload(valid_location_id, count=1) event2 = animal_service.create_cohort(payload2, ts_utc + 1000, "test_user") repo = AnimalRepository(seeded_db) result = repo.list_animals() assert len(result.items) == 2 # Second animal (later event) should come first assert result.items[0].animal_id == event2.entity_refs["animal_ids"][0] assert result.items[1].animal_id == event1.entity_refs["animal_ids"][0] class TestAnimalRepositoryPagination: """Tests for cursor-based pagination.""" def test_limits_results_to_page_size(self, seeded_db, animal_service, valid_location_id): """list_animals returns at most PAGE_SIZE items.""" ts_utc = int(time.time() * 1000) # Create more animals than page size payload = make_cohort_payload(valid_location_id, count=60) animal_service.create_cohort(payload, ts_utc, "test_user") repo = AnimalRepository(seeded_db) result = repo.list_animals() assert len(result.items) == 50 # PAGE_SIZE assert result.next_cursor is not None def test_cursor_returns_next_page(self, seeded_db, animal_service, valid_location_id): """Using cursor returns the next page of results.""" ts_utc = int(time.time() * 1000) # Create 60 animals payload = make_cohort_payload(valid_location_id, count=60) animal_service.create_cohort(payload, ts_utc, "test_user") repo = AnimalRepository(seeded_db) first_page = repo.list_animals() second_page = repo.list_animals(cursor=first_page.next_cursor) assert len(first_page.items) == 50 assert len(second_page.items) == 10 assert second_page.next_cursor is None # No overlap between pages first_ids = {item.animal_id for item in first_page.items} second_ids = {item.animal_id for item in second_page.items} assert first_ids.isdisjoint(second_ids) def test_total_count_is_independent_of_pagination( self, seeded_db, animal_service, valid_location_id ): """total_count reflects all matching animals, not just current page.""" ts_utc = int(time.time() * 1000) payload = make_cohort_payload(valid_location_id, count=60) animal_service.create_cohort(payload, ts_utc, "test_user") repo = AnimalRepository(seeded_db) result = repo.list_animals() assert result.total_count == 60 assert len(result.items) == 50 class TestAnimalRepositoryFacetCounts: """Tests for get_facet_counts method.""" def test_returns_counts_by_status(self, seeded_db, animal_service, valid_location_id): """get_facet_counts returns status breakdown.""" ts_utc = int(time.time() * 1000) payload = make_cohort_payload(valid_location_id, count=3) event = animal_service.create_cohort(payload, ts_utc, "test_user") ids = event.entity_refs["animal_ids"] # Kill one seeded_db.execute( "UPDATE animal_registry SET status = 'dead' WHERE animal_id = ?", (ids[0],), ) repo = AnimalRepository(seeded_db) facets = repo.get_facet_counts() assert facets.by_status["alive"] == 2 assert facets.by_status["dead"] == 1 def test_returns_counts_by_species(self, seeded_db, animal_service, valid_location_id): """get_facet_counts returns species breakdown.""" ts_utc = int(time.time() * 1000) duck_payload = make_cohort_payload(valid_location_id, count=3, species="duck") animal_service.create_cohort(duck_payload, ts_utc, "test_user") goose_payload = make_cohort_payload(valid_location_id, count=2, species="goose") animal_service.create_cohort(goose_payload, ts_utc + 1, "test_user") repo = AnimalRepository(seeded_db) facets = repo.get_facet_counts() assert facets.by_species["duck"] == 3 assert facets.by_species["goose"] == 2 def test_returns_counts_by_location( self, seeded_db, animal_service, valid_location_id, strip2_location_id ): """get_facet_counts returns location breakdown.""" ts_utc = int(time.time() * 1000) # Create at Strip 1 payload1 = make_cohort_payload(valid_location_id, count=3) animal_service.create_cohort(payload1, ts_utc, "test_user") # Create at Strip 2 payload2 = make_cohort_payload(strip2_location_id, count=2) animal_service.create_cohort(payload2, ts_utc + 1, "test_user") repo = AnimalRepository(seeded_db) facets = repo.get_facet_counts() assert facets.by_location[valid_location_id] == 3 assert facets.by_location[strip2_location_id] == 2 def test_facets_respect_filter(self, seeded_db, animal_service, valid_location_id): """get_facet_counts respects the filter parameter.""" ts_utc = int(time.time() * 1000) duck_payload = make_cohort_payload(valid_location_id, count=3, species="duck", sex="female") animal_service.create_cohort(duck_payload, ts_utc, "test_user") goose_payload = make_cohort_payload(valid_location_id, count=2, species="goose", sex="male") animal_service.create_cohort(goose_payload, ts_utc + 1, "test_user") repo = AnimalRepository(seeded_db) # Get facets for ducks only facets = repo.get_facet_counts(filter_str="species:duck") # Only ducks in the counts assert facets.by_species == {"duck": 3} assert facets.by_sex == {"female": 3}