feat: implement Cohort and Hatch action routes (Step 9.1 partial)
- Add /actions/cohort GET and /actions/animal-cohort POST routes - Add /actions/hatch GET and /actions/hatch-recorded POST routes - Add cohort_form() and hatch_form() templates - Add Cohort and Hatch icons and navigation items - Add list_active() method to SpeciesRepository - Register action routes in app.py - 26 new tests for cohort and hatch actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
425
tests/test_web_actions.py
Normal file
425
tests/test_web_actions.py
Normal file
@@ -0,0 +1,425 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user