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}
|
||||
@@ -27,13 +27,52 @@ class TestSimpleFilters:
|
||||
|
||||
def test_all_supported_fields(self) -> None:
|
||||
"""All supported fields should parse."""
|
||||
result = parse_filter("location:strip1 species:duck sex:male life_stage:adult tag:healthy")
|
||||
assert len(result.filters) == 5
|
||||
result = parse_filter(
|
||||
"location:strip1 species:duck sex:male life_stage:adult tag:healthy status:alive"
|
||||
)
|
||||
assert len(result.filters) == 6
|
||||
assert result.filters[0].field == "location"
|
||||
assert result.filters[1].field == "species"
|
||||
assert result.filters[2].field == "sex"
|
||||
assert result.filters[3].field == "life_stage"
|
||||
assert result.filters[4].field == "tag"
|
||||
assert result.filters[5].field == "status"
|
||||
|
||||
|
||||
class TestStatusField:
|
||||
"""Test the status field for filtering by animal status."""
|
||||
|
||||
def test_status_alive(self) -> None:
|
||||
"""status:alive -> filter by alive status."""
|
||||
result = parse_filter("status:alive")
|
||||
assert result == FilterAST([FieldFilter("status", ["alive"])])
|
||||
|
||||
def test_status_multiple_values(self) -> None:
|
||||
"""status:alive|dead -> OR of statuses."""
|
||||
result = parse_filter("status:alive|dead")
|
||||
assert result == FilterAST([FieldFilter("status", ["alive", "dead"])])
|
||||
|
||||
def test_status_all_values(self) -> None:
|
||||
"""All status values should parse."""
|
||||
result = parse_filter("status:alive|dead|harvested|sold|merged_into")
|
||||
assert result == FilterAST(
|
||||
[FieldFilter("status", ["alive", "dead", "harvested", "sold", "merged_into"])]
|
||||
)
|
||||
|
||||
def test_status_negated(self) -> None:
|
||||
"""-status:dead -> exclude dead animals."""
|
||||
result = parse_filter("-status:dead")
|
||||
assert result == FilterAST([FieldFilter("status", ["dead"], negated=True)])
|
||||
|
||||
def test_status_combined_with_other_fields(self) -> None:
|
||||
"""status:alive species:duck -> combine status with other filters."""
|
||||
result = parse_filter("status:alive species:duck")
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("status", ["alive"]),
|
||||
FieldFilter("species", ["duck"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestOrSyntax:
|
||||
|
||||
@@ -370,6 +370,179 @@ class TestResolveFilterIdentified:
|
||||
assert result.animal_ids == expected
|
||||
|
||||
|
||||
class TestResolveFilterStatus:
|
||||
"""Tests for status filter."""
|
||||
|
||||
def test_status_alive_is_default(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Empty filter returns only alive animals (status:alive is implicit)."""
|
||||
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 by updating status in attr_intervals
|
||||
dead_id = ids[0]
|
||||
seeded_db.execute(
|
||||
"""
|
||||
UPDATE animal_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# Default filter excludes dead
|
||||
result = resolve_filter(seeded_db, FilterAST([]), ts_utc + 2)
|
||||
assert dead_id not in result.animal_ids
|
||||
assert len(result.animal_ids) == 2
|
||||
|
||||
def test_status_dead_returns_dead_animals(self, seeded_db, animal_service, valid_location_id):
|
||||
"""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_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# status:dead filter returns only dead
|
||||
filter_ast = FilterAST([FieldFilter("status", ["dead"])])
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 2)
|
||||
assert result.animal_ids == [dead_id]
|
||||
|
||||
def test_status_alive_or_dead_returns_both(self, seeded_db, animal_service, valid_location_id):
|
||||
"""status:alive|dead returns both alive and 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_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# status:alive|dead filter returns all
|
||||
filter_ast = FilterAST([FieldFilter("status", ["alive", "dead"])])
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 2)
|
||||
assert sorted(result.animal_ids) == sorted(ids)
|
||||
|
||||
def test_status_negated_excludes(self, seeded_db, animal_service, valid_location_id):
|
||||
"""-status:dead excludes dead animals (same as default)."""
|
||||
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_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# -status:dead uses alive as base, then excludes dead (effectively same as default)
|
||||
filter_ast = FilterAST([FieldFilter("status", ["dead"], negated=True)])
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 2)
|
||||
assert dead_id not in result.animal_ids
|
||||
|
||||
def test_status_combined_with_species(self, seeded_db, animal_service, valid_location_id):
|
||||
"""status:dead species:duck returns only dead ducks."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# Create ducks
|
||||
duck_payload = make_cohort_payload(valid_location_id, count=2, species="duck")
|
||||
duck_event = animal_service.create_cohort(duck_payload, ts_utc, "test_user")
|
||||
duck_ids = duck_event.entity_refs["animal_ids"]
|
||||
|
||||
# Create geese
|
||||
goose_payload = make_cohort_payload(valid_location_id, count=2, species="goose")
|
||||
goose_event = animal_service.create_cohort(goose_payload, ts_utc + 1, "test_user")
|
||||
goose_ids = goose_event.entity_refs["animal_ids"]
|
||||
|
||||
# Kill one duck and one goose
|
||||
dead_duck_id = duck_ids[0]
|
||||
dead_goose_id = goose_ids[0]
|
||||
|
||||
for dead_id in [dead_duck_id, dead_goose_id]:
|
||||
seeded_db.execute(
|
||||
"""
|
||||
UPDATE animal_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 2, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 2),
|
||||
)
|
||||
|
||||
# status:dead species:duck returns only dead duck
|
||||
filter_ast = FilterAST(
|
||||
[
|
||||
FieldFilter("status", ["dead"]),
|
||||
FieldFilter("species", ["duck"]),
|
||||
]
|
||||
)
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 3)
|
||||
assert result.animal_ids == [dead_duck_id]
|
||||
|
||||
|
||||
class TestResolveFilterNegation:
|
||||
"""Tests for negated filters."""
|
||||
|
||||
|
||||
300
tests/test_web_registry.py
Normal file
300
tests/test_web_registry.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# 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 "<tr" in htmx_response.text
|
||||
|
||||
def test_last_page_has_no_sentinel(self, client, ducks_at_strip1):
|
||||
"""Last page has no load-more sentinel when all items fit."""
|
||||
response = client.get(
|
||||
"/registry",
|
||||
headers={"X-Oidc-Username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "5 animals" in response.text
|
||||
assert "load-more-sentinel" not in response.text
|
||||
Reference in New Issue
Block a user