feat: add rebuild-projections CLI and fix event delete projection revert
- Add 'rebuild-projections' CLI command that truncates all projection tables and replays non-tombstoned events to rebuild state - Fix event delete route to register all projections before calling delete_event, ensuring projections are properly reverted - Add comprehensive tests for both rebuild CLI and delete with projections The rebuild-projections command is useful for recovering from corrupted projection state, while the delete fix ensures future deletes properly revert animal status (e.g., sold -> alive). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
279
tests/test_web_events_delete.py
Normal file
279
tests/test_web_events_delete.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# ABOUTME: Tests for event delete with projection verification.
|
||||
# ABOUTME: Verifies that deleting events properly reverts projections.
|
||||
|
||||
import time
|
||||
|
||||
from animaltrack.db import get_db
|
||||
from animaltrack.events.delete import delete_event
|
||||
from animaltrack.events.enums import LifeStage, Origin, Outcome
|
||||
from animaltrack.events.payloads import AnimalCohortCreatedPayload, AnimalOutcomePayload
|
||||
from animaltrack.events.store import EventStore
|
||||
from animaltrack.migrations import run_migrations
|
||||
from animaltrack.projections import ProjectionRegistry
|
||||
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
||||
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||
from animaltrack.projections.event_log import EventLogProjection
|
||||
from animaltrack.projections.feed import FeedInventoryProjection
|
||||
from animaltrack.projections.intervals import IntervalProjection
|
||||
from animaltrack.projections.products import ProductsProjection
|
||||
from animaltrack.seeds import run_seeds
|
||||
from animaltrack.services.animal import AnimalService
|
||||
|
||||
|
||||
class TestEventDeleteProjections:
|
||||
"""Tests for delete_event with projection updates."""
|
||||
|
||||
def test_delete_animal_outcome_reverts_status(self, tmp_path):
|
||||
"""Deleting AnimalOutcome should revert animals to alive status."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
# Set up database
|
||||
run_migrations(str(db_path), "migrations", verbose=False)
|
||||
db = get_db(str(db_path))
|
||||
run_seeds(db)
|
||||
|
||||
# Create projections and services
|
||||
event_store = EventStore(db)
|
||||
registry = ProjectionRegistry()
|
||||
registry.register(AnimalRegistryProjection(db))
|
||||
registry.register(IntervalProjection(db))
|
||||
registry.register(EventAnimalsProjection(db))
|
||||
registry.register(ProductsProjection(db))
|
||||
registry.register(FeedInventoryProjection(db))
|
||||
registry.register(EventLogProjection(db))
|
||||
|
||||
animal_service = AnimalService(db, event_store, registry)
|
||||
|
||||
ts_utc = int(time.time() * 1000)
|
||||
location = db.execute("SELECT id FROM locations LIMIT 1").fetchone()[0]
|
||||
|
||||
# Create a cohort
|
||||
cohort_payload = AnimalCohortCreatedPayload(
|
||||
species="duck",
|
||||
count=3,
|
||||
origin=Origin.PURCHASED,
|
||||
life_stage=LifeStage.ADULT,
|
||||
location_id=location,
|
||||
)
|
||||
cohort_event = animal_service.create_cohort(
|
||||
payload=cohort_payload,
|
||||
ts_utc=ts_utc,
|
||||
actor="test",
|
||||
)
|
||||
|
||||
# Get animal IDs
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
# Verify all animals are alive
|
||||
for aid in animal_ids:
|
||||
row = db.execute(
|
||||
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
assert row[0] == "alive"
|
||||
|
||||
# Record outcome (sold)
|
||||
outcome_payload = AnimalOutcomePayload(
|
||||
outcome=Outcome.SOLD,
|
||||
resolved_ids=animal_ids,
|
||||
)
|
||||
outcome_event = animal_service.record_outcome(
|
||||
payload=outcome_payload,
|
||||
ts_utc=ts_utc + 1000,
|
||||
actor="test",
|
||||
)
|
||||
|
||||
# Verify animals are now "sold"
|
||||
for aid in animal_ids:
|
||||
row = db.execute(
|
||||
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
assert row[0] == "sold", f"Animal {aid} should be sold, got {row[0]}"
|
||||
|
||||
# Delete the outcome event
|
||||
deleted_ids = delete_event(
|
||||
db=db,
|
||||
event_store=event_store,
|
||||
event_id=outcome_event.id,
|
||||
actor="test",
|
||||
role="admin",
|
||||
cascade=False,
|
||||
reason="test deletion",
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
assert len(deleted_ids) == 1
|
||||
assert outcome_event.id in deleted_ids
|
||||
|
||||
# Verify animals are back to "alive"
|
||||
for aid in animal_ids:
|
||||
row = db.execute(
|
||||
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
assert row[0] == "alive", f"Animal {aid} should be alive after delete, got {row[0]}"
|
||||
|
||||
def test_delete_without_registry_does_not_revert(self, tmp_path):
|
||||
"""Without registry projections, delete won't revert status (bug demo)."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
# Set up database
|
||||
run_migrations(str(db_path), "migrations", verbose=False)
|
||||
db = get_db(str(db_path))
|
||||
run_seeds(db)
|
||||
|
||||
# Create projections and services
|
||||
event_store = EventStore(db)
|
||||
registry = ProjectionRegistry()
|
||||
registry.register(AnimalRegistryProjection(db))
|
||||
registry.register(IntervalProjection(db))
|
||||
registry.register(EventAnimalsProjection(db))
|
||||
registry.register(ProductsProjection(db))
|
||||
registry.register(FeedInventoryProjection(db))
|
||||
registry.register(EventLogProjection(db))
|
||||
|
||||
animal_service = AnimalService(db, event_store, registry)
|
||||
|
||||
ts_utc = int(time.time() * 1000)
|
||||
location = db.execute("SELECT id FROM locations LIMIT 1").fetchone()[0]
|
||||
|
||||
# Create a cohort
|
||||
cohort_payload = AnimalCohortCreatedPayload(
|
||||
species="duck",
|
||||
count=2,
|
||||
origin=Origin.PURCHASED,
|
||||
life_stage=LifeStage.ADULT,
|
||||
location_id=location,
|
||||
)
|
||||
cohort_event = animal_service.create_cohort(
|
||||
payload=cohort_payload,
|
||||
ts_utc=ts_utc,
|
||||
actor="test",
|
||||
)
|
||||
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
# Record outcome (sold)
|
||||
outcome_payload = AnimalOutcomePayload(
|
||||
outcome=Outcome.SOLD,
|
||||
resolved_ids=animal_ids,
|
||||
)
|
||||
outcome_event = animal_service.record_outcome(
|
||||
payload=outcome_payload,
|
||||
ts_utc=ts_utc + 1000,
|
||||
actor="test",
|
||||
)
|
||||
|
||||
# Verify animals are "sold"
|
||||
for aid in animal_ids:
|
||||
row = db.execute(
|
||||
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
assert row[0] == "sold"
|
||||
|
||||
# Delete with EMPTY registry (simulating the bug)
|
||||
empty_registry = ProjectionRegistry() # No projections registered!
|
||||
deleted_ids = delete_event(
|
||||
db=db,
|
||||
event_store=event_store,
|
||||
event_id=outcome_event.id,
|
||||
actor="test",
|
||||
role="admin",
|
||||
cascade=False,
|
||||
reason="test deletion",
|
||||
registry=empty_registry,
|
||||
)
|
||||
|
||||
assert len(deleted_ids) == 1
|
||||
|
||||
# Bug: Animals are still "sold" because projections weren't reverted
|
||||
for aid in animal_ids:
|
||||
row = db.execute(
|
||||
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
# This demonstrates the bug - with empty registry, status is not reverted
|
||||
assert row[0] == "sold", "Without projections, animal should stay sold"
|
||||
|
||||
def test_delete_death_outcome_reverts_to_alive(self, tmp_path):
|
||||
"""Deleting death outcome should revert animals to alive status."""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
# Set up database
|
||||
run_migrations(str(db_path), "migrations", verbose=False)
|
||||
db = get_db(str(db_path))
|
||||
run_seeds(db)
|
||||
|
||||
# Create projections and services
|
||||
event_store = EventStore(db)
|
||||
registry = ProjectionRegistry()
|
||||
registry.register(AnimalRegistryProjection(db))
|
||||
registry.register(IntervalProjection(db))
|
||||
registry.register(EventAnimalsProjection(db))
|
||||
registry.register(ProductsProjection(db))
|
||||
registry.register(FeedInventoryProjection(db))
|
||||
registry.register(EventLogProjection(db))
|
||||
|
||||
animal_service = AnimalService(db, event_store, registry)
|
||||
|
||||
ts_utc = int(time.time() * 1000)
|
||||
location = db.execute("SELECT id FROM locations LIMIT 1").fetchone()[0]
|
||||
|
||||
# Create a cohort
|
||||
cohort_payload = AnimalCohortCreatedPayload(
|
||||
species="duck",
|
||||
count=2,
|
||||
origin=Origin.PURCHASED,
|
||||
life_stage=LifeStage.ADULT,
|
||||
location_id=location,
|
||||
)
|
||||
cohort_event = animal_service.create_cohort(
|
||||
payload=cohort_payload,
|
||||
ts_utc=ts_utc,
|
||||
actor="test",
|
||||
)
|
||||
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
# Record death
|
||||
outcome_payload = AnimalOutcomePayload(
|
||||
outcome=Outcome.DEATH,
|
||||
resolved_ids=animal_ids,
|
||||
)
|
||||
outcome_event = animal_service.record_outcome(
|
||||
payload=outcome_payload,
|
||||
ts_utc=ts_utc + 1000,
|
||||
actor="test",
|
||||
)
|
||||
|
||||
# Verify animals are "dead"
|
||||
for aid in animal_ids:
|
||||
row = db.execute(
|
||||
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
assert row[0] == "dead"
|
||||
|
||||
# Delete the outcome event with proper registry
|
||||
deleted_ids = delete_event(
|
||||
db=db,
|
||||
event_store=event_store,
|
||||
event_id=outcome_event.id,
|
||||
actor="test",
|
||||
role="admin",
|
||||
cascade=False,
|
||||
reason="test deletion",
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
assert len(deleted_ids) == 1
|
||||
|
||||
# Verify animals are back to alive
|
||||
for aid in animal_ids:
|
||||
row = db.execute(
|
||||
"SELECT status FROM animal_registry WHERE animal_id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
assert row[0] == "alive", f"Animal {aid} should be alive, got {row[0]}"
|
||||
Reference in New Issue
Block a user