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>
This commit is contained in:
@@ -1,7 +1,17 @@
|
|||||||
# ABOUTME: Projection system for maintaining read models from events.
|
# ABOUTME: Projection system for maintaining read models from events.
|
||||||
# ABOUTME: Exports Projection base class, ProjectionRegistry, and ProjectionError.
|
# ABOUTME: Exports Projection base class, ProjectionRegistry, and ProjectionError.
|
||||||
|
|
||||||
|
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
||||||
from animaltrack.projections.base import Projection, ProjectionRegistry
|
from animaltrack.projections.base import Projection, ProjectionRegistry
|
||||||
|
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||||
from animaltrack.projections.exceptions import ProjectionError
|
from animaltrack.projections.exceptions import ProjectionError
|
||||||
|
from animaltrack.projections.intervals import IntervalProjection
|
||||||
|
|
||||||
__all__ = ["Projection", "ProjectionError", "ProjectionRegistry"]
|
__all__ = [
|
||||||
|
"AnimalRegistryProjection",
|
||||||
|
"EventAnimalsProjection",
|
||||||
|
"IntervalProjection",
|
||||||
|
"Projection",
|
||||||
|
"ProjectionError",
|
||||||
|
"ProjectionRegistry",
|
||||||
|
]
|
||||||
|
|||||||
123
src/animaltrack/projections/animal_registry.py
Normal file
123
src/animaltrack/projections/animal_registry.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# ABOUTME: Projection for animal_registry and live_animals_by_location tables.
|
||||||
|
# ABOUTME: Handles AnimalCohortCreated and other animal lifecycle events.
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalRegistryProjection(Projection):
|
||||||
|
"""Maintains animal_registry and live_animals_by_location tables.
|
||||||
|
|
||||||
|
This projection handles events that create, update, or terminate animals.
|
||||||
|
It maintains both the full animal_registry (all animals) and the
|
||||||
|
live_animals_by_location denormalized view (only alive animals).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Any) -> None:
|
||||||
|
"""Initialize the projection with a database connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: A fastlite database connection.
|
||||||
|
"""
|
||||||
|
super().__init__(db)
|
||||||
|
|
||||||
|
def get_event_types(self) -> list[str]:
|
||||||
|
"""Return the event types this projection handles."""
|
||||||
|
return [ANIMAL_COHORT_CREATED]
|
||||||
|
|
||||||
|
def apply(self, event: Event) -> None:
|
||||||
|
"""Apply an event to update registry tables."""
|
||||||
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
|
self._apply_cohort_created(event)
|
||||||
|
|
||||||
|
def revert(self, event: Event) -> None:
|
||||||
|
"""Revert an event from registry tables."""
|
||||||
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
|
self._revert_cohort_created(event)
|
||||||
|
|
||||||
|
def _apply_cohort_created(self, event: Event) -> None:
|
||||||
|
"""Create animals in registry from cohort event.
|
||||||
|
|
||||||
|
For each animal_id in entity_refs:
|
||||||
|
- Insert into animal_registry with attributes from payload
|
||||||
|
- Insert into live_animals_by_location for roster queries
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
payload = event.payload
|
||||||
|
ts_utc = event.ts_utc
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Insert into animal_registry
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO animal_registry (
|
||||||
|
animal_id, species_code, identified, nickname,
|
||||||
|
sex, repro_status, life_stage, status,
|
||||||
|
location_id, origin, born_or_hatched_at, acquired_at,
|
||||||
|
first_seen_utc, last_event_utc
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
animal_id,
|
||||||
|
payload["species"],
|
||||||
|
0, # identified = false
|
||||||
|
None, # nickname = NULL
|
||||||
|
payload["sex"],
|
||||||
|
"unknown", # repro_status
|
||||||
|
payload["life_stage"],
|
||||||
|
"alive", # status
|
||||||
|
payload["location_id"],
|
||||||
|
payload["origin"],
|
||||||
|
None, # born_or_hatched_at (could set if origin == 'hatched')
|
||||||
|
None, # acquired_at (could set if origin == 'purchased')
|
||||||
|
ts_utc, # first_seen_utc
|
||||||
|
ts_utc, # last_event_utc
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert into live_animals_by_location
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO live_animals_by_location (
|
||||||
|
animal_id, location_id, species_code, identified, nickname,
|
||||||
|
sex, repro_status, life_stage, first_seen_utc, last_move_utc, tags
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
animal_id,
|
||||||
|
payload["location_id"],
|
||||||
|
payload["species"],
|
||||||
|
0, # identified = false
|
||||||
|
None, # nickname = NULL
|
||||||
|
payload["sex"],
|
||||||
|
"unknown", # repro_status
|
||||||
|
payload["life_stage"],
|
||||||
|
ts_utc, # first_seen_utc
|
||||||
|
None, # last_move_utc = NULL for new cohort
|
||||||
|
"[]", # tags = empty JSON array
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _revert_cohort_created(self, event: Event) -> None:
|
||||||
|
"""Remove animals created by cohort event.
|
||||||
|
|
||||||
|
Deletes rows from both tables for all animal_ids in the event.
|
||||||
|
Order matters: delete from live_animals first to avoid FK issues.
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Delete from live_animals_by_location first
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then delete from animal_registry
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
)
|
||||||
49
src/animaltrack/projections/event_animals.py
Normal file
49
src/animaltrack/projections/event_animals.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# ABOUTME: Projection for event_animals link table.
|
||||||
|
# ABOUTME: Links events to the animals they affect for efficient querying.
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
|
|
||||||
|
class EventAnimalsProjection(Projection):
|
||||||
|
"""Maintains event_animals link table.
|
||||||
|
|
||||||
|
This projection tracks which animals are affected by each event,
|
||||||
|
enabling efficient queries like "show all events for animal X"
|
||||||
|
or "show all animals affected by event Y".
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Any) -> None:
|
||||||
|
"""Initialize the projection with a database connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: A fastlite database connection.
|
||||||
|
"""
|
||||||
|
super().__init__(db)
|
||||||
|
|
||||||
|
def get_event_types(self) -> list[str]:
|
||||||
|
"""Return the event types this projection handles."""
|
||||||
|
return [ANIMAL_COHORT_CREATED]
|
||||||
|
|
||||||
|
def apply(self, event: Event) -> None:
|
||||||
|
"""Link event to affected animals."""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO event_animals (event_id, animal_id, ts_utc)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(event.id, animal_id, event.ts_utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def revert(self, event: Event) -> None:
|
||||||
|
"""Remove event-animal links."""
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM event_animals WHERE event_id = ?",
|
||||||
|
(event.id,),
|
||||||
|
)
|
||||||
103
src/animaltrack/projections/intervals.py
Normal file
103
src/animaltrack/projections/intervals.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# ABOUTME: Projection for time-series interval tables.
|
||||||
|
# ABOUTME: Tracks animal location and attribute history over time.
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
|
|
||||||
|
class IntervalProjection(Projection):
|
||||||
|
"""Maintains interval tables for historical queries.
|
||||||
|
|
||||||
|
This projection manages animal_location_intervals and animal_attr_intervals
|
||||||
|
tables, which track the history of where animals were and what their
|
||||||
|
attributes were at any point in time.
|
||||||
|
|
||||||
|
Intervals have a start_utc and optional end_utc. An open interval
|
||||||
|
(end_utc=NULL) means the value is current.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Any) -> None:
|
||||||
|
"""Initialize the projection with a database connection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: A fastlite database connection.
|
||||||
|
"""
|
||||||
|
super().__init__(db)
|
||||||
|
|
||||||
|
def get_event_types(self) -> list[str]:
|
||||||
|
"""Return the event types this projection handles."""
|
||||||
|
return [ANIMAL_COHORT_CREATED]
|
||||||
|
|
||||||
|
def apply(self, event: Event) -> None:
|
||||||
|
"""Create intervals for event."""
|
||||||
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
|
self._apply_cohort_created(event)
|
||||||
|
|
||||||
|
def revert(self, event: Event) -> None:
|
||||||
|
"""Remove intervals created by event."""
|
||||||
|
if event.type == ANIMAL_COHORT_CREATED:
|
||||||
|
self._revert_cohort_created(event)
|
||||||
|
|
||||||
|
def _apply_cohort_created(self, event: Event) -> None:
|
||||||
|
"""Create initial intervals for new animals.
|
||||||
|
|
||||||
|
For each animal in the cohort:
|
||||||
|
- Create an open location interval at the initial location
|
||||||
|
- Create open attribute intervals for sex, life_stage, repro_status, status
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
payload = event.payload
|
||||||
|
ts_utc = event.ts_utc
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Create location interval (open-ended)
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO animal_location_intervals
|
||||||
|
(animal_id, location_id, start_utc, end_utc)
|
||||||
|
VALUES (?, ?, ?, NULL)
|
||||||
|
""",
|
||||||
|
(animal_id, payload["location_id"], ts_utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create attribute intervals (sex, life_stage, repro_status, status)
|
||||||
|
attrs = [
|
||||||
|
("sex", payload["sex"]),
|
||||||
|
("life_stage", payload["life_stage"]),
|
||||||
|
("repro_status", "unknown"), # Default for new cohort
|
||||||
|
("status", "alive"), # Default for new cohort
|
||||||
|
]
|
||||||
|
|
||||||
|
for attr, value in attrs:
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO animal_attr_intervals
|
||||||
|
(animal_id, attr, value, start_utc, end_utc)
|
||||||
|
VALUES (?, ?, ?, ?, NULL)
|
||||||
|
""",
|
||||||
|
(animal_id, attr, value, ts_utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _revert_cohort_created(self, event: Event) -> None:
|
||||||
|
"""Remove intervals for animals from cohort event.
|
||||||
|
|
||||||
|
Deletes all location and attribute intervals for the animals
|
||||||
|
created by this event.
|
||||||
|
"""
|
||||||
|
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||||
|
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
# Delete location intervals
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM animal_location_intervals WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete attribute intervals
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM animal_attr_intervals WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
)
|
||||||
2
src/animaltrack/services/__init__.py
Normal file
2
src/animaltrack/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# ABOUTME: Service layer for AnimalTrack business logic.
|
||||||
|
# ABOUTME: Coordinates event creation with projection updates.
|
||||||
147
src/animaltrack/services/animal.py
Normal file
147
src/animaltrack/services/animal.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# ABOUTME: Service layer for animal operations.
|
||||||
|
# ABOUTME: Coordinates event creation with projection updates.
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from animaltrack.db import transaction
|
||||||
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
||||||
|
from animaltrack.events.processor import process_event
|
||||||
|
from animaltrack.events.store import EventStore
|
||||||
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||||
|
from animaltrack.id_gen import generate_id
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
from animaltrack.projections import ProjectionRegistry
|
||||||
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
|
from animaltrack.repositories.species import SpeciesRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalServiceError(Exception):
|
||||||
|
"""Base exception for animal service errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(AnimalServiceError):
|
||||||
|
"""Raised when validation fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalService:
|
||||||
|
"""Service for animal operations.
|
||||||
|
|
||||||
|
Coordinates event store operations with projection updates,
|
||||||
|
ensuring all operations happen atomically within a transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db: Any,
|
||||||
|
event_store: EventStore,
|
||||||
|
registry: ProjectionRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the service with dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: A fastlite database connection.
|
||||||
|
event_store: The event store for event creation.
|
||||||
|
registry: The projection registry for processing events.
|
||||||
|
"""
|
||||||
|
self.db = db
|
||||||
|
self.event_store = event_store
|
||||||
|
self.registry = registry
|
||||||
|
self.location_repo = LocationRepository(db)
|
||||||
|
self.species_repo = SpeciesRepository(db)
|
||||||
|
|
||||||
|
def create_cohort(
|
||||||
|
self,
|
||||||
|
payload: AnimalCohortCreatedPayload,
|
||||||
|
ts_utc: int,
|
||||||
|
actor: str,
|
||||||
|
nonce: str | None = None,
|
||||||
|
route: str | None = None,
|
||||||
|
) -> Event:
|
||||||
|
"""Create a cohort of animals.
|
||||||
|
|
||||||
|
Creates an AnimalCohortCreated event and processes it through
|
||||||
|
all registered projections. All operations happen atomically
|
||||||
|
within a transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Validated cohort creation payload.
|
||||||
|
ts_utc: Timestamp in milliseconds since epoch.
|
||||||
|
actor: The user creating the cohort.
|
||||||
|
nonce: Optional idempotency nonce.
|
||||||
|
route: Required if nonce provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created event.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If validation fails.
|
||||||
|
"""
|
||||||
|
# Validate location exists and is active
|
||||||
|
self._validate_location(payload.location_id)
|
||||||
|
|
||||||
|
# Validate species exists and is active
|
||||||
|
self._validate_species(payload.species)
|
||||||
|
|
||||||
|
# Generate animal IDs for the cohort
|
||||||
|
animal_ids = [generate_id() for _ in range(payload.count)]
|
||||||
|
|
||||||
|
# Build entity_refs with location and animal IDs
|
||||||
|
entity_refs = {
|
||||||
|
"location_id": payload.location_id,
|
||||||
|
"animal_ids": animal_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction(self.db):
|
||||||
|
# Append event to store
|
||||||
|
event = self.event_store.append_event(
|
||||||
|
event_type=ANIMAL_COHORT_CREATED,
|
||||||
|
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_location(self, location_id: str) -> None:
|
||||||
|
"""Validate that location exists and is active.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location_id: The location ID to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If location doesn't exist or is archived.
|
||||||
|
"""
|
||||||
|
location = self.location_repo.get(location_id)
|
||||||
|
if location is None:
|
||||||
|
msg = f"Location {location_id} not found"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
if not location.active:
|
||||||
|
msg = f"Location {location_id} is archived"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
def _validate_species(self, species_code: str) -> None:
|
||||||
|
"""Validate that species exists and is active.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
species_code: The species code to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If species doesn't exist or is not active.
|
||||||
|
"""
|
||||||
|
species = self.species_repo.get(species_code)
|
||||||
|
if species is None:
|
||||||
|
msg = f"Species {species_code} not found"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
if not species.active:
|
||||||
|
msg = f"Species {species_code} is not active"
|
||||||
|
raise ValidationError(msg)
|
||||||
@@ -26,6 +26,15 @@ def temp_migrations_dir(tmp_path):
|
|||||||
return migrations_path
|
return migrations_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_db(migrated_db):
|
||||||
|
"""Database with migrations and seed data applied."""
|
||||||
|
from animaltrack.seeds import run_seeds
|
||||||
|
|
||||||
|
run_seeds(migrated_db)
|
||||||
|
return migrated_db
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fresh_db_path(tmp_path):
|
def fresh_db_path(tmp_path):
|
||||||
"""Provide a path for a non-existent database file.
|
"""Provide a path for a non-existent database file.
|
||||||
|
|||||||
405
tests/test_projection_animal_registry.py
Normal file
405
tests/test_projection_animal_registry.py
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# ABOUTME: Tests for AnimalRegistryProjection.
|
||||||
|
# ABOUTME: Validates animal_registry and live_animals_by_location updates on cohort creation.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
||||||
|
|
||||||
|
|
||||||
|
def make_cohort_event(
|
||||||
|
animal_ids: list[str],
|
||||||
|
location_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
species: str = "duck",
|
||||||
|
sex: str = "unknown",
|
||||||
|
life_stage: str = "adult",
|
||||||
|
origin: str = "purchased",
|
||||||
|
ts_utc: int = 1704067200000,
|
||||||
|
) -> Event:
|
||||||
|
"""Create a test AnimalCohortCreated event."""
|
||||||
|
return Event(
|
||||||
|
id="01ARZ3NDEKTSV4RRFFQ69G5001",
|
||||||
|
type=ANIMAL_COHORT_CREATED,
|
||||||
|
ts_utc=ts_utc,
|
||||||
|
actor="test_user",
|
||||||
|
entity_refs={
|
||||||
|
"location_id": location_id,
|
||||||
|
"animal_ids": animal_ids,
|
||||||
|
},
|
||||||
|
payload={
|
||||||
|
"species": species,
|
||||||
|
"count": len(animal_ids),
|
||||||
|
"life_stage": life_stage,
|
||||||
|
"sex": sex,
|
||||||
|
"location_id": location_id,
|
||||||
|
"origin": origin,
|
||||||
|
"notes": None,
|
||||||
|
},
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalRegistryProjectionEventTypes:
|
||||||
|
"""Tests for get_event_types method."""
|
||||||
|
|
||||||
|
def test_handles_animal_cohort_created(self, seeded_db):
|
||||||
|
"""Projection handles AnimalCohortCreated event type."""
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
assert ANIMAL_COHORT_CREATED in projection.get_event_types()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalRegistryProjectionApply:
|
||||||
|
"""Tests for apply() on AnimalCohortCreated."""
|
||||||
|
|
||||||
|
def test_creates_animal_registry_row_for_each_animal(self, seeded_db):
|
||||||
|
"""Apply creates one row in animal_registry per animal_id."""
|
||||||
|
# Get a valid location_id from seeds
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = [
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A01",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A02",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A03",
|
||||||
|
]
|
||||||
|
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
# Check animal_registry has 3 rows
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
# Check each animal_id exists
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT animal_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
|
||||||
|
def test_animal_has_correct_species(self, seeded_db):
|
||||||
|
"""Registry row has correct species_code from payload."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id, species="goose")
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT species_code FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "goose"
|
||||||
|
|
||||||
|
def test_animal_has_correct_sex(self, seeded_db):
|
||||||
|
"""Registry row has correct sex from payload."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id, sex="female")
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT sex FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "female"
|
||||||
|
|
||||||
|
def test_animal_has_correct_life_stage(self, seeded_db):
|
||||||
|
"""Registry row has correct life_stage from payload."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id, life_stage="juvenile")
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT life_stage FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "juvenile"
|
||||||
|
|
||||||
|
def test_animal_has_correct_location(self, seeded_db):
|
||||||
|
"""Registry row has correct location_id from payload."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == location_id
|
||||||
|
|
||||||
|
def test_animal_has_correct_origin(self, seeded_db):
|
||||||
|
"""Registry row has correct origin from payload."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id, origin="rescued")
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT origin FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "rescued"
|
||||||
|
|
||||||
|
def test_status_is_alive(self, seeded_db):
|
||||||
|
"""Registry row has status='alive' for new cohort."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "alive"
|
||||||
|
|
||||||
|
def test_identified_is_false(self, seeded_db):
|
||||||
|
"""Registry row has identified=false for new cohort."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT identified FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == 0 # SQLite stores booleans as 0/1
|
||||||
|
|
||||||
|
def test_repro_status_is_unknown(self, seeded_db):
|
||||||
|
"""Registry row has repro_status='unknown' for new cohort."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT repro_status FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "unknown"
|
||||||
|
|
||||||
|
def test_first_seen_utc_matches_event(self, seeded_db):
|
||||||
|
"""Registry row first_seen_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"]
|
||||||
|
ts_utc = 1704067200000
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id, ts_utc=ts_utc)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT first_seen_utc FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == ts_utc
|
||||||
|
|
||||||
|
def test_last_event_utc_matches_event(self, seeded_db):
|
||||||
|
"""Registry row last_event_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"]
|
||||||
|
ts_utc = 1704067200000
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id, ts_utc=ts_utc)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT last_event_utc FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == ts_utc
|
||||||
|
|
||||||
|
def test_creates_live_animal_row_for_each_animal(self, seeded_db):
|
||||||
|
"""Apply creates one row in live_animals_by_location per animal_id."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = [
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A01",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A02",
|
||||||
|
]
|
||||||
|
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
# Check live_animals_by_location has 2 rows
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM live_animals_by_location").fetchone()[0]
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Check each animal_id exists
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT animal_id FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
|
||||||
|
def test_live_animal_tags_empty_json_array(self, seeded_db):
|
||||||
|
"""Live animal row has tags='[]' for new cohort."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT tags FROM live_animals_by_location WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "[]"
|
||||||
|
assert json.loads(row[0]) == []
|
||||||
|
|
||||||
|
def test_live_animal_last_move_utc_is_null(self, seeded_db):
|
||||||
|
"""Live animal row has last_move_utc=NULL for new cohort."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(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
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalRegistryProjectionRevert:
|
||||||
|
"""Tests for revert() on AnimalCohortCreated."""
|
||||||
|
|
||||||
|
def test_removes_animal_registry_rows(self, seeded_db):
|
||||||
|
"""Revert deletes rows from animal_registry."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = [
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A01",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A02",
|
||||||
|
]
|
||||||
|
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
# Verify rows exist
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Revert
|
||||||
|
projection.revert(event)
|
||||||
|
|
||||||
|
# Verify rows removed
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
def test_removes_live_animal_rows(self, seeded_db):
|
||||||
|
"""Revert deletes rows from live_animals_by_location."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = [
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A01",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A02",
|
||||||
|
]
|
||||||
|
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event = make_cohort_event(animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
# Verify rows exist
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM live_animals_by_location").fetchone()[0]
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Revert
|
||||||
|
projection.revert(event)
|
||||||
|
|
||||||
|
# Verify rows removed
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM live_animals_by_location").fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
def test_revert_only_affects_event_animals(self, seeded_db):
|
||||||
|
"""Revert only removes animals from the specific event."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
# Create first cohort
|
||||||
|
animal_ids_1 = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
projection = AnimalRegistryProjection(seeded_db)
|
||||||
|
event1 = make_cohort_event(animal_ids_1, location_id=location_id)
|
||||||
|
projection.apply(event1)
|
||||||
|
|
||||||
|
# Create second cohort with different event
|
||||||
|
animal_ids_2 = ["01ARZ3NDEKTSV4RRFFQ69G5A02"]
|
||||||
|
event2 = Event(
|
||||||
|
id="01ARZ3NDEKTSV4RRFFQ69G5002", # Different event ID
|
||||||
|
type=ANIMAL_COHORT_CREATED,
|
||||||
|
ts_utc=1704067300000,
|
||||||
|
actor="test_user",
|
||||||
|
entity_refs={
|
||||||
|
"location_id": location_id,
|
||||||
|
"animal_ids": animal_ids_2,
|
||||||
|
},
|
||||||
|
payload={
|
||||||
|
"species": "duck",
|
||||||
|
"count": 1,
|
||||||
|
"life_stage": "adult",
|
||||||
|
"sex": "unknown",
|
||||||
|
"location_id": location_id,
|
||||||
|
"origin": "purchased",
|
||||||
|
"notes": None,
|
||||||
|
},
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
projection.apply(event2)
|
||||||
|
|
||||||
|
# Verify both exist
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Revert only event1
|
||||||
|
projection.revert(event1)
|
||||||
|
|
||||||
|
# Event2's animal should still exist
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
row = seeded_db.execute("SELECT animal_id FROM animal_registry").fetchone()
|
||||||
|
assert row[0] == animal_ids_2[0]
|
||||||
178
tests/test_projection_event_animals.py
Normal file
178
tests/test_projection_event_animals.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# ABOUTME: Tests for EventAnimalsProjection.
|
||||||
|
# ABOUTME: Validates event_animals link table updates on animal events.
|
||||||
|
|
||||||
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||||
|
|
||||||
|
|
||||||
|
def make_cohort_event(
|
||||||
|
event_id: str,
|
||||||
|
animal_ids: list[str],
|
||||||
|
location_id: str = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
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": "adult",
|
||||||
|
"sex": "unknown",
|
||||||
|
"location_id": location_id,
|
||||||
|
"origin": "purchased",
|
||||||
|
"notes": None,
|
||||||
|
},
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventAnimalsProjectionEventTypes:
|
||||||
|
"""Tests for get_event_types method."""
|
||||||
|
|
||||||
|
def test_handles_animal_cohort_created(self, seeded_db):
|
||||||
|
"""Projection handles AnimalCohortCreated event type."""
|
||||||
|
projection = EventAnimalsProjection(seeded_db)
|
||||||
|
assert ANIMAL_COHORT_CREATED in projection.get_event_types()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventAnimalsProjectionApply:
|
||||||
|
"""Tests for apply()."""
|
||||||
|
|
||||||
|
def test_creates_event_animal_link_for_each_animal(self, seeded_db):
|
||||||
|
"""Apply creates one row in event_animals per animal_id."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = [
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A01",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A02",
|
||||||
|
"01ARZ3NDEKTSV4RRFFQ69G5A03",
|
||||||
|
]
|
||||||
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
||||||
|
|
||||||
|
projection = EventAnimalsProjection(seeded_db)
|
||||||
|
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
# Check event_animals has 3 rows
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_animals").fetchone()[0]
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
# Check each animal_id is linked
|
||||||
|
for animal_id in animal_ids:
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT event_id FROM event_animals WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == event_id
|
||||||
|
|
||||||
|
def test_event_animal_link_has_correct_event_id(self, seeded_db):
|
||||||
|
"""Event animal link has correct event_id."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
location_id = row[0]
|
||||||
|
|
||||||
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
||||||
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
||||||
|
|
||||||
|
projection = EventAnimalsProjection(seeded_db)
|
||||||
|
event = make_cohort_event(event_id, animal_ids, location_id=location_id)
|
||||||
|
projection.apply(event)
|
||||||
|
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT event_id FROM event_animals WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == event_id
|
||||||
|
|
||||||
|
def test_event_animal_link_has_correct_ts_utc(self, seeded_db):
|
||||||
|
"""Event animal link has correct 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 = EventAnimalsProjection(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 ts_utc FROM event_animals WHERE animal_id = ?",
|
||||||
|
(animal_ids[0],),
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == ts_utc
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventAnimalsProjectionRevert:
|
||||||
|
"""Tests for revert()."""
|
||||||
|
|
||||||
|
def test_removes_event_animal_links(self, seeded_db):
|
||||||
|
"""Revert deletes rows from 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 = EventAnimalsProjection(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 event_animals").fetchone()[0]
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Revert
|
||||||
|
projection.revert(event)
|
||||||
|
|
||||||
|
# Verify rows removed
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_animals").fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
def test_revert_only_affects_specific_event(self, seeded_db):
|
||||||
|
"""Revert only removes links for the 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 = EventAnimalsProjection(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
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_animals").fetchone()[0]
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
# Revert only event1
|
||||||
|
projection.revert(event1)
|
||||||
|
|
||||||
|
# Event2's link should still exist
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_animals").fetchone()[0]
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
row = seeded_db.execute("SELECT event_id FROM event_animals").fetchone()
|
||||||
|
assert row[0] == event_id_2
|
||||||
338
tests/test_projection_intervals.py
Normal file
338
tests/test_projection_intervals.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# 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]
|
||||||
272
tests/test_service_animal.py
Normal file
272
tests/test_service_animal.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# ABOUTME: Tests for AnimalService.
|
||||||
|
# ABOUTME: Integration tests for cohort creation with full transaction.
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
||||||
|
from animaltrack.events.store import EventStore
|
||||||
|
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||||
|
from animaltrack.projections import ProjectionRegistry
|
||||||
|
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
||||||
|
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||||
|
from animaltrack.projections.intervals import IntervalProjection
|
||||||
|
from animaltrack.services.animal import AnimalService, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_store(seeded_db):
|
||||||
|
"""Create an EventStore for testing."""
|
||||||
|
return EventStore(seeded_db)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def projection_registry(seeded_db):
|
||||||
|
"""Create a ProjectionRegistry with all cohort projections registered."""
|
||||||
|
registry = ProjectionRegistry()
|
||||||
|
registry.register(AnimalRegistryProjection(seeded_db))
|
||||||
|
registry.register(EventAnimalsProjection(seeded_db))
|
||||||
|
registry.register(IntervalProjection(seeded_db))
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def animal_service(seeded_db, event_store, projection_registry):
|
||||||
|
"""Create an AnimalService for testing."""
|
||||||
|
return AnimalService(seeded_db, event_store, projection_registry)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_location_id(seeded_db):
|
||||||
|
"""Get a valid location ID from seeds."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def make_payload(
|
||||||
|
location_id: str,
|
||||||
|
count: int = 5,
|
||||||
|
species: str = "duck",
|
||||||
|
life_stage: str = "adult",
|
||||||
|
sex: str = "unknown",
|
||||||
|
origin: str = "purchased",
|
||||||
|
) -> AnimalCohortCreatedPayload:
|
||||||
|
"""Create a cohort payload for testing."""
|
||||||
|
return AnimalCohortCreatedPayload(
|
||||||
|
species=species,
|
||||||
|
count=count,
|
||||||
|
life_stage=life_stage,
|
||||||
|
sex=sex,
|
||||||
|
location_id=location_id,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalServiceCreateCohort:
|
||||||
|
"""Tests for create_cohort()."""
|
||||||
|
|
||||||
|
def test_creates_event(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""create_cohort creates an AnimalCohortCreated event."""
|
||||||
|
payload = make_payload(valid_location_id, count=3)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert event.type == ANIMAL_COHORT_CREATED
|
||||||
|
assert event.actor == "test_user"
|
||||||
|
assert event.ts_utc == ts_utc
|
||||||
|
|
||||||
|
def test_event_has_animal_ids_in_entity_refs(
|
||||||
|
self, seeded_db, animal_service, valid_location_id
|
||||||
|
):
|
||||||
|
"""Event entity_refs contains generated animal_ids list."""
|
||||||
|
payload = make_payload(valid_location_id, count=5)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert "animal_ids" in event.entity_refs
|
||||||
|
assert len(event.entity_refs["animal_ids"]) == 5
|
||||||
|
# Verify all IDs are ULIDs (26 chars)
|
||||||
|
for animal_id in event.entity_refs["animal_ids"]:
|
||||||
|
assert len(animal_id) == 26
|
||||||
|
|
||||||
|
def test_event_has_location_in_entity_refs(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Event entity_refs contains location_id."""
|
||||||
|
payload = make_payload(valid_location_id, count=1)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
assert "location_id" in event.entity_refs
|
||||||
|
assert event.entity_refs["location_id"] == valid_location_id
|
||||||
|
|
||||||
|
def test_animals_created_in_registry(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Animals are created in animal_registry table."""
|
||||||
|
payload = make_payload(valid_location_id, count=3)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
# Check animals exist in registry
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
# Check each generated animal_id is in the registry
|
||||||
|
for animal_id in event.entity_refs["animal_ids"]:
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT animal_id FROM animal_registry WHERE animal_id = ?",
|
||||||
|
(animal_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
|
||||||
|
def test_correct_number_of_animals_created(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Number of animals matches payload.count."""
|
||||||
|
payload = make_payload(valid_location_id, count=7)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert count == 7
|
||||||
|
|
||||||
|
def test_event_animal_links_created(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Event-animal links are created."""
|
||||||
|
payload = make_payload(valid_location_id, count=4)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
# Check event_animals has 4 rows
|
||||||
|
count = seeded_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_animals WHERE event_id = ?",
|
||||||
|
(event.id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == 4
|
||||||
|
|
||||||
|
def test_location_intervals_created(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Location intervals are created for each animal."""
|
||||||
|
payload = make_payload(valid_location_id, count=3)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_location_intervals").fetchone()[0]
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
def test_attr_intervals_created(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Attribute intervals are created for each animal."""
|
||||||
|
payload = make_payload(valid_location_id, count=2)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
# 2 animals * 4 attrs = 8 intervals
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0]
|
||||||
|
assert count == 8
|
||||||
|
|
||||||
|
def test_live_animals_created(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Live animals are created in live_animals_by_location."""
|
||||||
|
payload = make_payload(valid_location_id, count=5)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
count = seeded_db.execute("SELECT COUNT(*) FROM live_animals_by_location").fetchone()[0]
|
||||||
|
assert count == 5
|
||||||
|
|
||||||
|
def test_event_stored_in_events_table(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Event is stored in events table."""
|
||||||
|
payload = make_payload(valid_location_id, count=1)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
# Verify event exists in database
|
||||||
|
row = seeded_db.execute(
|
||||||
|
"SELECT id FROM events WHERE id = ?",
|
||||||
|
(event.id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalServiceValidation:
|
||||||
|
"""Tests for create_cohort() validation."""
|
||||||
|
|
||||||
|
def test_rejects_nonexistent_location(self, seeded_db, animal_service):
|
||||||
|
"""Raises ValidationError for non-existent location_id."""
|
||||||
|
# Use a valid ULID format but non-existent location
|
||||||
|
fake_location_id = "01ARZ3NDEKTSV4RRFFQ69G5XXX"
|
||||||
|
payload = make_payload(fake_location_id, count=1)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="not found"):
|
||||||
|
animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
def test_rejects_archived_location(self, seeded_db, animal_service):
|
||||||
|
"""Raises ValidationError for archived location."""
|
||||||
|
# First, create and archive a location
|
||||||
|
from animaltrack.id_gen import generate_id
|
||||||
|
|
||||||
|
location_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, ?, ?)""",
|
||||||
|
(location_id, ts, ts),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = make_payload(location_id, count=1)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="archived"):
|
||||||
|
animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
def test_rejects_inactive_species(self, seeded_db, animal_service, valid_location_id):
|
||||||
|
"""Raises ValidationError for inactive species."""
|
||||||
|
# First, deactivate duck species
|
||||||
|
seeded_db.execute("UPDATE species SET active = 0 WHERE code = 'duck'")
|
||||||
|
|
||||||
|
payload = make_payload(valid_location_id, count=1, species="duck")
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="not active"):
|
||||||
|
animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalServiceTransactionIntegrity:
|
||||||
|
"""Tests for transaction integrity."""
|
||||||
|
|
||||||
|
def test_no_partial_data_on_projection_error(self, seeded_db, event_store, valid_location_id):
|
||||||
|
"""If projection fails, event is not persisted."""
|
||||||
|
# Create a registry with a failing projection
|
||||||
|
from animaltrack.projections import Projection, ProjectionError
|
||||||
|
|
||||||
|
class FailingProjection(Projection):
|
||||||
|
def get_event_types(self):
|
||||||
|
return [ANIMAL_COHORT_CREATED]
|
||||||
|
|
||||||
|
def apply(self, event):
|
||||||
|
raise ProjectionError("Intentional failure")
|
||||||
|
|
||||||
|
def revert(self, event):
|
||||||
|
pass
|
||||||
|
|
||||||
|
registry = ProjectionRegistry()
|
||||||
|
registry.register(AnimalRegistryProjection(seeded_db))
|
||||||
|
registry.register(FailingProjection(seeded_db))
|
||||||
|
|
||||||
|
service = AnimalService(seeded_db, event_store, registry)
|
||||||
|
payload = make_payload(valid_location_id, count=2)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
with pytest.raises(ProjectionError):
|
||||||
|
service.create_cohort(payload, ts_utc, "test_user")
|
||||||
|
|
||||||
|
# Verify nothing was persisted
|
||||||
|
event_count = seeded_db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||||
|
assert event_count == 0
|
||||||
|
|
||||||
|
animal_count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||||
|
assert animal_count == 0
|
||||||
Reference in New Issue
Block a user