Files
animaltrack/tests/test_projection_intervals.py
Petru Paler 876e8174ee feat: add animal cohort creation projection and service
Implements Step 3.3: Animal Cohort Creation

- Add AnimalRegistryProjection for animal_registry and live_animals_by_location
- Add EventAnimalsProjection for event_animals link table
- Add IntervalProjection for location and attribute intervals
- Add AnimalService with create_cohort() for coordinating event + projections
- Add seeded_db fixture to conftest.py
- Update projections/__init__.py with new exports

All operations atomic within single transaction. Includes validation for
location (exists, active) and species (exists, active).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 06:52:23 +00:00

339 lines
12 KiB
Python

# ABOUTME: Tests for IntervalProjection.
# ABOUTME: Validates interval table updates for location and attributes.
from animaltrack.events.types import ANIMAL_COHORT_CREATED
from animaltrack.models.events import Event
from animaltrack.projections.intervals import IntervalProjection
def make_cohort_event(
event_id: str,
animal_ids: list[str],
location_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
sex: str = "unknown",
life_stage: str = "adult",
ts_utc: int = 1704067200000,
) -> Event:
"""Create a test AnimalCohortCreated event."""
return Event(
id=event_id,
type=ANIMAL_COHORT_CREATED,
ts_utc=ts_utc,
actor="test_user",
entity_refs={
"location_id": location_id,
"animal_ids": animal_ids,
},
payload={
"species": "duck",
"count": len(animal_ids),
"life_stage": life_stage,
"sex": sex,
"location_id": location_id,
"origin": "purchased",
"notes": None,
},
version=1,
)
class TestIntervalProjectionEventTypes:
"""Tests for get_event_types method."""
def test_handles_animal_cohort_created(self, seeded_db):
"""Projection handles AnimalCohortCreated event type."""
projection = IntervalProjection(seeded_db)
assert ANIMAL_COHORT_CREATED in projection.get_event_types()
class TestIntervalProjectionApply:
"""Tests for apply() on AnimalCohortCreated."""
def test_creates_location_interval_for_each_animal(self, seeded_db):
"""Apply creates one location interval per animal."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = [
"01ARZ3NDEKTSV4RRFFQ69G5A01",
"01ARZ3NDEKTSV4RRFFQ69G5A02",
]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
# Check animal_location_intervals has 2 rows
count = seeded_db.execute("SELECT COUNT(*) FROM animal_location_intervals").fetchone()[0]
assert count == 2
# Check each animal has a location interval
for animal_id in animal_ids:
row = seeded_db.execute(
"""SELECT location_id FROM animal_location_intervals
WHERE animal_id = ?""",
(animal_id,),
).fetchone()
assert row is not None
assert row[0] == location_id
def test_location_interval_is_open_ended(self, seeded_db):
"""Location interval has end_utc=NULL."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
row = seeded_db.execute(
"""SELECT end_utc FROM animal_location_intervals
WHERE animal_id = ?""",
(animal_ids[0],),
).fetchone()
assert row[0] is None
def test_location_interval_start_matches_event(self, seeded_db):
"""Location interval start_utc matches event ts_utc."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
ts_utc = 1704067200000
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id, ts_utc=ts_utc)
projection.apply(event)
row = seeded_db.execute(
"""SELECT start_utc FROM animal_location_intervals
WHERE animal_id = ?""",
(animal_ids[0],),
).fetchone()
assert row[0] == ts_utc
def test_creates_sex_attr_interval(self, seeded_db):
"""Apply creates sex attribute interval."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id, sex="female")
projection.apply(event)
row = seeded_db.execute(
"""SELECT value FROM animal_attr_intervals
WHERE animal_id = ? AND attr = 'sex'""",
(animal_ids[0],),
).fetchone()
assert row is not None
assert row[0] == "female"
def test_creates_life_stage_attr_interval(self, seeded_db):
"""Apply creates life_stage attribute interval."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(
event_id, animal_ids, location_id=location_id, life_stage="juvenile"
)
projection.apply(event)
row = seeded_db.execute(
"""SELECT value FROM animal_attr_intervals
WHERE animal_id = ? AND attr = 'life_stage'""",
(animal_ids[0],),
).fetchone()
assert row is not None
assert row[0] == "juvenile"
def test_creates_repro_status_attr_interval_unknown(self, seeded_db):
"""Apply creates repro_status attribute interval (default unknown)."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
row = seeded_db.execute(
"""SELECT value FROM animal_attr_intervals
WHERE animal_id = ? AND attr = 'repro_status'""",
(animal_ids[0],),
).fetchone()
assert row is not None
assert row[0] == "unknown"
def test_creates_status_attr_interval_alive(self, seeded_db):
"""Apply creates status attribute interval (alive)."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
row = seeded_db.execute(
"""SELECT value FROM animal_attr_intervals
WHERE animal_id = ? AND attr = 'status'""",
(animal_ids[0],),
).fetchone()
assert row is not None
assert row[0] == "alive"
def test_creates_four_attr_intervals_per_animal(self, seeded_db):
"""Each animal gets 4 attribute intervals (sex, life_stage, repro_status, status)."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
count = seeded_db.execute(
"""SELECT COUNT(*) FROM animal_attr_intervals
WHERE animal_id = ?""",
(animal_ids[0],),
).fetchone()[0]
assert count == 4
def test_attr_intervals_are_open_ended(self, seeded_db):
"""All attribute intervals have end_utc=NULL."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
rows = seeded_db.execute(
"""SELECT end_utc FROM animal_attr_intervals
WHERE animal_id = ?""",
(animal_ids[0],),
).fetchall()
assert len(rows) == 4
for row in rows:
assert row[0] is None
class TestIntervalProjectionRevert:
"""Tests for revert() on AnimalCohortCreated."""
def test_removes_location_intervals(self, seeded_db):
"""Revert deletes location intervals for event animals."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = [
"01ARZ3NDEKTSV4RRFFQ69G5A01",
"01ARZ3NDEKTSV4RRFFQ69G5A02",
]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
# Verify rows exist
count = seeded_db.execute("SELECT COUNT(*) FROM animal_location_intervals").fetchone()[0]
assert count == 2
# Revert
projection.revert(event)
# Verify rows removed
count = seeded_db.execute("SELECT COUNT(*) FROM animal_location_intervals").fetchone()[0]
assert count == 0
def test_removes_attr_intervals(self, seeded_db):
"""Revert deletes all attribute intervals for event animals."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
projection.apply(event)
# Verify rows exist
count = seeded_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0]
assert count == 4
# Revert
projection.revert(event)
# Verify rows removed
count = seeded_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0]
assert count == 0
def test_revert_only_affects_event_animals(self, seeded_db):
"""Revert only removes intervals for animals from specific event."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
location_id = row[0]
# Create first event
animal_ids_1 = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id_1 = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
event1 = make_cohort_event(event_id_1, animal_ids_1, location_id=location_id)
projection.apply(event1)
# Create second event
animal_ids_2 = ["01ARZ3NDEKTSV4RRFFQ69G5A02"]
event_id_2 = "01ARZ3NDEKTSV4RRFFQ69G5002"
event2 = make_cohort_event(
event_id_2, animal_ids_2, location_id=location_id, ts_utc=1704067300000
)
projection.apply(event2)
# Verify both exist: 2 location intervals, 8 attr intervals
count_loc = seeded_db.execute("SELECT COUNT(*) FROM animal_location_intervals").fetchone()[
0
]
assert count_loc == 2
count_attr = seeded_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0]
assert count_attr == 8
# Revert only event1
projection.revert(event1)
# Event2's intervals should still exist
count_loc = seeded_db.execute("SELECT COUNT(*) FROM animal_location_intervals").fetchone()[
0
]
assert count_loc == 1
count_attr = seeded_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0]
assert count_attr == 4
# Check correct animal remains
row = seeded_db.execute("SELECT animal_id FROM animal_location_intervals").fetchone()
assert row[0] == animal_ids_2[0]