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:
2025-12-31 08:41:48 +00:00
parent 301b925be3
commit f9e89fe5d6
8 changed files with 1124 additions and 0 deletions

View File

@@ -79,3 +79,23 @@ class SpeciesRepository:
) )
for row in rows for row in rows
] ]
def list_active(self) -> list[Species]:
"""Get all active species.
Returns:
List of active species.
"""
rows = self.db.execute(
"SELECT code, name, active, created_at_utc, updated_at_utc FROM species WHERE active = 1"
).fetchall()
return [
Species(
code=row[0],
name=row[1],
active=bool(row[2]),
created_at_utc=row[3],
updated_at_utc=row[4],
)
for row in rows
]

View File

@@ -18,6 +18,7 @@ from animaltrack.web.middleware import (
request_id_before, request_id_before,
) )
from animaltrack.web.routes import ( from animaltrack.web.routes import (
register_action_routes,
register_animals_routes, register_animals_routes,
register_egg_routes, register_egg_routes,
register_events_routes, register_events_routes,
@@ -133,6 +134,7 @@ def create_app(
# Register routes # Register routes
register_health_routes(rt, app) register_health_routes(rt, app)
register_action_routes(rt, app)
register_animals_routes(rt, app) register_animals_routes(rt, app)
register_egg_routes(rt, app) register_egg_routes(rt, app)
register_events_routes(rt, app) register_events_routes(rt, app)

View File

@@ -1,6 +1,7 @@
# ABOUTME: Routes package for AnimalTrack web application. # ABOUTME: Routes package for AnimalTrack web application.
# ABOUTME: Contains modular route handlers for different features. # ABOUTME: Contains modular route handlers for different features.
from animaltrack.web.routes.actions import register_action_routes
from animaltrack.web.routes.animals import register_animals_routes from animaltrack.web.routes.animals import register_animals_routes
from animaltrack.web.routes.eggs import register_egg_routes from animaltrack.web.routes.eggs import register_egg_routes
from animaltrack.web.routes.events import register_events_routes from animaltrack.web.routes.events import register_events_routes
@@ -10,6 +11,7 @@ from animaltrack.web.routes.move import register_move_routes
from animaltrack.web.routes.registry import register_registry_routes from animaltrack.web.routes.registry import register_registry_routes
__all__ = [ __all__ = [
"register_action_routes",
"register_animals_routes", "register_animals_routes",
"register_egg_routes", "register_egg_routes",
"register_events_routes", "register_events_routes",

View File

@@ -0,0 +1,336 @@
# ABOUTME: Routes for Animal Actions (create, update, tag, outcome, etc).
# ABOUTME: Handles all POST /actions/animal-* endpoints and their GET forms.
from __future__ import annotations
import json
import time
from typing import Any
from fasthtml.common import to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
from animaltrack.events.payloads import (
AnimalCohortCreatedPayload,
HatchRecordedPayload,
)
from animaltrack.events.store import EventStore
from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates.actions import cohort_form, hatch_form
def _create_animal_service(db: Any) -> AnimalService:
"""Create an AnimalService with standard projections.
Args:
db: Database connection.
Returns:
Configured AnimalService instance.
"""
event_store = EventStore(db)
registry = ProjectionRegistry()
registry.register(AnimalRegistryProjection(db))
registry.register(EventAnimalsProjection(db))
registry.register(IntervalProjection(db))
registry.register(EventLogProjection(db))
return AnimalService(db, event_store, registry)
# =============================================================================
# Cohort Creation
# =============================================================================
def cohort_index(request: Request):
"""GET /actions/cohort - Create Cohort form."""
db = request.app.state.db
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_active()
return page(
cohort_form(locations, species_list),
title="Create Cohort - AnimalTrack",
active_nav="cohort",
)
async def animal_cohort(request: Request):
"""POST /actions/animal-cohort - Create a new animal cohort."""
db = request.app.state.db
form = await request.form()
# Extract form data
species = form.get("species", "")
location_id = form.get("location_id", "")
count_str = form.get("count", "0")
life_stage = form.get("life_stage", "")
sex = form.get("sex", "unknown")
origin = form.get("origin", "")
notes = form.get("notes", "") or None
nonce = form.get("nonce")
# Get reference data for potential re-render
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_active()
# Validate count
try:
count = int(count_str)
if count < 1:
return _render_cohort_error(locations, species_list, "Count must be at least 1", form)
except ValueError:
return _render_cohort_error(locations, species_list, "Count must be a valid number", form)
# Validate required fields
if not species:
return _render_cohort_error(locations, species_list, "Please select a species", form)
if not location_id:
return _render_cohort_error(locations, species_list, "Please select a location", form)
if not life_stage:
return _render_cohort_error(locations, species_list, "Please select a life stage", form)
if not origin:
return _render_cohort_error(locations, species_list, "Please select an origin", form)
# Create payload
try:
payload = AnimalCohortCreatedPayload(
species=species,
count=count,
life_stage=life_stage,
sex=sex,
location_id=location_id,
origin=origin,
notes=notes,
)
except Exception as e:
return _render_cohort_error(locations, species_list, str(e), form)
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Create cohort
ts_utc = int(time.time() * 1000)
service = _create_animal_service(db)
try:
event = service.create_cohort(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-cohort"
)
except ValidationError as e:
return _render_cohort_error(locations, species_list, str(e), form)
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
cohort_form(locations, species_list),
title="Create Cohort - AnimalTrack",
active_nav="cohort",
)
),
)
# Add toast trigger header
animal_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Created {animal_count} {species}(s)",
"type": "success",
}
}
)
return response
def _render_cohort_error(
locations: list,
species_list: list,
error_message: str,
form_data: Any = None,
) -> HTMLResponse:
"""Render cohort form with error message."""
return HTMLResponse(
content=to_xml(
page(
cohort_form(
locations,
species_list,
error=error_message,
selected_species=form_data.get("species", "") if form_data else "",
selected_location=form_data.get("location_id", "") if form_data else "",
selected_life_stage=form_data.get("life_stage", "") if form_data else "",
selected_sex=form_data.get("sex", "unknown") if form_data else "",
selected_origin=form_data.get("origin", "") if form_data else "",
count_value=form_data.get("count", "") if form_data else "",
),
title="Create Cohort - AnimalTrack",
active_nav="cohort",
)
),
status_code=422,
)
# =============================================================================
# Hatch Recording
# =============================================================================
def hatch_index(request: Request):
"""GET /actions/hatch - Record Hatch form."""
db = request.app.state.db
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_active()
return page(
hatch_form(locations, species_list),
title="Record Hatch - AnimalTrack",
active_nav="hatch",
)
async def hatch_recorded(request: Request):
"""POST /actions/hatch-recorded - Record a hatch event."""
db = request.app.state.db
form = await request.form()
# Extract form data
species = form.get("species", "")
location_id = form.get("location_id", "")
assigned_brood_location_id = form.get("assigned_brood_location_id", "") or None
hatched_live_str = form.get("hatched_live", "0")
notes = form.get("notes", "") or None
nonce = form.get("nonce")
# Get reference data for potential re-render
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_active()
# Validate count
try:
hatched_live = int(hatched_live_str)
if hatched_live < 1:
return _render_hatch_error(
locations, species_list, "Hatched count must be at least 1", form
)
except ValueError:
return _render_hatch_error(
locations, species_list, "Hatched count must be a valid number", form
)
# Validate required fields
if not species:
return _render_hatch_error(locations, species_list, "Please select a species", form)
if not location_id:
return _render_hatch_error(locations, species_list, "Please select a hatch location", form)
# Create payload
try:
payload = HatchRecordedPayload(
species=species,
location_id=location_id,
assigned_brood_location_id=assigned_brood_location_id,
hatched_live=hatched_live,
notes=notes,
)
except Exception as e:
return _render_hatch_error(locations, species_list, str(e), form)
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Record hatch
ts_utc = int(time.time() * 1000)
service = _create_animal_service(db)
try:
event = service.record_hatch(
payload, ts_utc, actor, nonce=nonce, route="/actions/hatch-recorded"
)
except ValidationError as e:
return _render_hatch_error(locations, species_list, str(e), form)
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
hatch_form(locations, species_list),
title="Record Hatch - AnimalTrack",
active_nav="hatch",
)
),
)
# Add toast trigger header
animal_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {animal_count} hatchling(s)",
"type": "success",
}
}
)
return response
def _render_hatch_error(
locations: list,
species_list: list,
error_message: str,
form_data: Any = None,
) -> HTMLResponse:
"""Render hatch form with error message."""
return HTMLResponse(
content=to_xml(
page(
hatch_form(
locations,
species_list,
error=error_message,
selected_species=form_data.get("species", "") if form_data else "",
selected_location=form_data.get("location_id", "") if form_data else "",
selected_brood_location=form_data.get("assigned_brood_location_id", "")
if form_data
else "",
hatched_live_value=form_data.get("hatched_live", "") if form_data else "",
),
title="Record Hatch - AnimalTrack",
active_nav="hatch",
)
),
status_code=422,
)
# =============================================================================
# Route Registration
# =============================================================================
def register_action_routes(rt, app):
"""Register animal action routes.
Args:
rt: FastHTML route decorator.
app: FastHTML application instance.
"""
# Creation actions
rt("/actions/cohort")(cohort_index)
rt("/actions/animal-cohort", methods=["POST"])(animal_cohort)
rt("/actions/hatch")(hatch_index)
rt("/actions/hatch-recorded", methods=["POST"])(hatch_recorded)

