# 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")