# ABOUTME: Tests for Animal Action web routes. # ABOUTME: Covers cohort creation, hatch recording, and other animal actions. import os import pytest from starlette.testclient import TestClient 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 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_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] # ============================================================================= # Cohort Creation Tests # ============================================================================= class TestCohortFormRendering: """Tests for GET /actions/cohort form rendering.""" def test_cohort_form_renders(self, client): """GET /actions/cohort returns 200 with form elements.""" resp = client.get("/actions/cohort") assert resp.status_code == 200 assert "Create Animal Cohort" in resp.text or "Cohort" in resp.text def test_cohort_form_has_species_dropdown(self, client): """Form has species dropdown.""" resp = client.get("/actions/cohort") assert resp.status_code == 200 assert 'name="species"' in resp.text def test_cohort_form_has_location_dropdown(self, client): """Form has location dropdown with seeded locations.""" resp = client.get("/actions/cohort") assert resp.status_code == 200 assert "Strip 1" in resp.text assert 'name="location_id"' in resp.text def test_cohort_form_has_count_field(self, client): """Form has count input field.""" resp = client.get("/actions/cohort") assert resp.status_code == 200 assert 'name="count"' in resp.text def test_cohort_form_has_life_stage_dropdown(self, client): """Form has life stage dropdown.""" resp = client.get("/actions/cohort") assert resp.status_code == 200 assert 'name="life_stage"' in resp.text def test_cohort_form_has_origin_dropdown(self, client): """Form has origin dropdown.""" resp = client.get("/actions/cohort") assert resp.status_code == 200 assert 'name="origin"' in resp.text class TestCohortCreationSuccess: """Tests for successful POST /actions/animal-cohort.""" def test_cohort_creates_event(self, client, seeded_db, location_strip1_id): """POST creates AnimalCohortCreated event when valid.""" resp = client.post( "/actions/animal-cohort", data={ "species": "duck", "location_id": location_strip1_id, "count": "5", "life_stage": "adult", "sex": "female", "origin": "purchased", "nonce": "test-cohort-nonce-1", }, ) assert resp.status_code == 200 # Verify event was created event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'AnimalCohortCreated' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None assert event_row[0] == "AnimalCohortCreated" def test_cohort_creates_animals(self, client, seeded_db, location_strip1_id): """POST creates the correct number of animals in registry.""" # Count animals before count_before = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0] resp = client.post( "/actions/animal-cohort", data={ "species": "duck", "location_id": location_strip1_id, "count": "3", "life_stage": "juvenile", "sex": "unknown", "origin": "hatched", "nonce": "test-cohort-nonce-2", }, ) assert resp.status_code == 200 # Count animals after count_after = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0] assert count_after == count_before + 3 def test_cohort_success_returns_toast(self, client, seeded_db, location_strip1_id): """Successful cohort creation returns HX-Trigger with toast.""" resp = client.post( "/actions/animal-cohort", data={ "species": "duck", "location_id": location_strip1_id, "count": "2", "life_stage": "adult", "sex": "male", "origin": "purchased", "nonce": "test-cohort-nonce-3", }, ) assert resp.status_code == 200 assert "HX-Trigger" in resp.headers assert "showToast" in resp.headers["HX-Trigger"] class TestCohortCreationValidation: """Tests for validation errors in POST /actions/animal-cohort.""" def test_cohort_missing_species_returns_422(self, client, location_strip1_id): """Missing species returns 422.""" resp = client.post( "/actions/animal-cohort", data={ # Missing species "location_id": location_strip1_id, "count": "5", "life_stage": "adult", "origin": "purchased", "nonce": "test-cohort-nonce-4", }, ) assert resp.status_code == 422 def test_cohort_missing_location_returns_422(self, client): """Missing location returns 422.""" resp = client.post( "/actions/animal-cohort", data={ "species": "duck", # Missing location_id "count": "5", "life_stage": "adult", "origin": "purchased", "nonce": "test-cohort-nonce-5", }, ) assert resp.status_code == 422 def test_cohort_invalid_count_returns_422(self, client, location_strip1_id): """Invalid count (0 or negative) returns 422.""" resp = client.post( "/actions/animal-cohort", data={ "species": "duck", "location_id": location_strip1_id, "count": "0", # Invalid "life_stage": "adult", "origin": "purchased", "nonce": "test-cohort-nonce-6", }, ) assert resp.status_code == 422 def test_cohort_missing_life_stage_returns_422(self, client, location_strip1_id): """Missing life stage returns 422.""" resp = client.post( "/actions/animal-cohort", data={ "species": "duck", "location_id": location_strip1_id, "count": "5", # Missing life_stage "origin": "purchased", "nonce": "test-cohort-nonce-7", }, ) assert resp.status_code == 422 def test_cohort_missing_origin_returns_422(self, client, location_strip1_id): """Missing origin returns 422.""" resp = client.post( "/actions/animal-cohort", data={ "species": "duck", "location_id": location_strip1_id, "count": "5", "life_stage": "adult", # Missing origin "nonce": "test-cohort-nonce-8", }, ) assert resp.status_code == 422 # ============================================================================= # Hatch Recording Tests # ============================================================================= class TestHatchFormRendering: """Tests for GET /actions/hatch form rendering.""" def test_hatch_form_renders(self, client): """GET /actions/hatch returns 200 with form elements.""" resp = client.get("/actions/hatch") assert resp.status_code == 200 assert "Record Hatch" in resp.text or "Hatch" in resp.text def test_hatch_form_has_species_dropdown(self, client): """Form has species dropdown.""" resp = client.get("/actions/hatch") assert resp.status_code == 200 assert 'name="species"' in resp.text def test_hatch_form_has_location_dropdown(self, client): """Form has location dropdown.""" resp = client.get("/actions/hatch") assert resp.status_code == 200 assert 'name="location_id"' in resp.text def test_hatch_form_has_hatched_live_field(self, client): """Form has hatched live count field.""" resp = client.get("/actions/hatch") assert resp.status_code == 200 assert 'name="hatched_live"' in resp.text def test_hatch_form_has_brood_location_dropdown(self, client): """Form has optional brood location dropdown.""" resp = client.get("/actions/hatch") assert resp.status_code == 200 assert 'name="assigned_brood_location_id"' in resp.text class TestHatchRecordingSuccess: """Tests for successful POST /actions/hatch-recorded.""" def test_hatch_creates_event(self, client, seeded_db, location_strip1_id): """POST creates HatchRecorded event when valid.""" resp = client.post( "/actions/hatch-recorded", data={ "species": "duck", "location_id": location_strip1_id, "hatched_live": "4", "nonce": "test-hatch-nonce-1", }, ) assert resp.status_code == 200 # Verify event was created event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'HatchRecorded' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None assert event_row[0] == "HatchRecorded" def test_hatch_creates_hatchlings(self, client, seeded_db, location_strip1_id): """POST creates the correct number of hatchling animals.""" # Count animals before count_before = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0] resp = client.post( "/actions/hatch-recorded", data={ "species": "duck", "location_id": location_strip1_id, "hatched_live": "6", "nonce": "test-hatch-nonce-2", }, ) assert resp.status_code == 200 # Count animals after count_after = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0] assert count_after == count_before + 6 def test_hatch_with_brood_location( self, client, seeded_db, location_strip1_id, location_nursery1_id ): """POST with brood location places hatchlings at brood location.""" resp = client.post( "/actions/hatch-recorded", data={ "species": "duck", "location_id": location_strip1_id, "assigned_brood_location_id": location_nursery1_id, "hatched_live": "3", "nonce": "test-hatch-nonce-3", }, ) assert resp.status_code == 200 # Verify hatchlings are at brood location count_at_nursery = seeded_db.execute( "SELECT COUNT(*) FROM animal_registry WHERE location_id = ? AND life_stage = 'hatchling'", (location_nursery1_id,), ).fetchone()[0] assert count_at_nursery >= 3 def test_hatch_success_returns_toast(self, client, seeded_db, location_strip1_id): """Successful hatch recording returns HX-Trigger with toast.""" resp = client.post( "/actions/hatch-recorded", data={ "species": "duck", "location_id": location_strip1_id, "hatched_live": "2", "nonce": "test-hatch-nonce-4", }, ) assert resp.status_code == 200 assert "HX-Trigger" in resp.headers assert "showToast" in resp.headers["HX-Trigger"] class TestHatchRecordingValidation: """Tests for validation errors in POST /actions/hatch-recorded.""" def test_hatch_missing_species_returns_422(self, client, location_strip1_id): """Missing species returns 422.""" resp = client.post( "/actions/hatch-recorded", data={ # Missing species "location_id": location_strip1_id, "hatched_live": "4", "nonce": "test-hatch-nonce-5", }, ) assert resp.status_code == 422 def test_hatch_missing_location_returns_422(self, client): """Missing location returns 422.""" resp = client.post( "/actions/hatch-recorded", data={ "species": "duck", # Missing location_id "hatched_live": "4", "nonce": "test-hatch-nonce-6", }, ) assert resp.status_code == 422 def test_hatch_invalid_count_returns_422(self, client, location_strip1_id): """Invalid count (0 or negative) returns 422.""" resp = client.post( "/actions/hatch-recorded", data={ "species": "duck", "location_id": location_strip1_id, "hatched_live": "0", # Invalid "nonce": "test-hatch-nonce-7", }, ) assert resp.status_code == 422 # ============================================================================= # Animal Promotion Tests # ============================================================================= @pytest.fixture def unpromoted_animal_id(seeded_db, client, location_strip1_id): """Create an unpromoted animal for testing.""" # Create a cohort with one animal resp = client.post( "/actions/animal-cohort", data={ "species": "duck", "location_id": location_strip1_id, "count": "1", "life_stage": "adult", "sex": "female", "origin": "purchased", "nonce": "test-fixture-cohort-1", }, ) assert resp.status_code == 200 # Get the animal ID row = seeded_db.execute( "SELECT animal_id FROM animal_registry WHERE identified = 0 ORDER BY animal_id DESC LIMIT 1" ).fetchone() return row[0] class TestPromoteFormRendering: """Tests for GET /actions/promote/{animal_id} form rendering.""" def test_promote_form_renders(self, client, unpromoted_animal_id): """GET /actions/promote/{animal_id} returns 200 with form elements.""" resp = client.get(f"/actions/promote/{unpromoted_animal_id}") assert resp.status_code == 200 assert "Promote" in resp.text def test_promote_form_shows_animal_info(self, client, unpromoted_animal_id): """Form shows animal information.""" resp = client.get(f"/actions/promote/{unpromoted_animal_id}") assert resp.status_code == 200 # Should show animal ID (truncated) assert unpromoted_animal_id[:8] in resp.text def test_promote_form_has_nickname_field(self, client, unpromoted_animal_id): """Form has nickname input field.""" resp = client.get(f"/actions/promote/{unpromoted_animal_id}") assert resp.status_code == 200 assert 'name="nickname"' in resp.text def test_promote_form_has_sex_dropdown(self, client, unpromoted_animal_id): """Form has sex dropdown.""" resp = client.get(f"/actions/promote/{unpromoted_animal_id}") assert resp.status_code == 200 assert 'name="sex"' in resp.text def test_promote_form_not_found(self, client): """Non-existent animal returns 404.""" resp = client.get("/actions/promote/01ABCDEFGHIJKLMNOPQRSTUV00") assert resp.status_code == 404 class TestPromoteSuccess: """Tests for successful POST /actions/animal-promote.""" def test_promote_creates_event(self, client, seeded_db, unpromoted_animal_id): """POST creates AnimalPromoted event when valid.""" resp = client.post( "/actions/animal-promote", data={ "animal_id": unpromoted_animal_id, "nickname": "Daffy", "nonce": "test-promote-nonce-1", }, follow_redirects=False, ) # Should redirect assert resp.status_code == 303 # Verify event was created event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'AnimalPromoted' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None assert event_row[0] == "AnimalPromoted" def test_promote_sets_identified(self, client, seeded_db, unpromoted_animal_id): """POST sets identified flag on animal.""" resp = client.post( "/actions/animal-promote", data={ "animal_id": unpromoted_animal_id, "nickname": "Huey", "nonce": "test-promote-nonce-2", }, follow_redirects=False, ) assert resp.status_code == 303 # Verify identified is now true row = seeded_db.execute( "SELECT identified FROM animal_registry WHERE animal_id = ?", (unpromoted_animal_id,), ).fetchone() assert row[0] == 1 # identified = true def test_promote_sets_nickname(self, client, seeded_db, unpromoted_animal_id): """POST sets nickname on animal.""" resp = client.post( "/actions/animal-promote", data={ "animal_id": unpromoted_animal_id, "nickname": "Dewey", "nonce": "test-promote-nonce-3", }, follow_redirects=False, ) assert resp.status_code == 303 # Verify nickname is set row = seeded_db.execute( "SELECT nickname FROM animal_registry WHERE animal_id = ?", (unpromoted_animal_id,), ).fetchone() assert row[0] == "Dewey" class TestPromoteValidation: """Tests for validation errors in POST /actions/animal-promote.""" def test_promote_missing_animal_id_returns_422(self, client): """Missing animal_id returns 422.""" resp = client.post( "/actions/animal-promote", data={ "nickname": "Test", "nonce": "test-promote-nonce-4", }, ) assert resp.status_code == 422 def test_promote_not_found_returns_404(self, client): """Non-existent animal returns 404.""" resp = client.post( "/actions/animal-promote", data={ "animal_id": "01ABCDEFGHIJKLMNOPQRSTUV00", "nickname": "Test", "nonce": "test-promote-nonce-5", }, ) assert resp.status_code == 404