feat: add product sold event handling

Add sell_product() service method that creates ProductSold events
with calculated unit_price_cents (floor division of total/qty).
Update ProductsProjection to handle PRODUCT_SOLD events.

🤖 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-29 09:40:34 +00:00
parent c82104b029
commit b48fab5dde
4 changed files with 284 additions and 19 deletions

View File

@@ -0,0 +1,200 @@
# ABOUTME: Tests for ProductService sell_product operations.
# ABOUTME: Tests sell_product with event creation and unit_price_cents calculation.
import time
import pytest
from animaltrack.events.payloads import ProductSoldPayload
from animaltrack.events.store import EventStore
from animaltrack.events.types import PRODUCT_SOLD
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections.products import ProductsProjection
from animaltrack.services.products import ProductService, ValidationError
@pytest.fixture
def event_store(seeded_db):
"""Create an EventStore for testing."""
return EventStore(seeded_db)
@pytest.fixture
def projection_registry(seeded_db):
"""Create a ProjectionRegistry with ProductsProjection."""
registry = ProjectionRegistry()
registry.register(ProductsProjection(seeded_db))
return registry
@pytest.fixture
def product_service(seeded_db, event_store, projection_registry):
"""Create a ProductService for testing."""
return ProductService(seeded_db, event_store, projection_registry)
def make_sell_payload(
product_code: str = "egg.duck",
quantity: int = 30,
total_price_cents: int = 1500,
buyer: str | None = None,
notes: str | None = None,
) -> ProductSoldPayload:
"""Create a product sold payload for testing."""
return ProductSoldPayload(
product_code=product_code,
quantity=quantity,
total_price_cents=total_price_cents,
buyer=buyer,
notes=notes,
)
# =============================================================================
# sell_product Tests
# =============================================================================
class TestProductServiceSell:
"""Tests for sell_product()."""
def test_creates_product_sold_event(self, seeded_db, product_service):
"""sell_product creates a ProductSold event."""
payload = make_sell_payload()
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.type == PRODUCT_SOLD
assert event.actor == "test_user"
assert event.ts_utc == ts_utc
def test_event_has_product_code_in_entity_refs(self, seeded_db, product_service):
"""Event entity_refs contains product_code."""
payload = make_sell_payload(product_code="egg.duck")
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.entity_refs["product_code"] == "egg.duck"
def test_event_has_quantity_in_entity_refs(self, seeded_db, product_service):
"""Event entity_refs contains quantity."""
payload = make_sell_payload(quantity=24)
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.entity_refs["quantity"] == 24
def test_event_has_total_price_cents_in_entity_refs(self, seeded_db, product_service):
"""Event entity_refs contains total_price_cents."""
payload = make_sell_payload(total_price_cents=2400)
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.entity_refs["total_price_cents"] == 2400
def test_event_has_unit_price_cents_in_entity_refs(self, seeded_db, product_service):
"""Event entity_refs contains calculated unit_price_cents."""
payload = make_sell_payload(quantity=30, total_price_cents=1500)
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
# 1500 / 30 = 50 cents per unit
assert event.entity_refs["unit_price_cents"] == 50
def test_unit_price_cents_floor_division(self, seeded_db, product_service):
"""unit_price_cents uses floor division (exact case)."""
# 1500 cents / 30 = 50.0 cents exactly
payload = make_sell_payload(quantity=30, total_price_cents=1500)
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.entity_refs["unit_price_cents"] == 50
def test_unit_price_cents_rounds_down(self, seeded_db, product_service):
"""unit_price_cents rounds down for fractional cents."""
# 1000 cents / 3 = 333.33... -> 333
payload = make_sell_payload(quantity=3, total_price_cents=1000)
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.entity_refs["unit_price_cents"] == 333
def test_unit_price_cents_another_fractional_case(self, seeded_db, product_service):
"""unit_price_cents correctly floors another fractional case."""
# 1001 cents / 3 = 333.66... -> 333
payload = make_sell_payload(quantity=3, total_price_cents=1001)
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.entity_refs["unit_price_cents"] == 333
def test_event_stored_in_events_table(self, seeded_db, product_service):
"""Event is stored in the events table."""
payload = make_sell_payload()
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
row = seeded_db.execute(
"SELECT id, type FROM events WHERE id = ?",
(event.id,),
).fetchone()
assert row is not None
assert row[0] == event.id
assert row[1] == PRODUCT_SOLD
def test_buyer_stored_in_payload(self, seeded_db, product_service):
"""Optional buyer is stored in event payload."""
payload = make_sell_payload(buyer="Local Market")
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.payload["buyer"] == "Local Market"
def test_notes_stored_in_payload(self, seeded_db, product_service):
"""Optional notes are stored in event payload."""
payload = make_sell_payload(notes="Cash sale")
ts_utc = int(time.time() * 1000)
event = product_service.sell_product(payload, ts_utc, "test_user")
assert event.payload["notes"] == "Cash sale"
class TestProductServiceSellValidation:
"""Tests for sell_product() validation."""
def test_rejects_nonexistent_product(self, seeded_db, product_service):
"""Raises ValidationError for non-existent product_code."""
payload = make_sell_payload(product_code="nonexistent.product")
with pytest.raises(ValidationError, match="not found"):
product_service.sell_product(payload, int(time.time() * 1000), "test_user")
def test_rejects_inactive_product(self, seeded_db, product_service):
"""Raises ValidationError for inactive product."""
seeded_db.execute("UPDATE products SET active = 0 WHERE code = 'egg.duck'")
payload = make_sell_payload(product_code="egg.duck")
with pytest.raises(ValidationError, match="inactive"):
product_service.sell_product(payload, int(time.time() * 1000), "test_user")
def test_rejects_non_sellable_product(self, seeded_db, product_service):
"""Raises ValidationError for non-sellable product."""
seeded_db.execute("UPDATE products SET sellable = 0 WHERE code = 'egg.duck'")
payload = make_sell_payload(product_code="egg.duck")
with pytest.raises(ValidationError, match="not sellable"):
product_service.sell_product(payload, int(time.time() * 1000), "test_user")