feat: implement Feed Quick Capture form (Step 7.4)

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 10:43:28 +00:00
parent 3ce694b15d
commit 68e1a59ec7
9 changed files with 1128 additions and 14 deletions

17
PLAN.md
View File

@@ -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

View File

@@ -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
]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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,
)

View File

@@ -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),

View File

@@ -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",
)

View File

@@ -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",
)

362
tests/test_web_feed.py Normal file
View File

@@ -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()