feat: implement product-sold route (Step 9.2)

Add POST /actions/product-sold route for recording product sales.

Changes:
- Create web/templates/products.py with product_sold_form
- Create web/routes/products.py with GET /sell and POST /actions/product-sold
- Wire up routes in __init__.py and app.py
- Add "Record Sale" link to Egg Quick Capture page
- Add comprehensive tests for form rendering and sale recording

The form allows selling any sellable product with quantity and price,
and calculates unit_price_cents using floor division. Defaults to
egg.duck product as per spec.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 14:16:12 +00:00
parent 943383a620
commit 0eef3ed7cb
7 changed files with 603 additions and 12 deletions

View File

@@ -0,0 +1,280 @@
# ABOUTME: Tests for Product Sold web routes.
# ABOUTME: Covers GET /sell form rendering and POST /actions/product-sold.
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)
class TestProductSoldFormRendering:
"""Tests for GET /sell product sold form."""
def test_sell_form_renders(self, client):
"""GET /sell returns 200 with form elements."""
resp = client.get("/sell")
assert resp.status_code == 200
assert "Record Sale" in resp.text or "Sell" in resp.text
def test_sell_form_shows_products(self, client):
"""Form has product dropdown with sellable products."""
resp = client.get("/sell")
assert resp.status_code == 200
# Check for seeded sellable product names
assert "Duck Egg" in resp.text or "egg.duck" in resp.text
def test_sell_form_has_quantity_field(self, client):
"""Form has quantity input field."""
resp = client.get("/sell")
assert resp.status_code == 200
assert 'name="quantity"' in resp.text or 'id="quantity"' in resp.text
def test_sell_form_has_total_price_field(self, client):
"""Form has total_price_cents input field."""
resp = client.get("/sell")
assert resp.status_code == 200
assert 'name="total_price_cents"' in resp.text or 'id="total_price_cents"' in resp.text
def test_sell_form_has_buyer_field(self, client):
"""Form has optional buyer input field."""
resp = client.get("/sell")
assert resp.status_code == 200
assert 'name="buyer"' in resp.text or 'id="buyer"' in resp.text
def test_sell_form_default_product_egg_duck(self, client):
"""Form defaults to egg.duck product (selected)."""
resp = client.get("/sell")
assert resp.status_code == 200
# egg.duck should be the selected option
# Check for either value="egg.duck" with selected, or selected before egg.duck
assert "egg.duck" in resp.text
class TestProductSold:
"""Tests for POST /actions/product-sold."""
def test_product_sold_creates_event(self, client, seeded_db):
"""POST creates ProductSold event with correct data."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "30",
"total_price_cents": "1500",
"buyer": "Local Market",
"notes": "Weekly sale",
"nonce": "test-nonce-sold-1",
},
)
# Should succeed (200 or redirect)
assert resp.status_code in [200, 302, 303]
# Verify event was created in database
event_row = seeded_db.execute(
"SELECT type, entity_refs FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
assert event_row[0] == "ProductSold"
def test_product_sold_unit_price_calculated(self, client, seeded_db):
"""Unit price is calculated as floor(total/qty)."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "30",
"total_price_cents": "1500",
"nonce": "test-nonce-sold-2",
},
)
assert resp.status_code in [200, 302, 303]
# Verify unit_price_cents in entity_refs
import json
event_row = seeded_db.execute(
"SELECT entity_refs FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1"
).fetchone()
entity_refs = json.loads(event_row[0])
assert entity_refs["unit_price_cents"] == 50 # 1500 / 30 = 50
def test_product_sold_unit_price_floor_division(self, client, seeded_db):
"""Unit price uses floor division (rounds down)."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "3",
"total_price_cents": "1000",
"nonce": "test-nonce-sold-3",
},
)
assert resp.status_code in [200, 302, 303]
import json
event_row = seeded_db.execute(
"SELECT entity_refs FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1"
).fetchone()
entity_refs = json.loads(event_row[0])
assert entity_refs["unit_price_cents"] == 333 # floor(1000 / 3) = 333
def test_product_sold_validation_quantity_zero(self, client):
"""quantity=0 returns 422."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "0",
"total_price_cents": "1000",
"nonce": "test-nonce-sold-4",
},
)
assert resp.status_code == 422
def test_product_sold_validation_quantity_negative(self, client):
"""quantity=-1 returns 422."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "-1",
"total_price_cents": "1000",
"nonce": "test-nonce-sold-5",
},
)
assert resp.status_code == 422
def test_product_sold_validation_price_negative(self, client):
"""Negative price returns 422."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "10",
"total_price_cents": "-100",
"nonce": "test-nonce-sold-6",
},
)
assert resp.status_code == 422
def test_product_sold_validation_missing_product(self, client):
"""Missing product_code returns 422."""
resp = client.post(
"/actions/product-sold",
data={
"quantity": "10",
"total_price_cents": "1000",
"nonce": "test-nonce-sold-7",
},
)
assert resp.status_code == 422
def test_product_sold_invalid_product(self, client):
"""Non-existent product returns 422."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "invalid.product",
"quantity": "10",
"total_price_cents": "1000",
"nonce": "test-nonce-sold-8",
},
)
assert resp.status_code == 422
def test_product_sold_success_shows_toast(self, client):
"""Successful sale returns response with toast trigger."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "12",
"total_price_cents": "600",
"nonce": "test-nonce-sold-9",
},
)
assert resp.status_code == 200
# Check for HX-Trigger header with showToast
hx_trigger = resp.headers.get("HX-Trigger")
assert hx_trigger is not None
assert "showToast" in hx_trigger
def test_product_sold_optional_buyer(self, client, seeded_db):
"""Buyer field is optional."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "10",
"total_price_cents": "500",
"nonce": "test-nonce-sold-10",
},
)
assert resp.status_code in [200, 302, 303]
# Event should still be created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
def test_product_sold_optional_notes(self, client, seeded_db):
"""Notes field is optional."""
resp = client.post(
"/actions/product-sold",
data={
"product_code": "egg.duck",
"quantity": "10",
"total_price_cents": "500",
"buyer": "Test Buyer",
"nonce": "test-nonce-sold-11",
},
)
assert resp.status_code in [200, 302, 303]
# Event should still be created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None