From 68e1a59ec79b3368fd1d3ec840bdc6f8c8f18490 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Tue, 30 Dec 2025 10:43:28 +0000 Subject: [PATCH] feat: implement Feed Quick Capture form (Step 7.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /feed page with tabbed forms for Give Feed and Purchase Feed: - GET /feed renders page with tabs (Give Feed default, Purchase Feed) - POST /actions/feed-given records feed given to a location - POST /actions/feed-purchased records feed purchases to inventory Also adopts idiomatic FastHTML/HTMX pattern: - Add hx-boost to base template for automatic AJAX on forms - Refactor egg form to use action/method instead of hx_post Spec §22 compliance: - Integer kg only, min=1 - Warn if inventory negative (but allow) - Toast + stay on page after submit - Location/type stick, amount resets to default bag size 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- PLAN.md | 17 +- src/animaltrack/repositories/feed_types.py | 26 ++ src/animaltrack/web/app.py | 3 +- src/animaltrack/web/routes/__init__.py | 3 +- src/animaltrack/web/routes/feed.py | 425 +++++++++++++++++++++ src/animaltrack/web/templates/base.py | 3 + src/animaltrack/web/templates/eggs.py | 7 +- src/animaltrack/web/templates/feed.py | 296 ++++++++++++++ tests/test_web_feed.py | 362 ++++++++++++++++++ 9 files changed, 1128 insertions(+), 14 deletions(-) create mode 100644 src/animaltrack/web/routes/feed.py create mode 100644 src/animaltrack/web/templates/feed.py create mode 100644 tests/test_web_feed.py diff --git a/PLAN.md b/PLAN.md index d0ee8f8..49e19fa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -279,14 +279,15 @@ Check off items as completed. Each phase builds on the previous. - [x] Write tests: form renders, POST creates event, validation errors (422) - [x] **Commit checkpoint** (e9804cd) -### Step 7.4: Feed Quick Capture -- [ ] Create `web/routes/feed.py`: - - [ ] GET /feed - Feed Quick Capture form - - [ ] POST /actions/feed-given - - [ ] POST /actions/feed-purchased -- [ ] Create `web/templates/feed.py` with forms -- [ ] Implement defaults per spec §22 -- [ ] Write tests: form renders, POST creates events, blocked without purchase +### Step 7.4: Feed Quick Capture ✓ +- [x] Create `web/routes/feed.py`: + - [x] GET /feed - Feed Quick Capture form + - [x] POST /actions/feed-given + - [x] POST /actions/feed-purchased +- [x] Create `web/templates/feed.py` with forms +- [x] Implement defaults per spec §22 +- [x] Write tests: form renders, POST creates events, blocked without purchase +- [x] Adopt hx-boost pattern (idiomatic FastHTML/HTMX) - [ ] **Commit checkpoint** ### Step 7.5: Move Animals diff --git a/src/animaltrack/repositories/feed_types.py b/src/animaltrack/repositories/feed_types.py index 9a22801..e146d17 100644 --- a/src/animaltrack/repositories/feed_types.py +++ b/src/animaltrack/repositories/feed_types.py @@ -92,3 +92,29 @@ class FeedTypeRepository: ) for row in rows ] + + def list_active(self) -> list[FeedType]: + """Get all active feed types. + + Returns: + List of active feed types. + """ + rows = self.db.execute( + """ + SELECT code, name, default_bag_size_kg, protein_pct, active, created_at_utc, updated_at_utc + FROM feed_types + WHERE active = 1 + """ + ).fetchall() + return [ + FeedType( + code=row[0], + name=row[1], + default_bag_size_kg=row[2], + protein_pct=row[3], + active=bool(row[4]), + created_at_utc=row[5], + updated_at_utc=row[6], + ) + for row in rows + ] diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 282c854..6ca8146 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -17,7 +17,7 @@ from animaltrack.web.middleware import ( csrf_before, request_id_before, ) -from animaltrack.web.routes import register_egg_routes, register_health_routes +from animaltrack.web.routes import register_egg_routes, register_feed_routes, register_health_routes # Default static directory relative to this module DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static" @@ -126,5 +126,6 @@ def create_app( # Register routes register_health_routes(rt, app) register_egg_routes(rt, app) + register_feed_routes(rt, app) return app, rt diff --git a/src/animaltrack/web/routes/__init__.py b/src/animaltrack/web/routes/__init__.py index 197e091..67a8551 100644 --- a/src/animaltrack/web/routes/__init__.py +++ b/src/animaltrack/web/routes/__init__.py @@ -2,6 +2,7 @@ # ABOUTME: Contains modular route handlers for different features. from animaltrack.web.routes.eggs import register_egg_routes +from animaltrack.web.routes.feed import register_feed_routes from animaltrack.web.routes.health import register_health_routes -__all__ = ["register_egg_routes", "register_health_routes"] +__all__ = ["register_egg_routes", "register_feed_routes", "register_health_routes"] diff --git a/src/animaltrack/web/routes/feed.py b/src/animaltrack/web/routes/feed.py new file mode 100644 index 0000000..3dcbc5f --- /dev/null +++ b/src/animaltrack/web/routes/feed.py @@ -0,0 +1,425 @@ +# ABOUTME: Routes for Feed Quick Capture functionality. +# ABOUTME: Handles GET /feed form and POST /actions/feed-given, /actions/feed-purchased. + +from __future__ import annotations + +import json +import time +from typing import Any + +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload +from animaltrack.events.store import EventStore +from animaltrack.projections import ProjectionRegistry +from animaltrack.projections.feed import FeedInventoryProjection +from animaltrack.repositories.feed_types import FeedTypeRepository +from animaltrack.repositories.locations import LocationRepository +from animaltrack.services.feed import FeedService, ValidationError +from animaltrack.web.templates import page +from animaltrack.web.templates.feed import feed_page + + +def get_feed_balance(db: Any, feed_type_code: str) -> int | None: + """Get current feed balance for a feed type. + + Args: + db: Database connection. + feed_type_code: Feed type code. + + Returns: + Balance in kg, or None if no inventory record exists. + """ + row = db.execute( + "SELECT balance_kg FROM feed_inventory WHERE feed_type_code = ?", + (feed_type_code,), + ).fetchone() + return row[0] if row else None + + +def register_feed_routes(rt, app): + """Register feed capture routes. + + Args: + rt: FastHTML route decorator. + app: FastHTML application instance. + """ + + @rt("/feed") + def feed_index(request: Request): + """GET /feed - Feed Quick Capture page.""" + db = app.state.db + locations = LocationRepository(db).list_active() + feed_types = FeedTypeRepository(db).list_active() + + # Check for active tab from query params + active_tab = request.query_params.get("tab", "give") + if active_tab not in ("give", "purchase"): + active_tab = "give" + + return page( + feed_page( + locations, + feed_types, + active_tab=active_tab, + ), + title="Feed - AnimalTrack", + active_nav="feed", + ) + + @rt("/actions/feed-given", methods=["POST"]) + async def feed_given(request: Request): + """POST /actions/feed-given - Record feed given.""" + db = app.state.db + form = await request.form() + + # Extract form data + location_id = form.get("location_id", "") + feed_type_code = form.get("feed_type_code", "") + amount_kg_str = form.get("amount_kg", "0") + notes = form.get("notes") or None + nonce = form.get("nonce") + + # Get data for potential re-render + locations = LocationRepository(db).list_active() + feed_types = FeedTypeRepository(db).list_active() + + # Find default amount from feed type + default_amount_kg = 20 + for ft in feed_types: + if ft.code == feed_type_code: + default_amount_kg = ft.default_bag_size_kg + break + + # Validate location_id + if not location_id: + return _render_give_error( + locations, + feed_types, + "Please select a location", + selected_location_id=None, + selected_feed_type_code=feed_type_code or None, + ) + + # Validate feed_type_code + if not feed_type_code: + return _render_give_error( + locations, + feed_types, + "Please select a feed type", + selected_location_id=location_id, + selected_feed_type_code=None, + ) + + # Validate amount_kg + try: + amount_kg = int(amount_kg_str) + except ValueError: + return _render_give_error( + locations, + feed_types, + "Amount must be a number", + selected_location_id=location_id, + selected_feed_type_code=feed_type_code, + ) + + if amount_kg < 1: + return _render_give_error( + locations, + feed_types, + "Amount must be at least 1 kg", + selected_location_id=location_id, + selected_feed_type_code=feed_type_code, + ) + + # Get current timestamp + ts_utc = int(time.time() * 1000) + + # Create feed service + event_store = EventStore(db) + registry = ProjectionRegistry() + registry.register(FeedInventoryProjection(db)) + + feed_service = FeedService(db, event_store, registry) + + # Create payload + payload = FeedGivenPayload( + location_id=location_id, + feed_type_code=feed_type_code, + amount_kg=amount_kg, + notes=notes, + ) + + # Get actor from auth + auth = request.scope.get("auth") + actor = auth.username if auth else "unknown" + + # Give feed + try: + feed_service.give_feed( + payload=payload, + ts_utc=ts_utc, + actor=actor, + nonce=nonce, + route="/actions/feed-given", + ) + except ValidationError as e: + return _render_give_error( + locations, + feed_types, + str(e), + selected_location_id=location_id, + selected_feed_type_code=feed_type_code, + ) + + # Check for negative balance warning + balance = get_feed_balance(db, feed_type_code) + balance_warning = None + if balance is not None and balance < 0: + balance_warning = f"Warning: Inventory balance is now negative ({balance} kg)" + + # Success: re-render form with location/type sticking, amount reset + response = HTMLResponse( + content=str( + page( + feed_page( + locations, + feed_types, + active_tab="give", + selected_location_id=location_id, + selected_feed_type_code=feed_type_code, + default_amount_kg=default_amount_kg, + balance_warning=balance_warning, + ), + title="Feed - AnimalTrack", + active_nav="feed", + ) + ), + ) + + # Add toast trigger header + response.headers["HX-Trigger"] = json.dumps( + { + "showToast": { + "message": f"Recorded {amount_kg}kg {feed_type_code}", + "type": "success", + } + } + ) + + return response + + @rt("/actions/feed-purchased", methods=["POST"]) + async def feed_purchased(request: Request): + """POST /actions/feed-purchased - Record feed purchase.""" + db = app.state.db + form = await request.form() + + # Extract form data + feed_type_code = form.get("feed_type_code", "") + bag_size_kg_str = form.get("bag_size_kg", "0") + bags_count_str = form.get("bags_count", "0") + bag_price_cents_str = form.get("bag_price_cents", "0") + vendor = form.get("vendor") or None + notes = form.get("notes") or None + nonce = form.get("nonce") + + # Get data for potential re-render + locations = LocationRepository(db).list_active() + feed_types = FeedTypeRepository(db).list_active() + + # Validate feed_type_code + if not feed_type_code: + return _render_purchase_error( + locations, + feed_types, + "Please select a feed type", + ) + + # Validate bag_size_kg + try: + bag_size_kg = int(bag_size_kg_str) + except ValueError: + return _render_purchase_error( + locations, + feed_types, + "Bag size must be a number", + ) + + if bag_size_kg < 1: + return _render_purchase_error( + locations, + feed_types, + "Bag size must be at least 1 kg", + ) + + # Validate bags_count + try: + bags_count = int(bags_count_str) + except ValueError: + return _render_purchase_error( + locations, + feed_types, + "Bags count must be a number", + ) + + if bags_count < 1: + return _render_purchase_error( + locations, + feed_types, + "Bags count must be at least 1", + ) + + # Validate bag_price_cents + try: + bag_price_cents = int(bag_price_cents_str) + except ValueError: + return _render_purchase_error( + locations, + feed_types, + "Price must be a number", + ) + + if bag_price_cents < 0: + return _render_purchase_error( + locations, + feed_types, + "Price cannot be negative", + ) + + # Get current timestamp + ts_utc = int(time.time() * 1000) + + # Create feed service + event_store = EventStore(db) + registry = ProjectionRegistry() + registry.register(FeedInventoryProjection(db)) + + feed_service = FeedService(db, event_store, registry) + + # Create payload + payload = FeedPurchasedPayload( + feed_type_code=feed_type_code, + bag_size_kg=bag_size_kg, + bags_count=bags_count, + bag_price_cents=bag_price_cents, + vendor=vendor, + notes=notes, + ) + + # Get actor from auth + auth = request.scope.get("auth") + actor = auth.username if auth else "unknown" + + # Purchase feed + try: + feed_service.purchase_feed( + payload=payload, + ts_utc=ts_utc, + actor=actor, + nonce=nonce, + route="/actions/feed-purchased", + ) + except ValidationError as e: + return _render_purchase_error( + locations, + feed_types, + str(e), + ) + + # Calculate total for toast + total_kg = bag_size_kg * bags_count + + # Success: re-render form with fields cleared + response = HTMLResponse( + content=str( + page( + feed_page( + locations, + feed_types, + active_tab="purchase", + ), + title="Feed - AnimalTrack", + active_nav="feed", + ) + ), + ) + + # Add toast trigger header + response.headers["HX-Trigger"] = json.dumps( + { + "showToast": { + "message": f"Purchased {total_kg}kg {feed_type_code}", + "type": "success", + } + } + ) + + return response + + +def _render_give_error( + locations, + feed_types, + error_message, + selected_location_id=None, + selected_feed_type_code=None, +): + """Render give form with error message. + + Args: + locations: List of active locations. + feed_types: List of active feed types. + error_message: Error message to display. + selected_location_id: Currently selected location. + selected_feed_type_code: Currently selected feed type. + + Returns: + HTMLResponse with 422 status. + """ + return HTMLResponse( + content=str( + page( + feed_page( + locations, + feed_types, + active_tab="give", + selected_location_id=selected_location_id, + selected_feed_type_code=selected_feed_type_code, + give_error=error_message, + ), + title="Feed - AnimalTrack", + active_nav="feed", + ) + ), + status_code=422, + ) + + +def _render_purchase_error(locations, feed_types, error_message): + """Render purchase form with error message. + + Args: + locations: List of active locations. + feed_types: List of active feed types. + error_message: Error message to display. + + Returns: + HTMLResponse with 422 status. + """ + return HTMLResponse( + content=str( + page( + feed_page( + locations, + feed_types, + active_tab="purchase", + purchase_error=error_message, + ), + title="Feed - AnimalTrack", + active_nav="feed", + ) + ), + status_code=422, + ) diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index c7b2d82..830a31f 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -28,8 +28,11 @@ def page(content, title: str = "AnimalTrack", active_nav: str = "egg"): Title(title), BottomNavStyles(), # Main content with bottom padding for fixed nav + # hx-boost enables AJAX for all descendant forms/links Div( Container(content), + hx_boost="true", + hx_target="body", cls="pb-20 min-h-screen bg-[#0f0f0e] text-stone-100", ), BottomNav(active_id=active_nav), diff --git a/src/animaltrack/web/templates/eggs.py b/src/animaltrack/web/templates/eggs.py index 52f6495..117d8c7 100644 --- a/src/animaltrack/web/templates/eggs.py +++ b/src/animaltrack/web/templates/eggs.py @@ -81,9 +81,8 @@ def egg_form( Hidden(name="nonce", value=str(ULID())), # Submit button Button("Record Eggs", type="submit", cls=ButtonT.primary), - # Form submission via HTMX - hx_post="/actions/product-collected", - hx_target="body", - hx_swap="innerHTML", + # Form submission via standard action/method (hx-boost handles AJAX) + action="/actions/product-collected", + method="post", cls="space-y-4", ) diff --git a/src/animaltrack/web/templates/feed.py b/src/animaltrack/web/templates/feed.py new file mode 100644 index 0000000..63390a0 --- /dev/null +++ b/src/animaltrack/web/templates/feed.py @@ -0,0 +1,296 @@ +# ABOUTME: Templates for Feed Quick Capture forms. +# ABOUTME: Provides form components for recording feed given and purchases. + +from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul +from monsterui.all import ( + Button, + ButtonT, + LabelInput, + LabelSelect, + LabelTextArea, + TabContainer, +) +from ulid import ULID + +from animaltrack.models.reference import FeedType, Location + + +def feed_page( + locations: list[Location], + feed_types: list[FeedType], + active_tab: str = "give", + selected_location_id: str | None = None, + selected_feed_type_code: str | None = None, + default_amount_kg: int | None = None, + give_error: str | None = None, + purchase_error: str | None = None, + balance_warning: str | None = None, +): + """Create the Feed Quick Capture page with tabbed forms. + + Args: + locations: List of active locations for the dropdown. + feed_types: List of active feed types for the dropdown. + active_tab: Which tab is active ('give' or 'purchase'). + selected_location_id: Pre-selected location ID (sticks after give). + selected_feed_type_code: Pre-selected feed type code (sticks after give). + default_amount_kg: Default amount for give form (from feed type). + give_error: Error message for give form. + purchase_error: Error message for purchase form. + balance_warning: Warning about negative inventory balance. + + Returns: + Page content with tabbed forms. + """ + give_active = active_tab == "give" + + return Div( + H1("Feed", cls="text-2xl font-bold mb-6"), + # Tab navigation + TabContainer( + Li( + A( + "Give Feed", + href="#", + cls="uk-active" if give_active else "", + ), + ), + Li( + A( + "Purchase Feed", + href="#", + cls="" if give_active else "uk-active", + ), + ), + uk_switcher="connect: #feed-forms; animation: uk-animation-fade", + alt=True, + ), + # Tab content + Ul(id="feed-forms", cls="uk-switcher mt-4")( + Li( + give_feed_form( + locations, + feed_types, + selected_location_id=selected_location_id, + selected_feed_type_code=selected_feed_type_code, + default_amount_kg=default_amount_kg, + error=give_error, + balance_warning=balance_warning, + ), + cls="uk-active" if give_active else "", + ), + Li( + purchase_feed_form(feed_types, error=purchase_error), + cls="" if give_active else "uk-active", + ), + ), + cls="p-4", + ) + + +def give_feed_form( + locations: list[Location], + feed_types: list[FeedType], + selected_location_id: str | None = None, + selected_feed_type_code: str | None = None, + default_amount_kg: int | None = None, + error: str | None = None, + balance_warning: str | None = None, +) -> Form: + """Create the Give Feed form. + + Args: + locations: List of active locations. + feed_types: List of active feed types. + selected_location_id: Pre-selected location ID. + selected_feed_type_code: Pre-selected feed type code. + default_amount_kg: Default value for amount field. + error: Error message to display. + balance_warning: Warning about negative balance. + + Returns: + Form component for giving feed. + """ + # Build location options + location_options = [ + Option( + loc.name, + value=loc.id, + selected=(loc.id == selected_location_id), + ) + for loc in locations + ] + if selected_location_id is None: + location_options.insert( + 0, Option("Select a location...", value="", disabled=True, selected=True) + ) + + # Build feed type options + feed_type_options = [ + Option( + ft.name, + value=ft.code, + selected=(ft.code == selected_feed_type_code), + ) + for ft in feed_types + ] + if selected_feed_type_code is None: + feed_type_options.insert( + 0, Option("Select feed type...", value="", disabled=True, selected=True) + ) + + # Error display + error_component = None + if error: + error_component = Div( + P(error, cls="text-red-500 text-sm"), + cls="mb-4", + ) + + # Warning display + warning_component = None + if balance_warning: + warning_component = Div( + P(balance_warning, cls="text-yellow-500 text-sm"), + cls="mb-4", + ) + + return Form( + H2("Give Feed", cls="text-xl font-bold mb-4"), + error_component, + warning_component, + # Location dropdown + LabelSelect( + *location_options, + label="Location", + id="location_id", + name="location_id", + ), + # Feed type dropdown + LabelSelect( + *feed_type_options, + label="Feed Type", + id="feed_type_code", + name="feed_type_code", + ), + # Amount input + LabelInput( + "Amount (kg)", + id="amount_kg", + name="amount_kg", + type="number", + min="1", + step="1", + value=str(default_amount_kg) if default_amount_kg else "", + placeholder="Amount in kg", + required=True, + ), + # Optional notes + LabelTextArea( + "Notes", + id="notes", + name="notes", + placeholder="Optional notes", + ), + # Hidden nonce + Hidden(name="nonce", value=str(ULID())), + # Submit button + Button("Record Feed Given", type="submit", cls=ButtonT.primary), + action="/actions/feed-given", + method="post", + cls="space-y-4", + ) + + +def purchase_feed_form( + feed_types: list[FeedType], + error: str | None = None, +) -> Form: + """Create the Purchase Feed form. + + Args: + feed_types: List of active feed types. + error: Error message to display. + + Returns: + Form component for purchasing feed. + """ + # Build feed type options + feed_type_options = [Option(ft.name, value=ft.code) for ft in feed_types] + feed_type_options.insert( + 0, Option("Select feed type...", value="", disabled=True, selected=True) + ) + + # Error display + error_component = None + if error: + error_component = Div( + P(error, cls="text-red-500 text-sm"), + cls="mb-4", + ) + + return Form( + H2("Purchase Feed", cls="text-xl font-bold mb-4"), + error_component, + # Feed type dropdown + LabelSelect( + *feed_type_options, + label="Feed Type", + id="purchase_feed_type_code", + name="feed_type_code", + ), + # Bag size + LabelInput( + "Bag Size (kg)", + id="bag_size_kg", + name="bag_size_kg", + type="number", + min="1", + step="1", + value="20", + required=True, + ), + # Bags count + LabelInput( + "Number of Bags", + id="bags_count", + name="bags_count", + type="number", + min="1", + step="1", + value="1", + required=True, + ), + # Price per bag (cents) + LabelInput( + "Price per Bag (cents)", + id="bag_price_cents", + name="bag_price_cents", + type="number", + min="0", + step="1", + placeholder="e.g., 2400 for 24.00", + required=True, + ), + # Optional vendor + LabelInput( + "Vendor", + id="vendor", + name="vendor", + placeholder="Optional vendor name", + ), + # Optional notes + LabelTextArea( + "Notes", + id="purchase_notes", + name="notes", + placeholder="Optional notes", + ), + # Hidden nonce + Hidden(name="nonce", value=str(ULID())), + # Submit button + Button("Record Purchase", type="submit", cls=ButtonT.primary), + action="/actions/feed-purchased", + method="post", + cls="space-y-4", + ) diff --git a/tests/test_web_feed.py b/tests/test_web_feed.py new file mode 100644 index 0000000..1b3e116 --- /dev/null +++ b/tests/test_web_feed.py @@ -0,0 +1,362 @@ +# ABOUTME: Tests for Feed Quick Capture web routes. +# ABOUTME: Covers GET /feed form rendering and POST /actions/feed-given, /actions/feed-purchased. + +import os +import time + +import pytest +from starlette.testclient import TestClient + +from animaltrack.events.payloads import FeedPurchasedPayload +from animaltrack.events.store import EventStore +from animaltrack.projections import ProjectionRegistry +from animaltrack.projections.feed import FeedInventoryProjection +from animaltrack.services.feed import FeedService + + +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 feed_service(seeded_db): + """Create a FeedService for testing.""" + event_store = EventStore(seeded_db) + registry = ProjectionRegistry() + registry.register(FeedInventoryProjection(seeded_db)) + return FeedService(seeded_db, event_store, registry) + + +@pytest.fixture +def feed_purchase_in_db(seeded_db, feed_service): + """Create a feed purchase so give_feed can work.""" + payload = FeedPurchasedPayload( + feed_type_code="layer", + bag_size_kg=20, + bags_count=5, + bag_price_cents=2400, + ) + ts_utc = int(time.time() * 1000) - 86400000 # 1 day ago + feed_service.purchase_feed(payload, ts_utc, "test_user") + return payload + + +class TestFeedFormRendering: + """Tests for GET /feed form rendering.""" + + def test_feed_page_renders(self, client): + """GET /feed returns 200.""" + resp = client.get("/feed") + assert resp.status_code == 200 + + def test_feed_page_shows_tabs(self, client): + """Feed page shows both Give Feed and Purchase Feed tabs.""" + resp = client.get("/feed") + assert resp.status_code == 200 + assert "Give Feed" in resp.text or "give" in resp.text.lower() + assert "Purchase" in resp.text or "purchase" in resp.text.lower() + + def test_give_feed_form_has_fields(self, client): + """Give feed form has required fields.""" + resp = client.get("/feed") + assert resp.status_code == 200 + # Check for location, feed type, amount fields + assert 'name="location_id"' in resp.text or 'id="location_id"' in resp.text + assert 'name="feed_type_code"' in resp.text or 'id="feed_type_code"' in resp.text + assert 'name="amount_kg"' in resp.text or 'id="amount_kg"' in resp.text + + def test_locations_in_dropdown(self, client): + """Active locations appear in dropdown.""" + resp = client.get("/feed") + assert resp.status_code == 200 + assert "Strip 1" in resp.text + assert "Strip 2" in resp.text + + def test_feed_types_in_dropdown(self, client): + """Active feed types appear in dropdown.""" + resp = client.get("/feed") + assert resp.status_code == 200 + # Seeded feed types include 'layer' + assert "layer" in resp.text.lower() + + +class TestFeedGiven: + """Tests for POST /actions/feed-given.""" + + def test_give_feed_creates_event( + self, client, seeded_db, location_strip1_id, feed_purchase_in_db + ): + """POST creates FeedGiven event.""" + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "5", + "nonce": "test-nonce-feed-1", + }, + ) + + assert resp.status_code in [200, 302, 303] + + # Verify event was created in database + event_row = seeded_db.execute( + "SELECT type, payload FROM events WHERE type = 'FeedGiven' ORDER BY id DESC LIMIT 1" + ).fetchone() + assert event_row is not None + assert event_row[0] == "FeedGiven" + + def test_give_feed_validation_amount_zero( + self, client, location_strip1_id, feed_purchase_in_db + ): + """amount_kg=0 returns 422.""" + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "0", + "nonce": "test-nonce-feed-2", + }, + ) + assert resp.status_code == 422 + + def test_give_feed_validation_amount_negative( + self, client, location_strip1_id, feed_purchase_in_db + ): + """amount_kg=-1 returns 422.""" + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "-1", + "nonce": "test-nonce-feed-3", + }, + ) + assert resp.status_code == 422 + + def test_give_feed_validation_missing_location(self, client, feed_purchase_in_db): + """Missing location_id returns 422.""" + resp = client.post( + "/actions/feed-given", + data={ + "feed_type_code": "layer", + "amount_kg": "5", + "nonce": "test-nonce-feed-4", + }, + ) + assert resp.status_code == 422 + + def test_give_feed_validation_missing_feed_type( + self, client, location_strip1_id, feed_purchase_in_db + ): + """Missing feed_type_code returns 422.""" + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "amount_kg": "5", + "nonce": "test-nonce-feed-5", + }, + ) + assert resp.status_code == 422 + + def test_give_feed_blocked_without_purchase(self, client, location_strip1_id): + """Cannot give feed if no purchase exists for this feed type.""" + # Don't use feed_purchase_in_db fixture + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "5", + "nonce": "test-nonce-feed-6", + }, + ) + assert resp.status_code == 422 + assert "purchase" in resp.text.lower() + + def test_give_feed_location_sticks( + self, client, seeded_db, location_strip1_id, feed_purchase_in_db + ): + """After successful POST, returned form shows same location selected.""" + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "5", + "nonce": "test-nonce-feed-7", + }, + ) + assert resp.status_code == 200 + # The location should be pre-selected + assert "selected" in resp.text and location_strip1_id in resp.text + + def test_give_feed_type_sticks( + self, client, seeded_db, location_strip1_id, feed_purchase_in_db + ): + """After successful POST, returned form shows same feed type selected.""" + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "5", + "nonce": "test-nonce-feed-8", + }, + ) + assert resp.status_code == 200 + # The feed type should be pre-selected + assert "layer" in resp.text.lower() + + +class TestFeedPurchased: + """Tests for POST /actions/feed-purchased.""" + + def test_purchase_feed_creates_event(self, client, seeded_db): + """POST creates FeedPurchased event.""" + resp = client.post( + "/actions/feed-purchased", + data={ + "feed_type_code": "layer", + "bag_size_kg": "20", + "bags_count": "2", + "bag_price_cents": "2400", + "nonce": "test-nonce-purchase-1", + }, + ) + + assert resp.status_code in [200, 302, 303] + + # Verify event was created in database + event_row = seeded_db.execute( + "SELECT type, payload FROM events WHERE type = 'FeedPurchased' ORDER BY id DESC LIMIT 1" + ).fetchone() + assert event_row is not None + assert event_row[0] == "FeedPurchased" + + def test_purchase_feed_validation_bag_size_zero(self, client): + """bag_size_kg=0 returns 422.""" + resp = client.post( + "/actions/feed-purchased", + data={ + "feed_type_code": "layer", + "bag_size_kg": "0", + "bags_count": "2", + "bag_price_cents": "2400", + "nonce": "test-nonce-purchase-2", + }, + ) + assert resp.status_code == 422 + + def test_purchase_feed_validation_bags_count_zero(self, client): + """bags_count=0 returns 422.""" + resp = client.post( + "/actions/feed-purchased", + data={ + "feed_type_code": "layer", + "bag_size_kg": "20", + "bags_count": "0", + "bag_price_cents": "2400", + "nonce": "test-nonce-purchase-3", + }, + ) + assert resp.status_code == 422 + + def test_purchase_feed_validation_missing_feed_type(self, client): + """Missing feed_type_code returns 422.""" + resp = client.post( + "/actions/feed-purchased", + data={ + "bag_size_kg": "20", + "bags_count": "2", + "bag_price_cents": "2400", + "nonce": "test-nonce-purchase-4", + }, + ) + assert resp.status_code == 422 + + def test_purchase_enables_give(self, client, seeded_db, location_strip1_id): + """After purchasing, give feed works.""" + # First purchase + resp1 = client.post( + "/actions/feed-purchased", + data={ + "feed_type_code": "layer", + "bag_size_kg": "20", + "bags_count": "2", + "bag_price_cents": "2400", + "nonce": "test-nonce-purchase-5", + }, + ) + assert resp1.status_code in [200, 302, 303] + + # Then give should work + resp2 = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "5", + "nonce": "test-nonce-feed-after-purchase", + }, + ) + assert resp2.status_code in [200, 302, 303] + + +class TestInventoryWarning: + """Tests for inventory balance warnings.""" + + def test_negative_balance_shows_warning( + self, client, seeded_db, location_strip1_id, feed_purchase_in_db + ): + """Warning shown when give would result in negative balance.""" + # Purchase was 5 bags x 20kg = 100kg + # Give 150kg (more than available) - should show warning but allow + resp = client.post( + "/actions/feed-given", + data={ + "location_id": location_strip1_id, + "feed_type_code": "layer", + "amount_kg": "150", + "nonce": "test-nonce-warning-1", + }, + ) + # Should succeed but with warning + assert resp.status_code in [200, 302, 303] + # The response should contain a warning about negative inventory + assert "warning" in resp.text.lower() or "negative" in resp.text.lower()