Files
animaltrack/tests/test_web_actions.py
Petru Paler 99f2fbb964 feat: add Promote Animal action route (Step 9.1 continued)
- Add /actions/promote/{animal_id} GET and /actions/animal-promote POST routes
- Add promote_form() template with nickname, sex, repro_status fields
- Add AnimalRepository.get() method for single-animal lookup
- 10 new tests for promote form rendering and submission

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 09:41:17 +00:00

587 lines
20 KiB
Python

# 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