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:
474
tests/test_web_move.py
Normal file
474
tests/test_web_move.py
Normal 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
|
||||
Reference in New Issue
Block a user