# ABOUTME: E2E test #6 from spec section 21.6: Deletes - recorder vs admin cascade. # ABOUTME: Tests FeedGiven deletion by recorder and cascade deletion by admin. import time import pytest from animaltrack.events.store import EventStore @pytest.fixture def now_utc(): """Current time in milliseconds since epoch.""" return int(time.time() * 1000) @pytest.fixture def full_projection_registry(seeded_db): """Create a ProjectionRegistry with all projections.""" from animaltrack.projections import ProjectionRegistry from animaltrack.projections.animal_registry import AnimalRegistryProjection from animaltrack.projections.event_animals import EventAnimalsProjection from animaltrack.projections.feed import FeedInventoryProjection from animaltrack.projections.intervals import IntervalProjection from animaltrack.projections.products import ProductsProjection registry = ProjectionRegistry() registry.register(AnimalRegistryProjection(seeded_db)) registry.register(IntervalProjection(seeded_db)) registry.register(EventAnimalsProjection(seeded_db)) registry.register(ProductsProjection(seeded_db)) registry.register(FeedInventoryProjection(seeded_db)) return registry @pytest.fixture def services(seeded_db, full_projection_registry): """Create all services needed for E2E test.""" from animaltrack.services.animal import AnimalService from animaltrack.services.feed import FeedService from animaltrack.services.products import ProductService event_store = EventStore(seeded_db) return { "db": seeded_db, "event_store": event_store, "registry": full_projection_registry, "animal_service": AnimalService(seeded_db, event_store, full_projection_registry), "feed_service": FeedService(seeded_db, event_store, full_projection_registry), "product_service": ProductService(seeded_db, event_store, full_projection_registry), } @pytest.fixture def baseline_setup(seeded_db, services, now_utc): """Set up baseline scenario matching spec E2E tests 1-5. Creates: - 10 adult female ducks at Strip 1 - Feed: 40kg purchased @ EUR 1.20/kg - Feed given: 20kg to Strip 1, then 3kg more - Eggs: 27 + 6 = 33 eggs collected (after edit from 8 to 6) Returns dict with location IDs and event references. """ from animaltrack.events.payloads import ( AnimalCohortCreatedPayload, FeedGivenPayload, FeedPurchasedPayload, ProductCollectedPayload, ) # Get location IDs strip1_id = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()[0] nursery4_id = seeded_db.execute("SELECT id FROM locations WHERE name = 'Nursery 4'").fetchone()[ 0 ] one_day_ms = 24 * 60 * 60 * 1000 animal_creation_ts = now_utc - one_day_ms # Create 10 adult female ducks at Strip 1 cohort_payload = AnimalCohortCreatedPayload( species="duck", count=10, life_stage="adult", sex="female", location_id=strip1_id, origin="purchased", ) cohort_event = services["animal_service"].create_cohort( cohort_payload, animal_creation_ts, "test_user" ) animal_ids = cohort_event.entity_refs["animal_ids"] # Purchase feed: 40kg @ EUR 1.20/kg (2 bags of 20kg @ EUR 24 each) purchase_payload = FeedPurchasedPayload( feed_type_code="layer", bag_size_kg=20, bags_count=2, bag_price_cents=2400, # EUR 24 per 20kg = EUR 1.20/kg ) services["feed_service"].purchase_feed(purchase_payload, now_utc + 1000, "test_user") # Give first batch: 20kg to Strip 1 give1_payload = FeedGivenPayload( location_id=strip1_id, feed_type_code="layer", amount_kg=20, ) services["feed_service"].give_feed(give1_payload, now_utc + 2000, "test_user") # Give second batch: 3kg to Strip 1 (this is the event we'll test deleting) give2_payload = FeedGivenPayload( location_id=strip1_id, feed_type_code="layer", amount_kg=3, ) services["feed_service"].give_feed(give2_payload, now_utc + 2500, "test_user") # Give third batch: 4kg to Strip 1 (this is the event recorder will delete) give3_payload = FeedGivenPayload( location_id=strip1_id, feed_type_code="layer", amount_kg=4, ) give4kg_event = services["feed_service"].give_feed(give3_payload, now_utc + 2600, "test_user") # Collect eggs: 27 first, then 6 (simulating edit from 8 to 6) collect1_payload = ProductCollectedPayload( location_id=strip1_id, product_code="egg.duck", quantity=27, resolved_ids=animal_ids, ) services["product_service"].collect_product(collect1_payload, now_utc + 3000, "test_user") collect2_payload = ProductCollectedPayload( location_id=strip1_id, product_code="egg.duck", quantity=6, resolved_ids=animal_ids, ) services["product_service"].collect_product(collect2_payload, now_utc + 3500, "test_user") return { "strip1_id": strip1_id, "nursery4_id": nursery4_id, "animal_ids": animal_ids, "give4kg_event_id": give4kg_event.id, "ts_utc": now_utc + 4000, # After all baseline events } class TestE2EDeletion: """E2E Test #6: Deletes - recorder vs admin cascade. From spec section 21.6: 1. Recorder deletes 4 kg FeedGiven from @Strip 1 Expect: FeedInventory: given_kg=19; balance_kg=21 2. Create cohort 1 juvenile @Nursery 4; move to Strip 1 Recorder tries delete cohort → 409 (dependents) Admin deletes cohort with cascade → both tombstoned; animal removed """ def test_recorder_deletes_feed_given(self, seeded_db, services, baseline_setup, now_utc): """Part 1: Recorder successfully deletes FeedGiven event with no dependents. After deleting the 4kg FeedGiven: - given_kg decreases from 27 to 23 - balance_kg increases from 13 to 17 """ from animaltrack.events.delete import delete_event # Verify initial state: 27kg given (20 + 3 + 4), 13 balance row = seeded_db.execute( "SELECT purchased_kg, given_kg, balance_kg FROM feed_inventory " "WHERE feed_type_code = 'layer'" ).fetchone() assert row[0] == 40 # purchased_kg assert row[1] == 27 # given_kg (20 + 3 + 4) assert row[2] == 13 # balance_kg (40 - 27) # Recorder deletes the 4kg FeedGiven event deleted_ids = delete_event( db=seeded_db, event_store=services["event_store"], event_id=baseline_setup["give4kg_event_id"], actor="test_user", role="recorder", registry=services["registry"], ) assert len(deleted_ids) == 1 assert deleted_ids[0] == baseline_setup["give4kg_event_id"] # Verify updated state: 23kg given (20 + 3), 17 balance row = seeded_db.execute( "SELECT purchased_kg, given_kg, balance_kg FROM feed_inventory " "WHERE feed_type_code = 'layer'" ).fetchone() assert row[0] == 40 # purchased_kg unchanged assert row[1] == 23 # given_kg reduced by 4 assert row[2] == 17 # balance_kg increased by 4 def test_recorder_blocked_from_deleting_cohort_with_dependents( self, seeded_db, services, baseline_setup, now_utc ): """Part 2a: Recorder cannot delete cohort that has dependent move event.""" from animaltrack.events.delete import delete_event from animaltrack.events.exceptions import DependentEventsError from animaltrack.events.payloads import AnimalCohortCreatedPayload, AnimalMovedPayload # Create 1 juvenile at Nursery 4 cohort_payload = AnimalCohortCreatedPayload( species="duck", count=1, life_stage="juvenile", sex="male", location_id=baseline_setup["nursery4_id"], origin="hatched", ) cohort_event = services["animal_service"].create_cohort( cohort_payload, baseline_setup["ts_utc"] + 1000, "test_user" ) new_animal_ids = cohort_event.entity_refs["animal_ids"] # Move the juvenile from Nursery 4 to Strip 1 move_payload = AnimalMovedPayload( to_location_id=baseline_setup["strip1_id"], resolved_ids=new_animal_ids, ) move_event = services["animal_service"].move_animals( move_payload, baseline_setup["ts_utc"] + 2000, "test_user" ) # Verify animal exists and is at Strip 1 animal_count = seeded_db.execute( "SELECT COUNT(*) FROM animal_registry WHERE animal_id = ? AND location_id = ?", (new_animal_ids[0], baseline_setup["strip1_id"]), ).fetchone()[0] assert animal_count == 1 # Recorder tries to delete the cohort - should be blocked with pytest.raises(DependentEventsError) as exc_info: delete_event( db=seeded_db, event_store=services["event_store"], event_id=cohort_event.id, actor="test_user", role="recorder", registry=services["registry"], ) # Verify the error contains the move event as a dependent assert len(exc_info.value.dependent_events) == 1 assert exc_info.value.dependent_events[0].id == move_event.id # Verify cohort is NOT tombstoned assert not services["event_store"].is_tombstoned(cohort_event.id) def test_admin_cascade_deletes_cohort_and_move( self, seeded_db, services, baseline_setup, now_utc ): """Part 2b: Admin can cascade delete cohort + dependent move.""" from animaltrack.events.delete import delete_event from animaltrack.events.payloads import AnimalCohortCreatedPayload, AnimalMovedPayload # Create 1 juvenile at Nursery 4 cohort_payload = AnimalCohortCreatedPayload( species="duck", count=1, life_stage="juvenile", sex="male", location_id=baseline_setup["nursery4_id"], origin="hatched", ) cohort_event = services["animal_service"].create_cohort( cohort_payload, baseline_setup["ts_utc"] + 1000, "test_user" ) new_animal_ids = cohort_event.entity_refs["animal_ids"] new_animal_id = new_animal_ids[0] # Move the juvenile from Nursery 4 to Strip 1 move_payload = AnimalMovedPayload( to_location_id=baseline_setup["strip1_id"], resolved_ids=new_animal_ids, ) move_event = services["animal_service"].move_animals( move_payload, baseline_setup["ts_utc"] + 2000, "test_user" ) # Verify animal exists before deletion animal_in_registry = seeded_db.execute( "SELECT COUNT(*) FROM animal_registry WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert animal_in_registry == 1 animal_in_roster = seeded_db.execute( "SELECT COUNT(*) FROM live_animals_by_location WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert animal_in_roster == 1 location_intervals = seeded_db.execute( "SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert location_intervals == 2 # Initial at Nursery 4, then moved to Strip 1 attr_intervals = seeded_db.execute( "SELECT COUNT(*) FROM animal_attr_intervals WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert attr_intervals == 4 # sex, life_stage, repro_status, status # Admin cascade deletes the cohort deleted_ids = delete_event( db=seeded_db, event_store=services["event_store"], event_id=cohort_event.id, actor="admin", role="admin", cascade=True, registry=services["registry"], ) # Both events should be tombstoned assert len(deleted_ids) == 2 assert cohort_event.id in deleted_ids assert move_event.id in deleted_ids assert services["event_store"].is_tombstoned(cohort_event.id) assert services["event_store"].is_tombstoned(move_event.id) # Animal should be removed from registry animal_in_registry = seeded_db.execute( "SELECT COUNT(*) FROM animal_registry WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert animal_in_registry == 0 # Animal should be removed from roster animal_in_roster = seeded_db.execute( "SELECT COUNT(*) FROM live_animals_by_location WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert animal_in_roster == 0 # All intervals should be removed location_intervals = seeded_db.execute( "SELECT COUNT(*) FROM animal_location_intervals WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert location_intervals == 0 attr_intervals = seeded_db.execute( "SELECT COUNT(*) FROM animal_attr_intervals WHERE animal_id = ?", (new_animal_id,), ).fetchone()[0] assert attr_intervals == 0 # Original baseline animals should still exist original_animal_count = seeded_db.execute( "SELECT COUNT(*) FROM animal_registry WHERE animal_id IN " f"({','.join('?' for _ in baseline_setup['animal_ids'])})", baseline_setup["animal_ids"], ).fetchone()[0] assert original_animal_count == 10