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:
2025-12-29 06:52:23 +00:00
parent bd09c99366
commit 876e8174ee
11 changed files with 1637 additions and 1 deletions

View File

@@ -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",
]

View 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,),
)

View 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,),
)

View 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,),
)

View File

@@ -0,0 +1,2 @@
# ABOUTME: Service layer for AnimalTrack business logic.
# ABOUTME: Coordinates event creation with projection updates.

View 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)

View File

@@ -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.

View 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]

View 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

View 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]

View 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