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:
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||||
from animaltrack.models.events import Event
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.projections.base import Projection
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
@@ -26,17 +26,21 @@ class AnimalRegistryProjection(Projection):
|
|||||||
|
|
||||||
def get_event_types(self) -> list[str]:
|
def get_event_types(self) -> list[str]:
|
||||||
"""Return the event types this projection handles."""
|
"""Return the event types this projection handles."""
|
||||||
return [ANIMAL_COHORT_CREATED]
|
return [ANIMAL_COHORT_CREATED, ANIMAL_MOVED]
|
||||||
|
|
||||||
def apply(self, event: Event) -> None:
|
def apply(self, event: Event) -> None:
|
||||||
"""Apply an event to update registry tables."""
|
"""Apply an event to update registry tables."""
|
||||||
if event.type == ANIMAL_COHORT_CREATED:
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
self._apply_cohort_created(event)
|
self._apply_cohort_created(event)
|
||||||
|
elif event.type == ANIMAL_MOVED:
|
||||||
|
self._apply_animal_moved(event)
|
||||||
|
|
||||||
def revert(self, event: Event) -> None:
|
def revert(self, event: Event) -> None:
|
||||||
"""Revert an event from registry tables."""
|
"""Revert an event from registry tables."""
|
||||||
if event.type == ANIMAL_COHORT_CREATED:
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
self._revert_cohort_created(event)
|
self._revert_cohort_created(event)
|
||||||
|
elif event.type == ANIMAL_MOVED:
|
||||||
|
self._revert_animal_moved(event)
|
||||||
|
|
||||||
def _apply_cohort_created(self, event: Event) -> None:
|
def _apply_cohort_created(self, event: Event) -> None:
|
||||||
"""Create animals in registry from cohort event.
|
"""Create animals in registry from cohort event.
|
||||||
@@ -121,3 +125,65 @@ class AnimalRegistryProjection(Projection):
|
|||||||
"DELETE FROM animal_registry WHERE animal_id = ?",
|
"DELETE FROM animal_registry WHERE animal_id = ?",
|
||||||
(animal_id,),
|
(animal_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_animal_moved(self, event: Event) -> None:
|
||||||
|
"""Update animal locations from move event.
|
||||||
|
|
||||||
|
Updates both animal_registry and live_animals_by_location
|
||||||
|
with the new location_id.
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
to_location_id = event.entity_refs.get("to_location_id")
|
||||||
|
ts_utc = event.ts_utc
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Update animal_registry
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE animal_registry
|
||||||
|
SET location_id = ?, last_event_utc = ?
|
||||||
|
WHERE animal_id = ?
|
||||||
|
""",
|
||||||
|
(to_location_id, ts_utc, animal_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update live_animals_by_location
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE live_animals_by_location
|
||||||
|
SET location_id = ?, last_move_utc = ?
|
||||||
|
WHERE animal_id = ?
|
||||||
|
""",
|
||||||
|
(to_location_id, ts_utc, animal_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _revert_animal_moved(self, event: Event) -> None:
|
||||||
|
"""Revert animal move, restoring original location.
|
||||||
|
|
||||||
|
Uses from_location_id from entity_refs to restore
|
||||||
|
the previous location state.
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
from_location_id = event.entity_refs.get("from_location_id")
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Restore animal_registry location
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE animal_registry
|
||||||
|
SET location_id = ?
|
||||||
|
WHERE animal_id = ?
|
||||||
|
""",
|
||||||
|
(from_location_id, animal_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restore live_animals_by_location
|
||||||
|
# Set last_move_utc to NULL since we're reverting to before the move
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE live_animals_by_location
|
||||||
|
SET location_id = ?, last_move_utc = NULL
|
||||||
|
WHERE animal_id = ?
|
||||||
|
""",
|
||||||
|
(from_location_id, animal_id),
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||||
from animaltrack.models.events import Event
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.projections.base import Projection
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class EventAnimalsProjection(Projection):
|
|||||||
|
|
||||||
def get_event_types(self) -> list[str]:
|
def get_event_types(self) -> list[str]:
|
||||||
"""Return the event types this projection handles."""
|
"""Return the event types this projection handles."""
|
||||||
return [ANIMAL_COHORT_CREATED]
|
return [ANIMAL_COHORT_CREATED, ANIMAL_MOVED]
|
||||||
|
|
||||||
def apply(self, event: Event) -> None:
|
def apply(self, event: Event) -> None:
|
||||||
"""Link event to affected animals."""
|
"""Link event to affected animals."""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||||
from animaltrack.models.events import Event
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.projections.base import Projection
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
@@ -29,17 +29,21 @@ class IntervalProjection(Projection):
|
|||||||
|
|
||||||
def get_event_types(self) -> list[str]:
|
def get_event_types(self) -> list[str]:
|
||||||
"""Return the event types this projection handles."""
|
"""Return the event types this projection handles."""
|
||||||
return [ANIMAL_COHORT_CREATED]
|
return [ANIMAL_COHORT_CREATED, ANIMAL_MOVED]
|
||||||
|
|
||||||
def apply(self, event: Event) -> None:
|
def apply(self, event: Event) -> None:
|
||||||
"""Create intervals for event."""
|
"""Create intervals for event."""
|
||||||
if event.type == ANIMAL_COHORT_CREATED:
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
self._apply_cohort_created(event)
|
self._apply_cohort_created(event)
|
||||||
|
elif event.type == ANIMAL_MOVED:
|
||||||
|
self._apply_animal_moved(event)
|
||||||
|
|
||||||
def revert(self, event: Event) -> None:
|
def revert(self, event: Event) -> None:
|
||||||
"""Remove intervals created by event."""
|
"""Remove intervals created by event."""
|
||||||
if event.type == ANIMAL_COHORT_CREATED:
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
self._revert_cohort_created(event)
|
self._revert_cohort_created(event)
|
||||||
|
elif event.type == ANIMAL_MOVED:
|
||||||
|
self._revert_animal_moved(event)
|
||||||
|
|
||||||
def _apply_cohort_created(self, event: Event) -> None:
|
def _apply_cohort_created(self, event: Event) -> None:
|
||||||
"""Create initial intervals for new animals.
|
"""Create initial intervals for new animals.
|
||||||
@@ -101,3 +105,68 @@ class IntervalProjection(Projection):
|
|||||||
"DELETE FROM animal_attr_intervals WHERE animal_id = ?",
|
"DELETE FROM animal_attr_intervals WHERE animal_id = ?",
|
||||||
(animal_id,),
|
(animal_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_animal_moved(self, event: Event) -> None:
|
||||||
|
"""Close old location interval and create new one for move.
|
||||||
|
|
||||||
|
For each animal:
|
||||||
|
- Close the current open location interval with end_utc=ts_utc
|
||||||
|
- Create a new open interval at the destination location
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
from_location_id = event.entity_refs.get("from_location_id")
|
||||||
|
to_location_id = event.entity_refs.get("to_location_id")
|
||||||
|
ts_utc = event.ts_utc
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Close the old location interval
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE animal_location_intervals
|
||||||
|
SET end_utc = ?
|
||||||
|
WHERE animal_id = ? AND location_id = ? AND end_utc IS NULL
|
||||||
|
""",
|
||||||
|
(ts_utc, animal_id, from_location_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new location interval at destination
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO animal_location_intervals
|
||||||
|
(animal_id, location_id, start_utc, end_utc)
|
||||||
|
VALUES (?, ?, ?, NULL)
|
||||||
|
""",
|
||||||
|
(animal_id, to_location_id, ts_utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _revert_animal_moved(self, event: Event) -> None:
|
||||||
|
"""Revert move by removing new interval and reopening old one.
|
||||||
|
|
||||||
|
For each animal:
|
||||||
|
- Delete the new location interval at destination
|
||||||
|
- Reopen the old interval by setting end_utc=NULL
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
from_location_id = event.entity_refs.get("from_location_id")
|
||||||
|
to_location_id = event.entity_refs.get("to_location_id")
|
||||||
|
ts_utc = event.ts_utc
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Delete the new location interval
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM animal_location_intervals
|
||||||
|
WHERE animal_id = ? AND location_id = ? AND start_utc = ?
|
||||||
|
""",
|
||||||
|
(animal_id, to_location_id, ts_utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reopen the old location interval
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE animal_location_intervals
|
||||||
|
SET end_utc = NULL
|
||||||
|
WHERE animal_id = ? AND location_id = ? AND end_utc = ?
|
||||||
|
""",
|
||||||
|
(animal_id, from_location_id, ts_utc),
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from animaltrack.db import transaction
|
from animaltrack.db import transaction
|
||||||
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload, AnimalMovedPayload
|
||||||
from animaltrack.events.processor import process_event
|
from animaltrack.events.processor import process_event
|
||||||
from animaltrack.events.store import EventStore
|
from animaltrack.events.store import EventStore
|
||||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||||
from animaltrack.id_gen import generate_id
|
from animaltrack.id_gen import generate_id
|
||||||
from animaltrack.models.events import Event
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.projections import ProjectionRegistry
|
from animaltrack.projections import ProjectionRegistry
|
||||||
@@ -145,3 +145,104 @@ class AnimalService:
|
|||||||
if not species.active:
|
if not species.active:
|
||||||
msg = f"Species {species_code} is not active"
|
msg = f"Species {species_code} is not active"
|
||||||
raise ValidationError(msg)
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
def move_animals(
|
||||||
|
self,
|
||||||
|
payload: AnimalMovedPayload,
|
||||||
|
ts_utc: int,
|
||||||
|
actor: str,
|
||||||
|
nonce: str | None = None,
|
||||||
|
route: str | None = None,
|
||||||
|
) -> Event:
|
||||||
|
"""Move animals to a new location.
|
||||||
|
|
||||||
|
Creates an AnimalMoved event and processes it through
|
||||||
|
all registered projections. All operations happen atomically
|
||||||
|
within a transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Validated move payload with to_location_id and resolved_ids.
|
||||||
|
ts_utc: Timestamp in milliseconds since epoch.
|
||||||
|
actor: The user performing the move.
|
||||||
|
nonce: Optional idempotency nonce.
|
||||||
|
route: Required if nonce provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created event.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If validation fails.
|
||||||
|
"""
|
||||||
|
# Validate destination location exists and is active
|
||||||
|
self._validate_location(payload.to_location_id)
|
||||||
|
|
||||||
|
# Validate all animals exist and get their current location
|
||||||
|
from_location_id = self._validate_animals_for_move(
|
||||||
|
payload.resolved_ids, payload.to_location_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build entity_refs with from/to locations and animal IDs
|
||||||
|
entity_refs = {
|
||||||
|
"from_location_id": from_location_id,
|
||||||
|
"to_location_id": payload.to_location_id,
|
||||||
|
"animal_ids": payload.resolved_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction(self.db):
|
||||||
|
# Append event to store
|
||||||
|
event = self.event_store.append_event(
|
||||||
|
event_type=ANIMAL_MOVED,
|
||||||
|
ts_utc=ts_utc,
|
||||||
|
actor=actor,
|
||||||
|
entity_refs=entity_refs,
|
||||||
|
payload=payload.model_dump(),
|
||||||
|
nonce=nonce,
|
||||||
|
route=route,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process event through projections
|
||||||
|
process_event(event, self.registry)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
def _validate_animals_for_move(self, animal_ids: list[str], to_location_id: str) -> str:
|
||||||
|
"""Validate animals exist and are from a single location.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
animal_ids: List of animal IDs to validate.
|
||||||
|
to_location_id: The destination location ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The from_location_id (current location of all animals).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If animals don't exist, are from multiple
|
||||||
|
locations, or are already at the destination.
|
||||||
|
"""
|
||||||
|
locations: set[str] = set()
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
row = self.db.execute(
|
||||||
|
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
msg = f"Animal {animal_id} not found"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
locations.add(row[0])
|
||||||
|
|
||||||
|
# Check all animals are from the same location
|
||||||
|
if len(locations) > 1:
|
||||||
|
msg = "All animals must be from a single location"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
from_location_id = locations.pop()
|
||||||
|
|
||||||
|
# Check not moving to the same location
|
||||||
|
if from_location_id == to_location_id:
|
||||||
|
msg = "Cannot move animals to the same location"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
return from_location_id
|
||||||
|
|||||||
@@ -403,3 +403,290 @@ class TestAnimalRegistryProjectionRevert:
|
|||||||
|
|
||||||
row = seeded_db.execute("SELECT animal_id FROM animal_registry").fetchone()
|
row = seeded_db.execute("SELECT animal_id FROM animal_registry").fetchone()
|
||||||
assert row[0] == animal_ids_2[0]
|
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 TestAnimalRegistryProjectionMoveEventTypes:
|
||||||
|
"""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 = AnimalRegistryProjection(seeded_db)
|
||||||
|
assert ANIMAL_MOVED in projection.get_event_types()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalRegistryProjectionApplyMove:
|
||||||
|
"""Tests for apply() on AnimalMoved."""
|
||||||
|
|
||||||
|
def test_updates_location_in_animal_registry(self, seeded_db):
|
||||||
|
"""Apply move updates location_id in animal_registry."""
|
||||||
|
# Get two location IDs
|
||||||
|
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]
|
||||||
|
|
||||||
|
# First create a cohort at Strip 1
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(animal_ids, location_id=strip1)
|
||||||
|
projection.apply(cohort_event)
|
||||||
|
|
||||||
|
# Verify initial location
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == strip1
|
||||||
|
|
||||||
|
# Now move to Strip 2
|
||||||
|
move_event = make_move_event(
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5002",
|
||||||
|
animal_ids,
|
||||||
|
from_location_id=strip1,
|
||||||
|
to_location_id=strip2,
|
||||||
|
)
|
||||||
|
projection.apply(move_event)
|
||||||
|
|
||||||
|
# Verify new location
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == strip2
|
||||||
|
|
||||||
|
def test_updates_last_event_utc_in_registry(self, seeded_db):
|
||||||
|
"""Apply move updates last_event_utc in animal_registry."""
|
||||||
|
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"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(animal_ids, location_id=strip1, ts_utc=1704067200000)
|
||||||
|
projection.apply(cohort_event)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT last_event_utc FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == move_ts
|
||||||
|
|
||||||
|
def test_updates_location_in_live_animals(self, seeded_db):
|
||||||
|
"""Apply move updates location_id in live_animals_by_location."""
|
||||||
|
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"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(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)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT location_id FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == strip2
|
||||||
|
|
||||||
|
def test_updates_last_move_utc_in_live_animals(self, seeded_db):
|
||||||
|
"""Apply move updates last_move_utc in live_animals_by_location."""
|
||||||
|
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"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(animal_ids, location_id=strip1)
|
||||||
|
projection.apply(cohort_event)
|
||||||
|
|
||||||
|
# Verify last_move_utc is NULL initially
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT last_move_utc FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] is None
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT last_move_utc FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == move_ts
|
||||||
|
|
||||||
|
def test_moves_multiple_animals(self, seeded_db):
|
||||||
|
"""Apply move updates all animals in resolved_ids."""
|
||||||
|
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",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A03",
|
||||||
|
]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(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)
|
||||||
|
|
||||||
|
# All animals should now be 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
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalRegistryProjectionRevertMove:
|
||||||
|
"""Tests for revert() on AnimalMoved."""
|
||||||
|
|
||||||
|
def test_revert_restores_original_location_in_registry(self, seeded_db):
|
||||||
|
"""Revert move restores location_id in animal_registry."""
|
||||||
|
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"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(animal_ids, location_id=strip1, ts_utc=1704067200000)
|
||||||
|
projection.apply(cohort_event)
|
||||||
|
|
||||||
|
move_event = make_move_event(
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5002",
|
||||||
|
animal_ids,
|
||||||
|
from_location_id=strip1,
|
||||||
|
to_location_id=strip2,
|
||||||
|
ts_utc=1704067300000,
|
||||||
|
)
|
||||||
|
projection.apply(move_event)
|
||||||
|
|
||||||
|
# Verify moved
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == strip2
|
||||||
|
|
||||||
|
# Revert the move
|
||||||
|
projection.revert(move_event)
|
||||||
|
|
||||||
|
# Location should be restored
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == strip1
|
||||||
|
|
||||||
|
def test_revert_restores_original_location_in_live_animals(self, seeded_db):
|
||||||
|
"""Revert move restores location_id in live_animals_by_location."""
|
||||||
|
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"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(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)
|
||||||
|
projection.revert(move_event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT location_id FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == strip1
|
||||||
|
|
||||||
|
def test_revert_clears_last_move_utc_if_first_move(self, seeded_db):
|
||||||
|
"""Revert first move clears last_move_utc to 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"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
cohort_event = make_cohort_event(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)
|
||||||
|
projection.revert(move_event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT last_move_utc FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] is None
|
||||||
|
|||||||
@@ -336,3 +336,257 @@ class TestIntervalProjectionRevert:
|
|||||||
# Check correct animal remains
|
# Check correct animal remains
|
||||||
row = seeded_db.execute("SELECT animal_id FROM animal_location_intervals").fetchone()
|
row = seeded_db.execute("SELECT animal_id FROM animal_location_intervals").fetchone()
|
||||||
assert row[0] == animal_ids_2[0]
|
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
|
||||||
|
|||||||
@@ -270,3 +270,225 @@ class TestAnimalServiceTransactionIntegrity:
|
|||||||
|
|
||||||
animal_count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
animal_count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
assert animal_count == 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