feat: add animal movement projection and service

Implement AnimalMoved event handling:
- Update AnimalRegistryProjection for move events
- Update IntervalProjection to close/open location intervals
- Update EventAnimalsProjection to link move events to animals
- Add move_animals() to AnimalService with validations

Validations include:
- Destination location must exist and be active
- All animals must be from a single location
- Cannot move to the same location as current

🤖 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-29 07:02:19 +00:00
parent 144d23235e
commit b89ea41d63
7 changed files with 1007 additions and 8 deletions

View File

@@ -336,3 +336,257 @@ class TestIntervalProjectionRevert:
# Check correct animal remains
row = seeded_db.execute("SELECT animal_id FROM animal_location_intervals").fetchone()
assert row[0] == animal_ids_2[0]
# =============================================================================
# AnimalMoved Tests
# =============================================================================
def make_move_event(
event_id: str,
animal_ids: list[str],
from_location_id: str,
to_location_id: str,
ts_utc: int = 1704067300000,
) -> Event:
"""Create a test AnimalMoved event."""
from animaltrack.events.types import ANIMAL_MOVED
return Event(
id=event_id,
type=ANIMAL_MOVED,
ts_utc=ts_utc,
actor="test_user",
entity_refs={
"from_location_id": from_location_id,
"to_location_id": to_location_id,
"animal_ids": animal_ids,
},
payload={
"to_location_id": to_location_id,
"resolved_ids": animal_ids,
"notes": None,
},
version=1,
)
class TestIntervalProjectionMoveEventTypes:
"""Tests for get_event_types method including move."""
def test_handles_animal_moved(self, seeded_db):
"""Projection handles AnimalMoved event type."""
from animaltrack.events.types import ANIMAL_MOVED
projection = IntervalProjection(seeded_db)
assert ANIMAL_MOVED in projection.get_event_types()
class TestIntervalProjectionApplyMove:
"""Tests for apply() on AnimalMoved."""
def test_closes_old_location_interval(self, seeded_db):
"""Apply move closes the old location interval with end_utc."""
strip1 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()[0]
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
cohort_ts = 1704067200000
projection = IntervalProjection(seeded_db)
cohort_event = make_cohort_event(event_id, animal_ids, location_id=strip1, ts_utc=cohort_ts)
projection.apply(cohort_event)
# Verify open interval exists
row = seeded_db.execute(
"""SELECT end_utc FROM animal_location_intervals
WHERE animal_id = ? AND location_id = ?""",
(animal_ids[0], strip1),
).fetchone()
assert row[0] is None
# Move to strip2
move_ts = 1704067300000
move_event = make_move_event(
"01ARZ3NDEKTSV4RRFFQ69G5002",
animal_ids,
from_location_id=strip1,
to_location_id=strip2,
ts_utc=move_ts,
)
projection.apply(move_event)
# Old interval should be closed with move timestamp
row = seeded_db.execute(
"""SELECT end_utc FROM animal_location_intervals
WHERE animal_id = ? AND location_id = ?""",
(animal_ids[0], strip1),
).fetchone()
assert row[0] == move_ts
def test_creates_new_location_interval(self, seeded_db):
"""Apply move creates a new open location interval at destination."""
strip1 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()[0]
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
cohort_event = make_cohort_event(event_id, animal_ids, location_id=strip1)
projection.apply(cohort_event)
# Verify only 1 location interval exists initially
count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?",
(animal_ids[0],),
).fetchone()[0]
assert count == 1
# Move to strip2
move_ts = 1704067300000
move_event = make_move_event(
"01ARZ3NDEKTSV4RRFFQ69G5002",
animal_ids,
from_location_id=strip1,
to_location_id=strip2,
ts_utc=move_ts,
)
projection.apply(move_event)
# Now there should be 2 intervals
count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?",
(animal_ids[0],),
).fetchone()[0]
assert count == 2
# New interval should be at strip2, open-ended
row = seeded_db.execute(
"""SELECT start_utc, end_utc FROM animal_location_intervals
WHERE animal_id = ? AND location_id = ?""",
(animal_ids[0], strip2),
).fetchone()
assert row[0] == move_ts
assert row[1] is None
def test_move_multiple_animals_creates_intervals(self, seeded_db):
"""Apply move on multiple animals creates correct intervals."""
strip1 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()[0]
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
animal_ids = [
"01ARZ3NDEKTSV4RRFFQ69G5A01",
"01ARZ3NDEKTSV4RRFFQ69G5A02",
]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
cohort_event = make_cohort_event(event_id, animal_ids, location_id=strip1)
projection.apply(cohort_event)
move_event = make_move_event(
"01ARZ3NDEKTSV4RRFFQ69G5002",
animal_ids,
from_location_id=strip1,
to_location_id=strip2,
)
projection.apply(move_event)
# Each animal should have 2 location intervals (1 closed, 1 open)
for animal_id in animal_ids:
count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?",
(animal_id,),
).fetchone()[0]
assert count == 2
class TestIntervalProjectionRevertMove:
"""Tests for revert() on AnimalMoved."""
def test_revert_removes_new_location_interval(self, seeded_db):
"""Revert move removes the new location interval."""
strip1 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()[0]
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
cohort_event = make_cohort_event(event_id, animal_ids, location_id=strip1)
projection.apply(cohort_event)
move_event = make_move_event(
"01ARZ3NDEKTSV4RRFFQ69G5002",
animal_ids,
from_location_id=strip1,
to_location_id=strip2,
)
projection.apply(move_event)
# Verify 2 intervals exist
count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?",
(animal_ids[0],),
).fetchone()[0]
assert count == 2
# Revert
projection.revert(move_event)
# Now back to 1 interval
count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?",
(animal_ids[0],),
).fetchone()[0]
assert count == 1
# No interval at strip2
row = seeded_db.execute(
"""SELECT * FROM animal_location_intervals
WHERE animal_id = ? AND location_id = ?""",
(animal_ids[0], strip2),
).fetchone()
assert row is None
def test_revert_reopens_old_location_interval(self, seeded_db):
"""Revert move reopens the original location interval (end_utc=NULL)."""
strip1 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()[0]
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
projection = IntervalProjection(seeded_db)
cohort_event = make_cohort_event(event_id, animal_ids, location_id=strip1)
projection.apply(cohort_event)
move_event = make_move_event(
"01ARZ3NDEKTSV4RRFFQ69G5002",
animal_ids,
from_location_id=strip1,
to_location_id=strip2,
)
projection.apply(move_event)
# Verify old interval is closed
row = seeded_db.execute(
"""SELECT end_utc FROM animal_location_intervals
WHERE animal_id = ? AND location_id = ?""",
(animal_ids[0], strip1),
).fetchone()
assert row[0] is not None
# Revert
projection.revert(move_event)
# Old interval should be open again
row = seeded_db.execute(
"""SELECT end_utc FROM animal_location_intervals
WHERE animal_id = ? AND location_id = ?""",
(animal_ids[0], strip1),
).fetchone()
assert row[0] is None