View File

@@ -0,0 +1,277 @@
# ABOUTME: Form templates for animal action pages.
# ABOUTME: Provides reusable form components for create, update, tag, outcome actions.
from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, Div, Form, Hidden, Option, P
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect
from ulid import ULID
from animaltrack.models.reference import Location, Species
# =============================================================================
# Cohort Creation Form
# =============================================================================
def cohort_form(
locations: list[Location],
species_list: list[Species],
error: str | None = None,
selected_species: str = "",
selected_location: str = "",
selected_life_stage: str = "",
selected_sex: str = "unknown",
selected_origin: str = "",
count_value: str = "",
action: Callable[..., Any] | str = "/actions/animal-cohort",
) -> Form:
"""Create the Cohort Creation form.
Args:
locations: List of active locations for the dropdown.
species_list: List of active species for the dropdown.
error: Optional error message to display.
selected_species: Pre-selected species code.
selected_location: Pre-selected location ID.
selected_life_stage: Pre-selected life stage.
selected_sex: Pre-selected sex.
selected_origin: Pre-selected origin.
count_value: Pre-filled count value.
action: Route function or URL string for form submission.
Returns:
Form component for creating animal cohort.
"""
# Build species options
species_options = [
Option("Select species...", value="", disabled=True, selected=not selected_species)
]
for sp in species_list:
species_options.append(Option(sp.name, value=sp.code, selected=sp.code == selected_species))
# Build location options
location_options = [
Option("Select location...", value="", disabled=True, selected=not selected_location)
]
for loc in locations:
location_options.append(
Option(loc.name, value=loc.id, selected=loc.id == selected_location)
)
# Build life stage options
life_stages = [
("hatchling", "Hatchling"),
("juvenile", "Juvenile"),
("subadult", "Subadult"),
("adult", "Adult"),
]
life_stage_options = [
Option("Select life stage...", value="", disabled=True, selected=not selected_life_stage)
]
for code, label in life_stages:
life_stage_options.append(Option(label, value=code, selected=code == selected_life_stage))
# Build sex options
sexes = [
("unknown", "Unknown"),
("female", "Female"),
("male", "Male"),
]
sex_options = []
for code, label in sexes:
sex_options.append(Option(label, value=code, selected=code == selected_sex))
# Build origin options
origins = [
("hatched", "Hatched"),
("purchased", "Purchased"),
("rescued", "Rescued"),
("unknown", "Unknown"),
]
origin_options = [
Option("Select origin...", value="", disabled=True, selected=not selected_origin)
]
for code, label in origins:
origin_options.append(Option(label, value=code, selected=code == selected_origin))
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
return Form(
H2("Create Animal Cohort", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Species dropdown
LabelSelect(
*species_options,
label="Species",
id="species",
name="species",
),
# Location dropdown
LabelSelect(
*location_options,
label="Location",
id="location_id",
name="location_id",
),
# Count input
LabelInput(
"Count",
id="count",
name="count",
type="number",
min="1",
value=count_value,
placeholder="Number of animals",
),
# Life stage dropdown
LabelSelect(
*life_stage_options,
label="Life Stage",
id="life_stage",
name="life_stage",
),
# Sex dropdown
LabelSelect(
*sex_options,
label="Sex",
id="sex",
name="sex",
),
# Origin dropdown
LabelSelect(
*origin_options,
label="Origin",
id="origin",
name="origin",
),
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Create Cohort", type="submit", cls=ButtonT.primary),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
# =============================================================================
# Hatch Recording Form
# =============================================================================
def hatch_form(
locations: list[Location],
species_list: list[Species],
error: str | None = None,
selected_species: str = "",
selected_location: str = "",
selected_brood_location: str = "",
hatched_live_value: str = "",
action: Callable[..., Any] | str = "/actions/hatch-recorded",
) -> Form:
"""Create the Hatch Recording form.
Args:
locations: List of active locations for the dropdown.
species_list: List of active species for the dropdown.
error: Optional error message to display.
selected_species: Pre-selected species code.
selected_location: Pre-selected hatch location ID.
selected_brood_location: Pre-selected brood location ID.
hatched_live_value: Pre-filled hatched count value.
action: Route function or URL string for form submission.
Returns:
Form component for recording hatch events.
"""
# Build species options
species_options = [
Option("Select species...", value="", disabled=True, selected=not selected_species)
]
for sp in species_list:
species_options.append(Option(sp.name, value=sp.code, selected=sp.code == selected_species))
# Build location options (hatch location)
location_options = [
Option("Select hatch location...", value="", disabled=True, selected=not selected_location)
]
for loc in locations:
location_options.append(
Option(loc.name, value=loc.id, selected=loc.id == selected_location)
)
# Build brood location options (optional)
brood_location_options = [
Option(
"Same as hatch location",
value="",
selected=not selected_brood_location,
)
]
for loc in locations:
brood_location_options.append(
Option(loc.name, value=loc.id, selected=loc.id == selected_brood_location)
)
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
return Form(
H2("Record Hatch", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Species dropdown
LabelSelect(
*species_options,
label="Species",
id="species",
name="species",
),
# Hatch location dropdown
LabelSelect(
*location_options,
label="Hatch Location",
id="location_id",
name="location_id",
),
# Hatched count input
LabelInput(
"Hatched Live",
id="hatched_live",
name="hatched_live",
type="number",
min="1",
value=hatched_live_value,
placeholder="Number hatched",
),
# Brood location dropdown (optional)
Div(
LabelSelect(
*brood_location_options,
label="Brood Location (optional)",
id="assigned_brood_location_id",
name="assigned_brood_location_id",
),
P(
"If different from hatch location, hatchlings will be placed here",
cls="text-xs text-stone-400 mt-1",
),
),
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Record Hatch", type="submit", cls=ButtonT.primary),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)

View File

@@ -84,10 +84,70 @@ def RegistryIcon(active: bool = False): # noqa: N802
) )
def CohortIcon(active: bool = False): # noqa: N802
"""Cohort/group creation icon - multiple animals/figures."""
fill = "#b8860b" if active else "#6b6b63"
return Svg(
# Left duck silhouette
Path(
d="M6 10C6 8 7 6 9 6C11 6 12 8 12 10",
stroke=fill,
stroke_width="2",
fill="none",
),
# Right duck silhouette
Path(
d="M12 10C12 8 13 6 15 6C17 6 18 8 18 10",
stroke=fill,
stroke_width="2",
fill="none",
),
# Plus sign for creation
Path(
d="M12 14V20M9 17H15",
stroke=fill,
stroke_width="2",
stroke_linecap="round",
),
viewBox="0 0 24 24",
width="28",
height="28",
)
def HatchIcon(active: bool = False): # noqa: N802
"""Hatch/nest icon - cracked egg with hatchling."""
fill = "#b8860b" if active else "#6b6b63"
return Svg(
# Egg shell (cracked at top)
Path(
d="M7 12C7 7 9 4 12 4C15 4 17 7 17 12C17 17 15 20 12 20C9 20 7 17 7 12Z",
fill="none",
stroke=fill,
stroke_width="2",
),
# Crack lines
Path(
d="M10 8L12 10L14 8",
stroke=fill,
stroke_width="1.5",
stroke_linecap="round",
stroke_linejoin="round",
),
# Hatchling peek
Circle(cx="12", cy="14", r="2", fill=fill),
viewBox="0 0 24 24",
width="28",
height="28",
)
# Icon mapping by nav item ID # Icon mapping by nav item ID
NAV_ICONS = { NAV_ICONS = {
"egg": EggIcon, "egg": EggIcon,
"feed": FeedIcon, "feed": FeedIcon,
"move": MoveIcon, "move": MoveIcon,
"registry": RegistryIcon, "registry": RegistryIcon,
"cohort": CohortIcon,
"hatch": HatchIcon,
} }

View File

@@ -10,6 +10,8 @@ NAV_ITEMS = [
{"id": "egg", "label": "Egg", "href": "/"}, {"id": "egg", "label": "Egg", "href": "/"},
{"id": "feed", "label": "Feed", "href": "/feed"}, {"id": "feed", "label": "Feed", "href": "/feed"},
{"id": "move", "label": "Move", "href": "/move"}, {"id": "move", "label": "Move", "href": "/move"},
{"id": "cohort", "label": "Cohort", "href": "/actions/cohort"},
{"id": "hatch", "label": "Hatch", "href": "/actions/hatch"},
{"id": "registry", "label": "Registry", "href": "/registry"}, {"id": "registry", "label": "Registry", "href": "/registry"},
] ]

425
tests/test_web_actions.py Normal file
View 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