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

@@ -26,6 +26,15 @@ def temp_migrations_dir(tmp_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
def fresh_db_path(tmp_path):
"""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