diff --git a/src/animaltrack/repositories/species.py b/src/animaltrack/repositories/species.py index 18d9f22..44ea64c 100644 --- a/src/animaltrack/repositories/species.py +++ b/src/animaltrack/repositories/species.py @@ -79,3 +79,23 @@ class SpeciesRepository: ) 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 + ] diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 179dd8f..1dd0bdf 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -18,6 +18,7 @@ from animaltrack.web.middleware import ( request_id_before, ) from animaltrack.web.routes import ( + register_action_routes, register_animals_routes, register_egg_routes, register_events_routes, @@ -133,6 +134,7 @@ def create_app( # Register routes register_health_routes(rt, app) + register_action_routes(rt, app) register_animals_routes(rt, app) register_egg_routes(rt, app) register_events_routes(rt, app) diff --git a/src/animaltrack/web/routes/__init__.py b/src/animaltrack/web/routes/__init__.py index 3090247..cfb7390 100644 --- a/src/animaltrack/web/routes/__init__.py +++ b/src/animaltrack/web/routes/__init__.py @@ -1,6 +1,7 @@ # ABOUTME: Routes package for AnimalTrack web application. # 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.eggs import register_egg_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 __all__ = [ + "register_action_routes", "register_animals_routes", "register_egg_routes", "register_events_routes", diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py new file mode 100644 index 0000000..1a8b61d --- /dev/null +++ b/src/animaltrack/web/routes/actions.py @@ -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) diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py new file mode 100644 index 0000000..d7b590b --- /dev/null +++ b/src/animaltrack/web/templates/actions.py @@ -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", + ) diff --git a/src/animaltrack/web/templates/icons.py b/src/animaltrack/web/templates/icons.py index 7fbed9f..d7b854c 100644 --- a/src/animaltrack/web/templates/icons.py +++ b/src/animaltrack/web/templates/icons.py @@ -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 NAV_ICONS = { "egg": EggIcon, "feed": FeedIcon, "move": MoveIcon, "registry": RegistryIcon, + "cohort": CohortIcon, + "hatch": HatchIcon, } diff --git a/src/animaltrack/web/templates/nav.py b/src/animaltrack/web/templates/nav.py index 104d081..7f82928 100644 --- a/src/animaltrack/web/templates/nav.py +++ b/src/animaltrack/web/templates/nav.py @@ -10,6 +10,8 @@ NAV_ITEMS = [ {"id": "egg", "label": "Egg", "href": "/"}, {"id": "feed", "label": "Feed", "href": "/feed"}, {"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"}, ] diff --git a/tests/test_web_actions.py b/tests/test_web_actions.py new file mode 100644 index 0000000..7750c65 --- /dev/null +++ b/tests/test_web_actions.py @@ -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