From 876e8174eea88603d52d4cad43249281f6cfd648 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Mon, 29 Dec 2025 06:52:23 +0000 Subject: [PATCH] feat: add animal cohort creation projection and service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/animaltrack/projections/__init__.py | 12 +- .../projections/animal_registry.py | 123 ++++++ src/animaltrack/projections/event_animals.py | 49 +++ src/animaltrack/projections/intervals.py | 103 +++++ src/animaltrack/services/__init__.py | 2 + src/animaltrack/services/animal.py | 147 +++++++ tests/conftest.py | 9 + tests/test_projection_animal_registry.py | 405 ++++++++++++++++++ tests/test_projection_event_animals.py | 178 ++++++++ tests/test_projection_intervals.py | 338 +++++++++++++++ tests/test_service_animal.py | 272 ++++++++++++ 11 files changed, 1637 insertions(+), 1 deletion(-) create mode 100644 src/animaltrack/projections/animal_registry.py create mode 100644 src/animaltrack/projections/event_animals.py create mode 100644 src/animaltrack/projections/intervals.py create mode 100644 src/animaltrack/services/__init__.py create mode 100644 src/animaltrack/services/animal.py create mode 100644 tests/test_projection_animal_registry.py create mode 100644 tests/test_projection_event_animals.py create mode 100644 tests/test_projection_intervals.py create mode 100644 tests/test_service_animal.py diff --git a/src/animaltrack/projections/__init__.py b/src/animaltrack/projections/__init__.py index fe26fe3..7c224d5 100644 --- a/src/animaltrack/projections/__init__.py +++ b/src/animaltrack/projections/__init__.py @@ -1,7 +1,17 @@ # ABOUTME: Projection system for maintaining read models from events. # 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.event_animals import EventAnimalsProjection from animaltrack.projections.exceptions import ProjectionError +from animaltrack.projections.intervals import IntervalProjection -__all__ = ["Projection", "ProjectionError", "ProjectionRegistry"] +__all__ = [ + "AnimalRegistryProjection", + "EventAnimalsProjection", + "IntervalProjection", + "Projection", + "ProjectionError", + "ProjectionRegistry", +] diff --git a/src/animaltrack/projections/animal_registry.py b/src/animaltrack/projections/animal_registry.py new file mode 100644 index 0000000..7f3d12b --- /dev/null +++ b/src/animaltrack/projections/animal_registry.py @@ -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,), + ) diff --git a/src/animaltrack/projections/event_animals.py b/src/animaltrack/projections/event_animals.py new file mode 100644 index 0000000..34f6d58 --- /dev/null +++ b/src/animaltrack/projections/event_animals.py @@ -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,), + ) diff --git a/src/animaltrack/projections/intervals.py b/src/animaltrack/projections/intervals.py new file mode 100644 index 0000000..9771067 --- /dev/null +++ b/src/animaltrack/projections/intervals.py @@ -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,), + ) diff --git a/src/animaltrack/services/__init__.py b/src/animaltrack/services/__init__.py new file mode 100644 index 0000000..1ba91d4 --- /dev/null +++ b/src/animaltrack/services/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Service layer for AnimalTrack business logic. +# ABOUTME: Coordinates event creation with projection updates. diff --git a/src/animaltrack/services/animal.py b/src/animaltrack/services/animal.py new file mode 100644 index 0000000..95e799d --- /dev/null +++ b/src/animaltrack/services/animal.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 81ccbfa..c1cad3a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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. diff --git a/tests/test_projection_animal_registry.py b/tests/test_projection_animal_registry.py new file mode 100644 index 0000000..f902477 --- /dev/null +++ b/tests/test_projection_animal_registry.py @@ -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] diff --git a/tests/test_projection_event_animals.py b/tests/test_projection_event_animals.py new file mode 100644 index 0000000..302a3a2 --- /dev/null +++ b/tests/test_projection_event_animals.py @@ -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 diff --git a/tests/test_projection_intervals.py b/tests/test_projection_intervals.py new file mode 100644 index 0000000..33574e0 --- /dev/null +++ b/tests/test_projection_intervals.py @@ -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] diff --git a/tests/test_service_animal.py b/tests/test_service_animal.py new file mode 100644 index 0000000..82cc592 --- /dev/null +++ b/tests/test_service_animal.py @@ -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