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:
280
tests/test_web_products_sold.py
Normal file
280
tests/test_web_products_sold.py
Normal 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
|
||||
Reference in New Issue
Block a user