POST routes were returning HTMLResponse(content=to_xml(...)) which bypassed FastHTML's toast middleware. The middleware only injects toasts for tuple, FT, or FtResponse responses. Changed 12 routes to return render_page() directly: - actions.py: 7 routes (cohort, hatch, tag-add, tag-end, attrs, outcome, status-correct) - eggs.py: 2 routes (product-collected, product-sold) - feed.py: 2 routes (feed-given, feed-purchased) - move.py: 1 route (animal-move) Updated tests to check for toast content in response body instead of session cookie, since middleware now renders toasts inline. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
475 lines
17 KiB
Python
475 lines
17 KiB
Python
# 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 renders toast in response body."""
|
|
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
|
|
# Toast is injected into response body by FastHTML's toast middleware
|
|
assert "Moved 5 animals to Strip 2" in resp.text
|
|
|
|
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
|