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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user