Files
animaltrack/tests/test_web_move.py
Petru Paler 3937d675ba feat: add event detail slide-over, fix toasts, and checkbox selection
Three major features implemented:

1. Event Detail Slide-Over Panel
   - Click timeline events to view details in slide-over
   - New /events/{event_id} route and event_detail.py template
   - Type-specific payload rendering for all event types

2. Toast System Refactor
   - Switch from custom addEventListener to FastHTML's add_toast()
   - Replace HX-Trigger headers with session-based toasts
   - Add event links in toast messages
   - Replace addEventListener with hx_on_* in templates

3. Checkbox Selection for Animal Subsets
   - New animal_select.py component with checkbox list
   - New /api/compute-hash and /api/selection-preview endpoints
   - Add subset_mode support to SelectionContext validation
   - Update 5 forms: outcome, move, tag-add, tag-end, attrs
   - Users can select specific animals from filtered results

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:10:57 +00:00

483 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 returns session cookie 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 "set-cookie" in resp.headers
session_cookie = resp.headers["set-cookie"]
assert "session_=" in session_cookie
# Base64 decode contains toast message
import base64
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
base64_data = cookie_value.split(".")[0]
decoded = base64.b64decode(base64_data).decode()
assert "Moved 5 animals to Strip 2" in decoded
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