# 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 class TestMoveRecentEvents: """Tests for recent events display on move page.""" def test_move_form_shows_recent_events_section(self, client): """Move form shows Recent Moves section.""" resp = client.get("/move") assert resp.status_code == 200 assert "Recent Moves" in resp.text def test_move_event_appears_in_recent( self, client, seeded_db, animal_service, location_strip1_id, location_strip2_id, ducks_at_strip1, ): """Newly created move event appears in recent events list.""" 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-recent-move-1", }, ) assert resp.status_code == 200 # Recent events should include the newly created event assert "/events/" in resp.text def test_move_event_links_to_detail( self, client, seeded_db, animal_service, location_strip1_id, location_strip2_id, ducks_at_strip1, ): """Move events in recent list link to event detail page.""" 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-recent-move-2", }, ) assert resp.status_code == 200 # Get the event ID from DB event_row = seeded_db.execute( "SELECT id FROM events WHERE type = 'AnimalMoved' ORDER BY id DESC LIMIT 1" ).fetchone() event_id = event_row[0] # The response should contain a link to the event detail assert f"/events/{event_id}" in resp.text def test_days_since_last_move_shows_today( self, client, seeded_db, animal_service, location_strip1_id, location_strip2_id, ducks_at_strip1, ): """After a move today, shows 'Last move: today'.""" 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-recent-move-3", }, ) assert resp.status_code == 200 # Stats should show "Last move: today" assert "Last move: today" in resp.text