feat: implement Move Animals UI with optimistic locking (Step 7.5)

Add Move Animals form with selection context validation and concurrent
change handling via optimistic locking. When selection changes between
client resolution and submit, the user is shown a diff panel and can
confirm to proceed with the current server resolution.

Key changes:
- Add move template with form and diff panel components
- Add move routes (GET /move, POST /actions/animal-move)
- Register move routes in app
- Fix to_xml() usage for HTMLResponse (was using str())
- Use max(current_time, form_ts) for confirmed re-resolution

Tests:
- 15 route tests covering form rendering, success, validation, mismatch
- 7 E2E tests for optimistic lock flow (spec §21.8)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 14:31:03 +00:00
parent b1bfdfb05c
commit ff4fa86beb
7 changed files with 1530 additions and 4 deletions

View File

@@ -0,0 +1,499 @@
# ABOUTME: E2E test #8 from spec section 21.8: Optimistic locking with confirm.
# ABOUTME: Tests concurrent move handling with selection validation and confirmation flow.
import time
import pytest
from animaltrack.events.payloads import (
AnimalCohortCreatedPayload,
AnimalMovedPayload,
)
from animaltrack.events.store import EventStore
from animaltrack.events.types import ANIMAL_MOVED
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
@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
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),
}
@pytest.fixture
def strip1_id(seeded_db):
"""Get Strip 1 location ID from seeds."""
return seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()[0]
@pytest.fixture
def strip2_id(seeded_db):
"""Get Strip 2 location ID from seeds."""
return seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()[0]
@pytest.fixture
def nursery1_id(seeded_db):
"""Get Nursery 1 location ID from seeds."""
return seeded_db.execute("SELECT id FROM locations WHERE name = 'Nursery 1'").fetchone()[0]
@pytest.fixture
def optimistic_lock_scenario(seeded_db, services, now_utc, strip1_id, strip2_id):
"""Set up optimistic lock test scenario.
Creates:
- 5 adult female ducks at Strip 1
- 3 adult female ducks at Strip 2
Returns dict with animal IDs and references.
"""
one_day_ms = 24 * 60 * 60 * 1000
animal_creation_ts = now_utc - one_day_ms
# Create 5 adult female ducks at Strip 1
cohort1_payload = AnimalCohortCreatedPayload(
species="duck",
count=5,
life_stage="adult",
sex="female",
location_id=strip1_id,
origin="purchased",
)
cohort1_event = services["animal_service"].create_cohort(
cohort1_payload, animal_creation_ts, "test_user"
)
strip1_animal_ids = cohort1_event.entity_refs["animal_ids"]
# Create 3 adult female ducks at Strip 2
cohort2_payload = AnimalCohortCreatedPayload(
species="duck",
count=3,
life_stage="adult",
sex="female",
location_id=strip2_id,
origin="purchased",
)
cohort2_event = services["animal_service"].create_cohort(
cohort2_payload, animal_creation_ts, "test_user"
)
strip2_animal_ids = cohort2_event.entity_refs["animal_ids"]
return {
"strip1_id": strip1_id,
"strip2_id": strip2_id,
"strip1_animal_ids": strip1_animal_ids,
"strip2_animal_ids": strip2_animal_ids,
"animal_creation_ts": animal_creation_ts,
}
class TestE2E8OptimisticLockWithConfirm:
"""E2E test #8: Optimistic locking with confirm from spec section 21.8.
Scenario:
- Setup: Strip 1 = 5 female ducks, Strip 2 = 3 female ducks
- Client A resolves filter location:"Strip 1" → 5 ids, hash H1
- Client B moves 2 from Strip 1 → Strip 2 (commits)
- Client A submits move to Nursery 1 with roster_hash=H1 → 409, diff shows removed:2
- Client A resubmits with confirmed=true → moves remaining 3 to Nursery 1
Final state:
- Strip 1 females = 0
- Strip 2 females = 5 (original 3 + 2 moved from Strip 1)
- Nursery 1 females = 3
- Two AnimalMoved events logged
"""
def test_client_a_initial_resolution(
self, seeded_db, services, now_utc, strip1_id, optimistic_lock_scenario
):
"""Client A resolves filter and gets 5 animals with hash."""
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
ts_utc = now_utc
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
assert len(resolution.animal_ids) == 5
assert resolution.roster_hash != ""
def test_concurrent_move_causes_mismatch(
self,
seeded_db,
services,
now_utc,
strip1_id,
strip2_id,
nursery1_id,
optimistic_lock_scenario,
):
"""After client B moves 2 animals, client A's hash becomes invalid."""
strip1_animal_ids = optimistic_lock_scenario["strip1_animal_ids"]
# Client A resolves filter at T1
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
ts_t1 = now_utc
client_a_resolution = resolve_filter(seeded_db, filter_ast, ts_t1)
client_a_hash = compute_roster_hash(client_a_resolution.animal_ids, strip1_id)
assert len(client_a_resolution.animal_ids) == 5
# Client B moves 2 animals from Strip 1 to Strip 2 at T2
ts_t2 = ts_t1 + 1000
move_payload = AnimalMovedPayload(
resolved_ids=strip1_animal_ids[:2],
to_location_id=strip2_id,
)
services["animal_service"].move_animals(move_payload, ts_t2, "client_b")
# Client A submits with old hash at T3 (should detect mismatch)
ts_t3 = ts_t2 + 1000
context = SelectionContext(
filter=filter_str,
resolved_ids=list(client_a_resolution.animal_ids),
roster_hash=client_a_hash,
ts_utc=ts_t3,
from_location_id=strip1_id,
confirmed=False,
)
result = validate_selection(seeded_db, context)
# Validation should fail with mismatch
assert not result.valid
assert result.diff is not None
assert len(result.diff.removed) == 2
assert len(result.diff.added) == 0
def test_confirmed_proceeds_with_remaining_animals(
self,
seeded_db,
services,
now_utc,
strip1_id,
strip2_id,
nursery1_id,
optimistic_lock_scenario,
):
"""With confirmed=true, move proceeds with current server resolution."""
strip1_animal_ids = optimistic_lock_scenario["strip1_animal_ids"]
# Client A resolves filter at T1
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
ts_t1 = now_utc
client_a_resolution = resolve_filter(seeded_db, filter_ast, ts_t1)
client_a_hash = compute_roster_hash(client_a_resolution.animal_ids, strip1_id)
# Client B moves 2 animals from Strip 1 to Strip 2 at T2
ts_t2 = ts_t1 + 1000
move_payload = AnimalMovedPayload(
resolved_ids=strip1_animal_ids[:2],
to_location_id=strip2_id,
)
services["animal_service"].move_animals(move_payload, ts_t2, "client_b")
# Client A resubmits with confirmed=true at T3
ts_t3 = ts_t2 + 1000
context = SelectionContext(
filter=filter_str,
resolved_ids=list(client_a_resolution.animal_ids),
roster_hash=client_a_hash,
ts_utc=ts_t3,
from_location_id=strip1_id,
confirmed=True,
)
result = validate_selection(seeded_db, context)
# Validation should pass with confirmed=true
assert result.valid
# Per route behavior: re-resolve at ts_t3 to get current IDs
# (validate_selection returns client's IDs, route re-resolves)
current_resolution = resolve_filter(seeded_db, filter_ast, ts_t3)
ids_to_move = current_resolution.animal_ids
assert len(ids_to_move) == 3
# Now move those 3 to Nursery 1
move_payload = AnimalMovedPayload(
resolved_ids=ids_to_move,
to_location_id=nursery1_id,
)
services["animal_service"].move_animals(move_payload, ts_t3, "client_a")
# Verify final state
# Strip 1 should have 0 females
strip1_count = seeded_db.execute(
"""SELECT COUNT(*) FROM live_animals_by_location
WHERE location_id = ? AND sex = 'female'""",
(strip1_id,),
).fetchone()[0]
assert strip1_count == 0
def test_final_state_after_both_moves(
self,
seeded_db,
services,
now_utc,
strip1_id,
strip2_id,
nursery1_id,
optimistic_lock_scenario,
):
"""Full scenario verifies all final location counts."""
strip1_animal_ids = optimistic_lock_scenario["strip1_animal_ids"]
# Client A resolves filter at T1
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
ts_t1 = now_utc
client_a_resolution = resolve_filter(seeded_db, filter_ast, ts_t1)
client_a_hash = compute_roster_hash(client_a_resolution.animal_ids, strip1_id)
# Client B moves 2 animals from Strip 1 to Strip 2 at T2
ts_t2 = ts_t1 + 1000
move_payload_b = AnimalMovedPayload(
resolved_ids=strip1_animal_ids[:2],
to_location_id=strip2_id,
)
services["animal_service"].move_animals(move_payload_b, ts_t2, "client_b")
# Client A confirms and moves remaining 3 to Nursery 1 at T3
ts_t3 = ts_t2 + 1000
context = SelectionContext(
filter=filter_str,
resolved_ids=list(client_a_resolution.animal_ids),
roster_hash=client_a_hash,
ts_utc=ts_t3,
from_location_id=strip1_id,
confirmed=True,
)
result = validate_selection(seeded_db, context)
assert result.valid
# Per route behavior: re-resolve to get current IDs
current_resolution = resolve_filter(seeded_db, filter_ast, ts_t3)
ids_to_move = current_resolution.animal_ids
move_payload_a = AnimalMovedPayload(
resolved_ids=ids_to_move,
to_location_id=nursery1_id,
)
services["animal_service"].move_animals(move_payload_a, ts_t3, "client_a")
# Verify final state:
# Strip 1 females = 0 (all moved out)
strip1_count = seeded_db.execute(
"""SELECT COUNT(*) FROM live_animals_by_location
WHERE location_id = ? AND sex = 'female'""",
(strip1_id,),
).fetchone()[0]
assert strip1_count == 0
# Strip 2 females = 5 (original 3 + 2 moved from Strip 1)
strip2_count = seeded_db.execute(
"""SELECT COUNT(*) FROM live_animals_by_location
WHERE location_id = ? AND sex = 'female'""",
(strip2_id,),
).fetchone()[0]
assert strip2_count == 5
# Nursery 1 females = 3 (moved from Strip 1 by client A)
nursery1_count = seeded_db.execute(
"""SELECT COUNT(*) FROM live_animals_by_location
WHERE location_id = ? AND sex = 'female'""",
(nursery1_id,),
).fetchone()[0]
assert nursery1_count == 3
def test_two_move_events_logged(
self,
seeded_db,
services,
now_utc,
strip1_id,
strip2_id,
nursery1_id,
optimistic_lock_scenario,
):
"""Both moves should create AnimalMoved events."""
strip1_animal_ids = optimistic_lock_scenario["strip1_animal_ids"]
# Count events before
events_before = seeded_db.execute(
"SELECT COUNT(*) FROM events WHERE type = ?",
(ANIMAL_MOVED,),
).fetchone()[0]
# Client A resolves filter at T1
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
ts_t1 = now_utc
client_a_resolution = resolve_filter(seeded_db, filter_ast, ts_t1)
client_a_hash = compute_roster_hash(client_a_resolution.animal_ids, strip1_id)
# Client B moves 2 animals from Strip 1 to Strip 2 at T2
ts_t2 = ts_t1 + 1000
move_payload_b = AnimalMovedPayload(
resolved_ids=strip1_animal_ids[:2],
to_location_id=strip2_id,
)
services["animal_service"].move_animals(move_payload_b, ts_t2, "client_b")
# Client A confirms and moves remaining 3 to Nursery 1 at T3
ts_t3 = ts_t2 + 1000
context = SelectionContext(
filter=filter_str,
resolved_ids=list(client_a_resolution.animal_ids),
roster_hash=client_a_hash,
ts_utc=ts_t3,
from_location_id=strip1_id,
confirmed=True,
)
result = validate_selection(seeded_db, context)
assert result.valid
# Per route behavior: re-resolve to get current IDs
current_resolution = resolve_filter(seeded_db, filter_ast, ts_t3)
ids_to_move = current_resolution.animal_ids
move_payload_a = AnimalMovedPayload(
resolved_ids=ids_to_move,
to_location_id=nursery1_id,
)
services["animal_service"].move_animals(move_payload_a, ts_t3, "client_a")
# Count events after
events_after = seeded_db.execute(
"SELECT COUNT(*) FROM events WHERE type = ?",
(ANIMAL_MOVED,),
).fetchone()[0]
# Two new move events should be logged
assert events_after - events_before == 2
def test_move_without_confirm_fails_on_mismatch(
self,
seeded_db,
services,
now_utc,
strip1_id,
strip2_id,
nursery1_id,
optimistic_lock_scenario,
):
"""Without confirmed=true, mismatch should block the move."""
strip1_animal_ids = optimistic_lock_scenario["strip1_animal_ids"]
# Client A resolves filter at T1
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
ts_t1 = now_utc
client_a_resolution = resolve_filter(seeded_db, filter_ast, ts_t1)
client_a_hash = compute_roster_hash(client_a_resolution.animal_ids, strip1_id)
# Client B moves 2 animals from Strip 1 to Strip 2 at T2
ts_t2 = ts_t1 + 1000
move_payload = AnimalMovedPayload(
resolved_ids=strip1_animal_ids[:2],
to_location_id=strip2_id,
)
services["animal_service"].move_animals(move_payload, ts_t2, "client_b")
# Client A tries to move without confirmed=true at T3
ts_t3 = ts_t2 + 1000
context = SelectionContext(
filter=filter_str,
resolved_ids=list(client_a_resolution.animal_ids),
roster_hash=client_a_hash,
ts_utc=ts_t3,
from_location_id=strip1_id,
confirmed=False, # Not confirmed
)
result = validate_selection(seeded_db, context)
# Should be invalid
assert not result.valid
# Diff should show 2 removed
assert len(result.diff.removed) == 2
def test_diff_contains_updated_hash_and_ids(
self,
seeded_db,
services,
now_utc,
strip1_id,
strip2_id,
nursery1_id,
optimistic_lock_scenario,
):
"""Mismatch response should contain updated hash and resolved IDs."""
strip1_animal_ids = optimistic_lock_scenario["strip1_animal_ids"]
# Client A resolves filter at T1
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
ts_t1 = now_utc
client_a_resolution = resolve_filter(seeded_db, filter_ast, ts_t1)
client_a_hash = compute_roster_hash(client_a_resolution.animal_ids, strip1_id)
# Client B moves 2 animals from Strip 1 to Strip 2 at T2
ts_t2 = ts_t1 + 1000
move_payload = AnimalMovedPayload(
resolved_ids=strip1_animal_ids[:2],
to_location_id=strip2_id,
)
services["animal_service"].move_animals(move_payload, ts_t2, "client_b")
# Client A submits without confirmed at T3
ts_t3 = ts_t2 + 1000
context = SelectionContext(
filter=filter_str,
resolved_ids=list(client_a_resolution.animal_ids),
roster_hash=client_a_hash,
ts_utc=ts_t3,
from_location_id=strip1_id,
confirmed=False,
)
result = validate_selection(seeded_db, context)
# Result should contain updated info
assert not result.valid
assert len(result.resolved_ids) == 3 # Current server resolution
assert result.roster_hash != client_a_hash # Hash changed
assert result.roster_hash != "" # New hash computed

