feat: implement Animal Registry view with filtering and pagination (Step 8.1)
- Add status field to filter DSL parser and resolver - Create AnimalRepository with list_animals and get_facet_counts - Implement registry templates with table, facet sidebar, infinite scroll - Create registry route handler with HTMX partial support - Default shows only alive animals; status filter overrides 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
308
tests/test_repository_animals.py
Normal file
308
tests/test_repository_animals.py
Normal file
@@ -0,0 +1,308 @@
|
||||
# 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}
|
||||
Reference in New Issue
Block a user