diff --git a/PLAN.md b/PLAN.md index bf4efef..ac1f4b8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -179,11 +179,11 @@ Check off items as completed. Each phase builds on the previous. - [x] **Commit checkpoint** (c08fa47) ### Step 4.5: Product Sold Event -- [ ] Implement apply_product_sold projection -- [ ] Add sell_product to services/products.py -- [ ] Calculate unit_price_cents = floor(total/qty) -- [ ] Write tests: event stored, unit price calculated -- [ ] **Commit checkpoint** +- [x] Implement apply_product_sold projection +- [x] Add sell_product to services/products.py +- [x] Calculate unit_price_cents = floor(total/qty) +- [x] Write tests: event stored, unit price calculated +- [x] **Commit checkpoint** --- diff --git a/src/animaltrack/projections/products.py b/src/animaltrack/projections/products.py index 598fc89..6b033f9 100644 --- a/src/animaltrack/projections/products.py +++ b/src/animaltrack/projections/products.py @@ -1,19 +1,18 @@ -# ABOUTME: Projection for product collection tracking. -# ABOUTME: Placeholder for ProductCollected events until stats tables exist. +# ABOUTME: Projection for product events (collected and sold). +# ABOUTME: Handles ProductCollected and ProductSold events. from typing import Any -from animaltrack.events.types import PRODUCT_COLLECTED +from animaltrack.events.types import PRODUCT_COLLECTED, PRODUCT_SOLD from animaltrack.models.events import Event from animaltrack.projections.base import Projection class ProductsProjection(Projection): - """Handles ProductCollected events. + """Handles ProductCollected and ProductSold events. - This is a placeholder projection for Step 4.3. - Product statistics tables are created in Step 4.4. - The event_animals linkage is handled by EventAnimalsProjection. + ProductCollected: event_animals linkage handled by EventAnimalsProjection. + ProductSold: currently a no-op (fast-revert projection per spec ยง14). """ def __init__(self, db: Any) -> None: @@ -26,18 +25,18 @@ class ProductsProjection(Projection): def get_event_types(self) -> list[str]: """Return the event types this projection handles.""" - return [PRODUCT_COLLECTED] + return [PRODUCT_COLLECTED, PRODUCT_SOLD] def apply(self, event: Event) -> None: - """Apply ProductCollected event. + """Apply product event. - Currently a no-op. Stats projection added in Step 4.4. + Currently a no-op for both event types. """ pass def revert(self, event: Event) -> None: - """Revert ProductCollected event. + """Revert product event. - Currently a no-op. Stats projection added in Step 4.4. + Currently a no-op for both event types. """ pass diff --git a/src/animaltrack/services/products.py b/src/animaltrack/services/products.py index d76fecd..138f5f1 100644 --- a/src/animaltrack/services/products.py +++ b/src/animaltrack/services/products.py @@ -4,10 +4,10 @@ from typing import Any from animaltrack.db import transaction -from animaltrack.events.payloads import ProductCollectedPayload +from animaltrack.events.payloads import ProductCollectedPayload, ProductSoldPayload from animaltrack.events.processor import process_event from animaltrack.events.store import EventStore -from animaltrack.events.types import PRODUCT_COLLECTED +from animaltrack.events.types import PRODUCT_COLLECTED, PRODUCT_SOLD from animaltrack.models.events import Event from animaltrack.projections import ProjectionRegistry from animaltrack.repositories.locations import LocationRepository @@ -128,3 +128,69 @@ class ProductService: process_event(event, self.registry) return event + + def sell_product( + self, + payload: ProductSoldPayload, + ts_utc: int, + actor: str, + nonce: str | None = None, + route: str | None = None, + ) -> Event: + """Record a product sale. + + Creates a ProductSold event with calculated unit_price_cents. + + Args: + payload: Validated product sold payload. + ts_utc: Timestamp in milliseconds since epoch. + actor: The user performing the sale. + nonce: Optional idempotency nonce. + route: Required if nonce provided. + + Returns: + The created event. + + Raises: + ValidationError: If product doesn't exist, is inactive, + or is not sellable. + """ + # Validate product exists and is active + product = self.product_repo.get(payload.product_code) + if product is None: + msg = f"Product '{payload.product_code}' not found" + raise ValidationError(msg) + + if not product.active: + msg = f"Product '{payload.product_code}' is inactive" + raise ValidationError(msg) + + if not product.sellable: + msg = f"Product '{payload.product_code}' is not sellable" + raise ValidationError(msg) + + # Calculate unit price using floor division + unit_price_cents = payload.total_price_cents // payload.quantity + + # Build entity_refs + entity_refs = { + "product_code": payload.product_code, + "quantity": payload.quantity, + "total_price_cents": payload.total_price_cents, + "unit_price_cents": unit_price_cents, + } + + with transaction(self.db): + event = self.event_store.append_event( + event_type=PRODUCT_SOLD, + ts_utc=ts_utc, + actor=actor, + entity_refs=entity_refs, + payload=payload.model_dump(), + nonce=nonce, + route=route, + ) + + process_event(event, self.registry) + + return event diff --git a/tests/test_service_products_sold.py b/tests/test_service_products_sold.py new file mode 100644 index 0000000..7a79864 --- /dev/null +++ b/tests/test_service_products_sold.py @@ -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")