feat: add event deletion with tombstone creation and cascade rules
Implement event deletion per spec §10 with role-based rules: - Recorder can delete own events if no dependents exist - Admin can cascade delete (tombstone target + all dependents) - All deletions create immutable tombstone records New modules: - events/dependencies.py: find events depending on a target via shared animals - events/delete.py: delete_event() with projection reversal DependentEventsError exception added for 409 Conflict responses. E2E test #6 implemented per spec §21.6. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
369
tests/test_e2e_deletion.py
Normal file
369
tests/test_e2e_deletion.py
Normal file
@@ -0,0 +1,369 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user