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:
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