474
tests/test_web_move.py Normal file
View File

@@ -0,0 +1,474 @@
# ABOUTME: Tests for Move Animals web routes.
# ABOUTME: Covers GET /move form rendering and POST /actions/animal-move with optimistic locking.
import os
import time
import pytest
from starlette.testclient import TestClient
from animaltrack.events.payloads import AnimalCohortCreatedPayload, AnimalMovedPayload
from animaltrack.events.store import EventStore
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.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.services.animal import AnimalService
def make_test_settings(
csrf_secret: str = "test-secret",
trusted_proxy_ips: str = "127.0.0.1",
dev_mode: bool = True,
):
"""Create Settings for testing by setting env vars temporarily."""
from animaltrack.config import Settings
old_env = os.environ.copy()
try:
os.environ["CSRF_SECRET"] = csrf_secret
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
os.environ["DEV_MODE"] = str(dev_mode).lower()
return Settings()
finally:
os.environ.clear()
os.environ.update(old_env)
@pytest.fixture
def client(seeded_db):
"""Create a test client for the app."""
from animaltrack.web.app import create_app
settings = make_test_settings(trusted_proxy_ips="testclient")
app, rt = create_app(settings=settings, db=seeded_db)
return TestClient(app, raise_server_exceptions=True)
@pytest.fixture
def projection_registry(seeded_db):
"""Create a ProjectionRegistry with animal 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, projection_registry):
"""Create an AnimalService for testing."""
event_store = EventStore(seeded_db)
return AnimalService(seeded_db, event_store, projection_registry)
@pytest.fixture
def location_strip1_id(seeded_db):
"""Get Strip 1 location ID from seeded data."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
return row[0]
@pytest.fixture
def location_strip2_id(seeded_db):
"""Get Strip 2 location ID from seeded data."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()
return row[0]
@pytest.fixture
def location_nursery1_id(seeded_db):
"""Get Nursery 1 location ID from seeded data."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Nursery 1'").fetchone()
return row[0]
@pytest.fixture
def ducks_at_strip1(seeded_db, animal_service, location_strip1_id):
"""Create 5 female ducks at Strip 1 for testing move operations."""
payload = AnimalCohortCreatedPayload(
species="duck",
count=5,
life_stage="adult",
sex="female",
location_id=location_strip1_id,
origin="purchased",
)
ts_utc = int(time.time() * 1000)
event = animal_service.create_cohort(payload, ts_utc, "test_user")
return event.entity_refs["animal_ids"]
class TestMoveFormRendering:
"""Tests for GET /move form rendering."""
def test_move_form_renders(self, client):
"""GET /move returns 200 with form elements."""
resp = client.get("/move")
assert resp.status_code == 200
assert "Move" in resp.text
def test_move_form_shows_locations(self, client):
"""Form has location dropdown with seeded locations."""
resp = client.get("/move")
assert resp.status_code == 200
assert "Strip 1" in resp.text
assert "Strip 2" in resp.text
def test_move_form_has_filter_field(self, client):
"""Form has filter input field."""
resp = client.get("/move")
assert resp.status_code == 200
assert 'name="filter"' in resp.text or 'id="filter"' in resp.text
def test_move_form_has_destination_dropdown(self, client):
"""Form has destination location dropdown."""
resp = client.get("/move")
assert resp.status_code == 200
assert 'name="to_location_id"' in resp.text or 'id="to_location_id"' in resp.text
def test_move_form_with_filter_param(self, client, ducks_at_strip1):
"""GET /move?filter=... pre-fills filter and shows animal count."""
resp = client.get('/move?filter=location:"Strip 1"')
assert resp.status_code == 200
# Filter should be pre-filled
assert "Strip 1" in resp.text
# Should show animal count (5 ducks)
assert "5" in resp.text
def test_move_form_has_hidden_fields(self, client, ducks_at_strip1):
"""Form has hidden fields for selection context."""
resp = client.get('/move?filter=location:"Strip 1"')
assert resp.status_code == 200
# Hidden fields for selection context
assert 'name="roster_hash"' in resp.text
assert 'name="ts_utc"' in resp.text
assert 'name="nonce"' in resp.text
class TestMoveAnimalSuccess:
"""Tests for successful POST /actions/animal-move."""
def test_move_creates_event(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""POST creates AnimalMoved event when valid."""
# Get selection context by resolving filter
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-move-1",
},
)
assert resp.status_code in [200, 302, 303]
# Verify event was created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'AnimalMoved' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
assert event_row[0] == "AnimalMoved"
def test_move_success_returns_toast(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""Successful move returns HX-Trigger with toast."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-move-2",
},
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
def test_move_success_resets_form(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""After successful move, form is reset (nothing sticks)."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-move-3",
},
)
assert resp.status_code == 200
# Form should be reset - filter input should be empty (no value attribute or empty value)
# The old filter value should not be pre-filled
assert 'value="location:' not in resp.text
# The filter field should exist but be empty (or have no value)
assert 'name="filter"' in resp.text
class TestMoveAnimalValidation:
"""Tests for validation errors in POST /actions/animal-move."""
def test_move_no_animals_returns_422(self, client, location_strip1_id, location_strip2_id):
"""Moving with no animals selected returns 422."""
ts_utc = int(time.time() * 1000)
roster_hash = compute_roster_hash([])
resp = client.post(
"/actions/animal-move",
data={
"filter": "species:nonexistent",
"to_location_id": location_strip2_id,
"resolved_ids": [],
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-move-4",
},
)
assert resp.status_code == 422
def test_move_same_location_returns_422(
self, client, seeded_db, location_strip1_id, ducks_at_strip1
):
"""Moving to same location returns 422."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip1_id, # Same as from
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-move-5",
},
)
assert resp.status_code == 422
def test_move_missing_destination_returns_422(
self, client, seeded_db, location_strip1_id, ducks_at_strip1
):
"""Missing to_location_id returns 422."""
ts_utc = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
# Missing to_location_id
"resolved_ids": resolution.animal_ids,
"roster_hash": roster_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_utc),
"nonce": "test-nonce-move-6",
},
)
assert resp.status_code == 422
class TestMoveAnimalMismatch:
"""Tests for optimistic locking mismatch handling."""
def test_mismatch_returns_409(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""Hash mismatch (concurrent change) returns 409."""
# Client A resolves at ts_before
ts_before = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
client_resolution = resolve_filter(seeded_db, filter_ast, ts_before)
client_hash = compute_roster_hash(client_resolution.animal_ids, location_strip1_id)
# Client B moves 2 animals away
ts_move = ts_before + 1000
move_payload = AnimalMovedPayload(
resolved_ids=ducks_at_strip1[:2],
to_location_id=location_strip2_id,
)
animal_service.move_animals(move_payload, ts_move, "client_b")
# Client A submits with old hash at new timestamp
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": client_resolution.animal_ids,
"roster_hash": client_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_move), # Using ts_move so server will see different state
"nonce": "test-nonce-move-7",
},
)
assert resp.status_code == 409
def test_mismatch_shows_diff(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
ducks_at_strip1,
):
"""409 response shows diff panel with removed count."""
ts_before = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
client_resolution = resolve_filter(seeded_db, filter_ast, ts_before)
client_hash = compute_roster_hash(client_resolution.animal_ids, location_strip1_id)
# Move 2 animals away
ts_move = ts_before + 1000
move_payload = AnimalMovedPayload(
resolved_ids=ducks_at_strip1[:2],
to_location_id=location_strip2_id,
)
animal_service.move_animals(move_payload, ts_move, "client_b")
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_strip2_id,
"resolved_ids": client_resolution.animal_ids,
"roster_hash": client_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_move),
"nonce": "test-nonce-move-8",
},
)
assert resp.status_code == 409
# Response should show diff info
assert "2" in resp.text # 2 removed
def test_confirmed_proceeds_despite_mismatch(
self,
client,
seeded_db,
animal_service,
location_strip1_id,
location_strip2_id,
location_nursery1_id,
ducks_at_strip1,
):
"""confirmed=true bypasses mismatch and proceeds with server's resolution."""
ts_before = int(time.time() * 1000)
filter_str = 'location:"Strip 1"'
filter_ast = parse_filter(filter_str)
client_resolution = resolve_filter(seeded_db, filter_ast, ts_before)
client_hash = compute_roster_hash(client_resolution.animal_ids, location_strip1_id)
# Move 2 animals away
ts_move = ts_before + 1000
move_payload = AnimalMovedPayload(
resolved_ids=ducks_at_strip1[:2],
to_location_id=location_strip2_id,
)
animal_service.move_animals(move_payload, ts_move, "client_b")
# Client A resubmits with confirmed=true
resp = client.post(
"/actions/animal-move",
data={
"filter": filter_str,
"to_location_id": location_nursery1_id,
"resolved_ids": client_resolution.animal_ids,
"roster_hash": client_hash,
"from_location_id": location_strip1_id,
"ts_utc": str(ts_move),
"confirmed": "true",
"nonce": "test-nonce-move-9",
},
)
# Should succeed
print("RESPONSE STATUS:", resp.status_code)
print("RESPONSE TEXT:", resp.text[:2000])
assert resp.status_code == 200
# Verify only 3 animals were moved (the ones still at Strip 1)
event_row = seeded_db.execute(
"SELECT payload FROM events WHERE type = 'AnimalMoved' ORDER BY id DESC LIMIT 1"
).fetchone()
import json
payload = json.loads(event_row[0])
# Should have moved 3 animals (5 original - 2 moved by client B)
assert len(payload["resolved_ids"]) == 3