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:
@@ -270,3 +270,225 @@ class TestAnimalServiceTransactionIntegrity:
|
||||
|
||||
animal_count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||
assert animal_count == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# move_animals Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_move_payload(
|
||||
to_location_id: str,
|
||||
resolved_ids: list[str],
|
||||
):
|
||||
"""Create a move payload for testing."""
|
||||
from animaltrack.events.payloads import AnimalMovedPayload
|
||||
|
||||
return AnimalMovedPayload(
|
||||
to_location_id=to_location_id,
|
||||
resolved_ids=resolved_ids,
|
||||
)
|
||||
|
||||
|
||||
class TestAnimalServiceMoveAnimals:
|
||||
"""Tests for move_animals()."""
|
||||
|
||||
def test_creates_animal_moved_event(self, seeded_db, animal_service, valid_location_id):
|
||||
"""move_animals creates an AnimalMoved event."""
|
||||
from animaltrack.events.types import ANIMAL_MOVED
|
||||
|
||||
# First create a cohort
|
||||
cohort_payload = make_payload(valid_location_id, count=3)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
# Get another location
|
||||
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
|
||||
|
||||
# Move the animals
|
||||
move_payload = make_move_payload(strip2, animal_ids)
|
||||
move_ts = ts_utc + 1000
|
||||
move_event = animal_service.move_animals(move_payload, move_ts, "test_user")
|
||||
|
||||
assert move_event.type == ANIMAL_MOVED
|
||||
assert move_event.actor == "test_user"
|
||||
assert move_event.ts_utc == move_ts
|
||||
|
||||
def test_event_has_animal_ids_in_entity_refs(
|
||||
self, seeded_db, animal_service, valid_location_id
|
||||
):
|
||||
"""Event entity_refs contains animal_ids list."""
|
||||
cohort_payload = make_payload(valid_location_id, count=2)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
|
||||
|
||||
move_payload = make_move_payload(strip2, animal_ids)
|
||||
move_event = animal_service.move_animals(move_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
assert "animal_ids" in move_event.entity_refs
|
||||
assert set(move_event.entity_refs["animal_ids"]) == set(animal_ids)
|
||||
|
||||
def test_event_has_from_and_to_location_in_entity_refs(
|
||||
self, seeded_db, animal_service, valid_location_id
|
||||
):
|
||||
"""Event entity_refs contains both from_location_id and to_location_id."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
|
||||
|
||||
move_payload = make_move_payload(strip2, animal_ids)
|
||||
move_event = animal_service.move_animals(move_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
assert move_event.entity_refs["from_location_id"] == valid_location_id
|
||||
assert move_event.entity_refs["to_location_id"] == strip2
|
||||
|
||||
def test_updates_location_in_registry(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Animals are moved in animal_registry table."""
|
||||
cohort_payload = make_payload(valid_location_id, count=2)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
|
||||
|
||||
move_payload = make_move_payload(strip2, animal_ids)
|
||||
animal_service.move_animals(move_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Check each animal is now at strip2
|
||||
for animal_id in animal_ids:
|
||||
row = seeded_db.execute(
|
||||
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()
|
||||
assert row[0] == strip2
|
||||
|
||||
def test_creates_location_intervals(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Move creates new location intervals and closes old ones."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
|
||||
|
||||
move_payload = make_move_payload(strip2, animal_ids)
|
||||
animal_service.move_animals(move_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Should have 2 location intervals: one closed (strip1), one open (strip2)
|
||||
count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?",
|
||||
(animal_ids[0],),
|
||||
).fetchone()[0]
|
||||
assert count == 2
|
||||
|
||||
def test_event_animal_links_created(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Event-animal links are created for move event."""
|
||||
cohort_payload = make_payload(valid_location_id, count=3)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
|
||||
|
||||
move_payload = make_move_payload(strip2, animal_ids)
|
||||
move_event = animal_service.move_animals(move_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Check event_animals has 3 rows for the move event
|
||||
count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM event_animals WHERE event_id = ?",
|
||||
(move_event.id,),
|
||||
).fetchone()[0]
|
||||
assert count == 3
|
||||
|
||||
|
||||
class TestAnimalServiceMoveValidation:
|
||||
"""Tests for move_animals() validation."""
|
||||
|
||||
def test_rejects_nonexistent_to_location(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Raises ValidationError for non-existent to_location_id."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
fake_location_id = "01ARZ3NDEKTSV4RRFFQ69G5XXX"
|
||||
move_payload = make_move_payload(fake_location_id, animal_ids)
|
||||
|
||||
with pytest.raises(ValidationError, match="not found"):
|
||||
animal_service.move_animals(move_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
def test_rejects_archived_to_location(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Raises ValidationError for archived to_location."""
|
||||
from animaltrack.id_gen import generate_id
|
||||
|
||||
# Create an archived location
|
||||
archived_id = generate_id()
|
||||
ts = int(time.time() * 1000)
|
||||
seeded_db.execute(
|
||||
"""INSERT INTO locations (id, name, active, created_at_utc, updated_at_utc)
|
||||
VALUES (?, 'Archived Test', 0, ?, ?)""",
|
||||
(archived_id, ts, ts),
|
||||
)
|
||||
|
||||
cohort_payload = make_payload(valid_location_id, count=1)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
move_payload = make_move_payload(archived_id, animal_ids)
|
||||
|
||||
with pytest.raises(ValidationError, match="archived"):
|
||||
animal_service.move_animals(move_payload, ts + 1000, "test_user")
|
||||
|
||||
def test_rejects_same_location(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Raises ValidationError when moving to the same location."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
# Try to move to the same location
|
||||
move_payload = make_move_payload(valid_location_id, animal_ids)
|
||||
|
||||
with pytest.raises(ValidationError, match="same location"):
|
||||
animal_service.move_animals(move_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
def test_rejects_animals_from_multiple_locations(self, seeded_db, animal_service):
|
||||
"""Raises ValidationError when animals are from different locations."""
|
||||
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]
|
||||
strip3 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 3'").fetchone()[0]
|
||||
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# Create a cohort at strip1
|
||||
cohort1 = animal_service.create_cohort(make_payload(strip1, count=1), ts_utc, "test_user")
|
||||
animal1 = cohort1.entity_refs["animal_ids"][0]
|
||||
|
||||
# Create a cohort at strip2
|
||||
cohort2 = animal_service.create_cohort(
|
||||
make_payload(strip2, count=1), ts_utc + 1000, "test_user"
|
||||
)
|
||||
animal2 = cohort2.entity_refs["animal_ids"][0]
|
||||
|
||||
# Try to move animals from different locations
|
||||
move_payload = make_move_payload(strip3, [animal1, animal2])
|
||||
|
||||
with pytest.raises(ValidationError, match="single location"):
|
||||
animal_service.move_animals(move_payload, ts_utc + 2000, "test_user")
|
||||
|
||||
def test_rejects_nonexistent_animal(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Raises ValidationError for non-existent animal_id."""
|
||||
strip2 = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
|
||||
|
||||
fake_animal_id = "01ARZ3NDEKTSV4RRFFQ69G5XXX"
|
||||
move_payload = make_move_payload(strip2, [fake_animal_id])
|
||||
|
||||
with pytest.raises(ValidationError, match="not found"):
|
||||
animal_service.move_animals(move_payload, int(time.time() * 1000), "test_user")
|
||||
|
||||
Reference in New Issue
Block a user