All checks were successful
Deploy / deploy (push) Successful in 2m50s
The egg sale form had two issues: - Duplicate POST /actions/product-sold route in products.py was overwriting the eggs.py handler, causing incomplete page responses (no tabs, no recent sales list) - Price input used cents while feed purchase uses euros, inconsistent UX Changes: - Remove duplicate handler from products.py (keep only redirect) - Change sell form price input from cents to euros (consistent with feed) - Parse euros in handler, convert to cents for storage - Add TestEggSale class with 4 tests for the fixed behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
280 lines
9.2 KiB
Python
280 lines
9.2 KiB
Python
# 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_euros input field."""
|
|
resp = client.get("/sell")
|
|
assert resp.status_code == 200
|
|
assert 'name="total_price_euros"' in resp.text or 'id="total_price_euros"' 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_euros": "15.00",
|
|
"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_euros": "15.00",
|
|
"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_euros": "10.00",
|
|
"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_euros": "10.00",
|
|
"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_euros": "10.00",
|
|
"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_euros": "-1.00",
|
|
"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_euros": "10.00",
|
|
"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_euros": "10.00",
|
|
"nonce": "test-nonce-sold-8",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 422
|
|
|
|
def test_product_sold_success_returns_full_page(self, client):
|
|
"""Successful sale returns full eggs page with tabs."""
|
|
resp = client.post(
|
|
"/actions/product-sold",
|
|
data={
|
|
"product_code": "egg.duck",
|
|
"quantity": "12",
|
|
"total_price_euros": "6.00",
|
|
"nonce": "test-nonce-sold-9",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
# Should return full eggs page with tabs (toast via session)
|
|
assert "Harvest" in resp.text
|
|
assert "Sell" in resp.text
|
|
|
|
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_euros": "5.00",
|
|
"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_euros": "5.00",
|
|
"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
|