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:
2025-12-30 14:59:13 +00:00
parent 254466827c
commit 8e155080e4
12 changed files with 1630 additions and 28 deletions

View 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}

View File

@@ -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:

View File

@@ -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
View 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