diff --git a/buzz/tests/factories/ticketing/buzz_coupon_code_factory.py b/buzz/tests/factories/ticketing/buzz_coupon_code_factory.py index 3ca68c31..166bc0d7 100644 --- a/buzz/tests/factories/ticketing/buzz_coupon_code_factory.py +++ b/buzz/tests/factories/ticketing/buzz_coupon_code_factory.py @@ -13,3 +13,26 @@ class BuzzCouponCodeFactory(BaseFactory[BuzzCouponCode]): @property def default_attributes(self) -> dict[str, Any]: return {"coupon_type": "Discount", "discount_type": "Percentage", "discount_value": 10} + + @property + def percentage(self) -> dict[str, Any]: + return {"coupon_type": "Discount", "discount_type": "Percentage"} + + @property + def flat(self) -> dict[str, Any]: + return {"coupon_type": "Discount", "discount_type": "Flat Amount"} + + @property + def free_tickets(self) -> dict[str, Any]: + """One free ticket. Pass `event` and its `ticket_type` together.""" + from buzz.tests.factories import BuzzEventFactory, EventTicketTypeFactory + + event = self.overrides.get("event") or BuzzEventFactory.create().name + ticket_type = self.overrides.get("ticket_type") or EventTicketTypeFactory.create(event=event).name + return { + "coupon_type": "Free Tickets", + "applies_to": "Event", + "event": event, + "ticket_type": ticket_type, + "number_of_free_tickets": 1, + } diff --git a/buzz/ticketing/doctype/buzz_coupon_code/coupon_test_case.py b/buzz/ticketing/doctype/buzz_coupon_code/coupon_test_case.py new file mode 100644 index 00000000..a87bb04f --- /dev/null +++ b/buzz/ticketing/doctype/buzz_coupon_code/coupon_test_case.py @@ -0,0 +1,51 @@ +from frappe.tests import IntegrationTestCase + +from buzz.api.booking.services import create_add_on_doc +from buzz.tests.factories import ( + BuzzEventFactory, + EventBookingFactory, + EventTicketTypeFactory, + TicketAddOnFactory, +) + +TICKET_PRICE = 500 +ADD_ON_PRICE = 200 + + +class CouponTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = BuzzEventFactory.create() + cls.ticket_type = create_paid_ticket_type(cls.event.name) + cls.add_on = TicketAddOnFactory.create(event=cls.event.name, price=ADD_ON_PRICE) + + def create_booking(self, coupon: str, count: int = 1, **overrides): + attendees = attendee_rows(self.ticket_type.name, count) + return EventBookingFactory.create( + event=self.event.name, coupon_code=coupon, attendees=attendees, **overrides + ) + + def create_booking_with_add_on(self, coupon: str, count: int = 1): + attendees = attendee_rows(self.ticket_type.name, count) + attendees[0]["add_ons"] = create_add_on_doc("", [{"add_on": self.add_on.name, "value": "XL"}]).name + return EventBookingFactory.create(event=self.event.name, coupon_code=coupon, attendees=attendees) + + def assert_amounts(self, booking, net: int, discount: int, total: int): + amounts = (booking.net_amount, booking.discount_amount, booking.total_amount) + self.assertEqual(amounts, (net, discount, total)) + + +def create_paid_ticket_type(event, price: int = TICKET_PRICE): + return EventTicketTypeFactory.create(event=event, prices=[{"currency": "INR", "price": price}]) + + +def attendee_rows(ticket_type, count: int) -> list[dict]: + return [ + { + "ticket_type": ticket_type, + "first_name": f"Attendee {index}", + "email": f"attendee{index}@example.com", + } + for index in range(count) + ] diff --git a/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py b/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py index 570d56a9..e882895d 100644 --- a/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py +++ b/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py @@ -2,1326 +2,121 @@ # See license.txt import frappe -from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today -from buzz.api.booking import validate_coupon as validate_coupon_endpoint +from buzz.api.booking import validate_coupon +from buzz.tests.factories import BuzzCouponCodeFactory, UserFactory +from buzz.ticketing.doctype.buzz_coupon_code.coupon_test_case import CouponTestCase -def validate_coupon(coupon_code, event, user_email=None): - return validate_coupon_endpoint(coupon_code, event, user_email).__json__() - - -EXTRA_TEST_RECORD_DEPENDENCIES = [] -IGNORE_TEST_RECORD_DEPENDENCIES = [] - - -class IntegrationTestBuzzCouponCode(IntegrationTestCase): - """ - Integration tests for BuzzCouponCode. - Use this class for testing interactions between multiple components. - """ - - def setUp(self): - """Set up test fixtures.""" - self.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.test_event.apply_tax = False - self.test_event.save() - - # Create test ticket type - self.test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": "Coupon Test Ticket", - "prices": [{"currency": "INR", "price": 500}], - "is_published": True, - } - ).insert() - - # Create test add-on - self.test_add_on = frappe.get_doc( - { - "doctype": "Ticket Add-on", - "event": self.test_event.name, - "title": f"Test T-Shirt {frappe.generate_hash(length=6)}", - "price": 200, - } - ).insert() - - # ==================== DISCOUNT COUPON TESTS ==================== - - def test_percentage_discount_applies_correctly(self): - """Test that percentage discount is calculated correctly.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 20, - "is_active": True, - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "John Doe", - "email": "john@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Jane Doe", - "email": "jane@test.com", - }, - ], - } - ).insert() - - # 2 tickets x 500 = 1000, 20% discount = 200 - self.assertEqual(booking.net_amount, 1000) - self.assertEqual(booking.discount_amount, 200) - self.assertEqual(booking.total_amount, 800) - - def test_flat_discount_applies_correctly(self): - """Test that flat amount discount is calculated correctly.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Flat Amount", - "discount_value": 300, - "is_active": True, - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "John Doe", - "email": "john@test.com", - }, - ], - } - ).insert() - - # 1 ticket x 500 = 500, flat 300 discount - self.assertEqual(booking.net_amount, 500) - self.assertEqual(booking.discount_amount, 300) - self.assertEqual(booking.total_amount, 200) - - def test_flat_discount_does_not_exceed_total(self): - """Test that flat discount is capped at net amount.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Flat Amount", - "discount_value": 1000, # More than ticket price - "is_active": True, - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "John Doe", - "email": "john@test.com", - }, - ], - } - ).insert() - - # Discount should be capped at 500 (net amount), not 1000 - self.assertEqual(booking.net_amount, 500) - self.assertEqual(booking.discount_amount, 500) - self.assertEqual(booking.total_amount, 0) +class TestCouponUsageLimits(CouponTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.booker = UserFactory.create_once("coupon-booker@example.com").name + cls.other_booker = UserFactory.create_once("coupon-other-booker@example.com").name def test_discount_coupon_usage_limit_enforced(self): - """Test that discount coupon usage limit is enforced.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "max_usage_count": 2, - "is_active": True, - } - ).insert() - - # First booking - should succeed - booking1 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User 1", - "email": "user1@test.com", - }, - ], - } - ).insert() - booking1.submit() + coupon = BuzzCouponCodeFactory.create("percentage", max_usage_count=2).name + self.create_booking(coupon).submit() + self.create_booking(coupon).submit() - # Second booking - should succeed - booking2 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User 2", - "email": "user2@test.com", - }, - ], - } - ).insert() - booking2.submit() - - # Third booking - should fail (usage limit reached) with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User 3", - "email": "user3@test.com", - }, - ], - } - ).insert() + self.create_booking(coupon) def test_discount_coupon_unlimited_usage(self): - """Test that coupon with max_usage_count=0 has unlimited usage.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 5, - "max_usage_count": 0, # Unlimited - "is_active": True, - } - ).insert() + coupon = BuzzCouponCodeFactory.create("percentage", max_usage_count=0) - # Create 5 bookings - all should succeed - for i in range(5): - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": f"User {i}", - "email": f"user{i}@test.com", - }, - ], - } - ).insert() - booking.submit() + for _ in range(5): + self.create_booking(coupon.name).submit() - # Verify all 5 bookings were created self.assertEqual(coupon.times_used, 5) - # ==================== MAX DISCOUNT CAP TESTS ==================== - - def test_percentage_discount_capped_at_max(self): - """Test that percentage discount is capped at maximum_discount_amount.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 50, - "maximum_discount_amount": 500, - "is_active": True, - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "John Doe", - "email": "john@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Jane Doe", - "email": "jane@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Bob Smith", - "email": "bob@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Alice Brown", - "email": "alice@test.com", - }, - ], - } - ).insert() - - # 4 tickets x 500 = 2000, 50% = 1000, but capped at 500 - self.assertEqual(booking.net_amount, 2000) - self.assertEqual(booking.discount_amount, 500) - self.assertEqual(booking.total_amount, 1500) - - # ==================== MINIMUM ORDER VALUE TESTS ==================== - - def test_min_order_value_enforced(self): - """Test that coupon is rejected when order is below minimum_order_value.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 20, - "minimum_order_value": 1000, - "is_active": True, - } - ).insert() - - # Order of 500 (1 ticket) is below minimum of 1000 - with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "John Doe", - "email": "john@test.com", - }, - ], - } - ).insert() - - def test_percentage_with_cap_and_min_order(self): - """Test coupon with both maximum_discount_amount and minimum_order_value.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 50, - "maximum_discount_amount": 300, - "minimum_order_value": 500, - "is_active": True, - } - ).insert() - - # Order of 500 (1 ticket) meets minimum, 50% = 250, below cap - booking1 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "John Doe", - "email": "john@test.com", - }, - ], - } - ).insert() - - self.assertEqual(booking1.net_amount, 500) - self.assertEqual(booking1.discount_amount, 250) - self.assertEqual(booking1.total_amount, 250) - - # Order of 1000 (2 tickets), 50% = 500, capped at 300 - booking2 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Jane Doe", - "email": "jane@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Bob Smith", - "email": "bob@test.com", - }, - ], - } - ).insert() - - self.assertEqual(booking2.net_amount, 1000) - self.assertEqual(booking2.discount_amount, 300) - self.assertEqual(booking2.total_amount, 700) - - # ==================== FREE TICKETS COUPON TESTS ==================== - - def test_free_tickets_applied_correctly(self): - """Test that free tickets coupon makes tickets free.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "Event", - "event": self.test_event.name, - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 2, - "is_active": True, - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Speaker 1", - "email": "speaker1@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Speaker 2", - "email": "speaker2@test.com", - }, - ], - } - ).insert() - - # Both tickets should be free - self.assertEqual(booking.net_amount, 1000) - self.assertEqual(booking.discount_amount, 1000) - self.assertEqual(booking.total_amount, 0) - - def test_partial_free_tickets(self): - """Test that only N tickets are free when booking more than free limit.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "Event", - "event": self.test_event.name, - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 2, - "is_active": True, - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Person 1", - "email": "person1@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Person 2", - "email": "person2@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Person 3", - "email": "person3@test.com", - }, - ], - } - ).insert() - - # 3 tickets x 500 = 1500, 2 free = 1000 discount - self.assertEqual(booking.net_amount, 1500) - self.assertEqual(booking.discount_amount, 1000) - self.assertEqual(booking.total_amount, 500) - - def test_partial_free_tickets_with_paid_addon(self): - """Test 1 free ticket + 2 attendees + paid add-on.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "Event", - "event": self.test_event.name, - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 1, - "is_active": True, - # No free_add_ons - T-Shirt should be paid - } - ).insert() - - # Create attendee add-on (paid, not in free list) - attendee_add_on = frappe.get_doc( - { - "doctype": "Attendee Ticket Add-on", - "add_ons": [{"add_on": self.test_add_on.name, "value": "XL"}], - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Person 1", - "email": "person1@test.com", - "add_ons": attendee_add_on.name, - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Person 2", - "email": "person2@test.com", - }, - ], - } - ).insert() - - # 2 tickets x 500 = 1000, 1 add-on x 200 = 200 - # Net = 1200 - # Only 1 ticket free = 500 discount - # Total = 700 (1 paid ticket + 1 paid add-on) - self.assertEqual(booking.net_amount, 1200) - self.assertEqual(booking.discount_amount, 500) - self.assertEqual(booking.total_amount, 700) - - def test_free_tickets_tracking_across_bookings(self): - """Test that free tickets are tracked across multiple bookings.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "Event", - "event": self.test_event.name, - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 5, - "is_active": True, - } - ).insert() - - # First booking: claim 2 free tickets - booking1 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User A1", - "email": "usera1@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User A2", - "email": "usera2@test.com", - }, - ], - } - ).insert() - booking1.submit() - - # Verify 2 claimed - self.assertEqual(coupon.free_tickets_claimed, 2) - - # Second booking: claim 2 more (4 total, 1 remaining) - booking2 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User B1", - "email": "userb1@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User B2", - "email": "userb2@test.com", - }, - ], - } - ).insert() - booking2.submit() - - # Verify 4 claimed - self.assertEqual(coupon.free_tickets_claimed, 4) - - # Third booking: try to claim 3, but only 1 remaining - booking3 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User C1", - "email": "userc1@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User C2", - "email": "userc2@test.com", - }, - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User C3", - "email": "userc3@test.com", - }, - ], - } - ).insert() - - # 3 tickets x 500 = 1500, only 1 free remaining = 500 discount - self.assertEqual(booking3.discount_amount, 500) - self.assertEqual(booking3.total_amount, 1000) - - def test_free_tickets_with_free_addons(self): - """Test that free add-ons are discounted for free ticket holders.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "Event", - "event": self.test_event.name, - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 1, - "free_add_ons": [{"add_on": self.test_add_on.name}], - "is_active": True, - } - ).insert() - - # Create attendee add-on - attendee_add_on = frappe.get_doc( - { - "doctype": "Attendee Ticket Add-on", - "add_ons": [{"add_on": self.test_add_on.name, "value": "XL"}], - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Speaker", - "email": "speaker@test.com", - "add_ons": attendee_add_on.name, - }, - ], - } - ).insert() - - # Ticket (500) + Add-on (200) = 700, both free - self.assertEqual(booking.net_amount, 700) - self.assertEqual(booking.discount_amount, 700) - self.assertEqual(booking.total_amount, 0) - - # ==================== VALIDATION TESTS ==================== - - def test_coupon_event_scope_validation(self): - """Test that coupon scoped to event rejects other events.""" - # Get category and host from test event - category = self.test_event.category - host = self.test_event.host - - other_event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Other Event", - "route": "other-event-test", - "start_date": "2025-12-31", - "start_time": "10:00:00", - "end_time": "18:00:00", - "category": category, - "host": host, - } - ).insert() - - other_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": other_event.name, - "title": "Other Ticket", - "prices": [{"currency": "INR", "price": 100}], - "is_published": True, - } - ).insert() - - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "applies_to": "Event", - "event": self.test_event.name, # Scoped to test_event - "is_active": True, - } - ).insert() - - # Try to use on other event - should fail - with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Event Booking", - "event": other_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": other_ticket_type.name, - "first_name": "Test User", - "email": "test@test.com", - }, - ], - } - ).insert() - - def test_coupon_category_scope_validation(self): - """Test that coupon scoped to event_category applies to events in that category.""" - # Create a different category - other_category = frappe.get_doc( - { - "doctype": "Event Category", - "name": "Other Test Category", - "slug": "other-test-category", - } - ).insert() - - # Create coupon scoped to test_event's category - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "applies_to": "Event Category", - "event_category": self.test_event.category, # Scoped to category - "is_active": True, - } - ).insert() - - # Should work for event in same category - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Test User", - "email": "test@test.com", - }, - ], - } - ).insert() - self.assertGreater(booking.discount_amount, 0) - - # Create event in different category - other_event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Other Category Event", - "route": "other-category-event-test", - "start_date": "2025-12-31", - "start_time": "10:00:00", - "end_time": "18:00:00", - "category": other_category.name, - "host": self.test_event.host, - } - ).insert() - - other_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": other_event.name, - "title": "Other Category Ticket", - "prices": [{"currency": "INR", "price": 100}], - "is_published": True, - } - ).insert() - - # Should fail for event in different category - with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Event Booking", - "event": other_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": other_ticket_type.name, - "first_name": "Test User", - "email": "test@test.com", - }, - ], - } - ).insert() - - def test_coupon_global_scope(self): - """Test that coupon with no event/event_category applies to all events.""" - # Create coupon with no event or category (global scope) - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 15, - "is_active": True, - # No event or event_category - should apply globally - } - ).insert() - - # Should work for test_event - booking1 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "User 1", - "email": "user1@test.com", - }, - ], - } - ).insert() - self.assertGreater(booking1.discount_amount, 0) - - # Create another event - other_event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Another Event", - "route": "another-event-global-test", - "start_date": "2025-12-31", - "start_time": "10:00:00", - "end_time": "18:00:00", - "category": self.test_event.category, - "host": self.test_event.host, - } - ).insert() - - other_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": other_event.name, - "title": "Another Event Ticket", - "prices": [{"currency": "INR", "price": 200}], - "is_published": True, - } - ).insert() - - # Should also work for other event (global scope) - booking2 = frappe.get_doc( - { - "doctype": "Event Booking", - "event": other_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": other_ticket_type.name, - "first_name": "User 2", - "email": "user2@test.com", - }, - ], - } - ).insert() - self.assertGreater(booking2.discount_amount, 0) - def test_inactive_coupon_rejected(self): - """Test that inactive coupon is rejected.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "is_active": False, # Inactive - } - ).insert() - - with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Test User", - "email": "test@test.com", - }, - ], - } - ).insert() - - def test_free_tickets_requires_event(self): - """Test that Free Tickets coupon requires event.""" - with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "Event", - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 5, - "is_active": True, - # No event - should fail - } - ).insert() - - def test_free_tickets_requires_specific_event_restriction(self): - """Test that Free Tickets coupon requires applies_to='Event'.""" - with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "Event Category", - "event_category": self.test_event.category, - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 2, - "is_active": True, - } - ).insert() + coupon = BuzzCouponCodeFactory.create("percentage", is_active=0).name - def test_free_tickets_rejects_all_events(self): - """Test that Free Tickets coupon cannot use applies_to=''.""" with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Free Tickets", - "applies_to": "", - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 2, - "is_active": True, - } - ).insert() + self.create_booking(coupon) - def test_specific_event_clears_event_category(self): - """Test that applies_to='Event' clears event_category field.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "applies_to": "Event", - "event": self.test_event.name, - "event_category": self.test_event.category, # Should be cleared - "is_active": True, - } - ).insert() - - self.assertIsNone(coupon.event_category) - - def test_event_category_clears_event(self): - """Test that applies_to='Event Category' clears event field.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "applies_to": "Event Category", - "event": self.test_event.name, # Should be cleared - "event_category": self.test_event.category, - "is_active": True, - } - ).insert() - - self.assertIsNone(coupon.event) - - def test_all_events_clears_both_scope_fields(self): - """Test that applies_to='' clears both event and event_category.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "applies_to": "", - "event": self.test_event.name, # Should be cleared - "event_category": self.test_event.category, # Should be cleared - "is_active": True, - } - ).insert() - - self.assertIsNone(coupon.event) - self.assertIsNone(coupon.event_category) - - def test_percentage_discount_cannot_exceed_100(self): - """Test that percentage discount cannot exceed 100%.""" - with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 150, # More than 100% - "is_active": True, - } - ).insert() - - def test_buzz_coupon_code_auto_generated(self): - """Test that coupon code is auto-generated if not provided.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "is_active": True, - # No code provided - } - ).insert() - - self.assertIsNotNone(coupon.code) - self.assertEqual(len(coupon.code), 8) - - def test_coupon_tracked_in_booking(self): - """Test that coupon code is tracked in booking document.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "TESTTRACK", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "is_active": True, - } - ).insert() + def test_max_usage_per_user_enforced(self): + coupon = BuzzCouponCodeFactory.create("percentage", max_usage_per_user=1).name + self.create_booking(coupon, user=self.booker).submit() - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Test User", - "email": "test@test.com", - }, - ], - } - ).insert() + with self.set_user(self.booker): + response = validate_coupon_payload(coupon, self.event.name) - # Coupon should be tracked in booking - self.assertEqual(booking.coupon_code, "TESTTRACK") + self.assertFalse(response["valid"]) + self.assertIn("maximum usage limit", response["error"].lower()) + def test_per_user_limit_does_not_affect_other_users(self): + coupon = BuzzCouponCodeFactory.create("percentage", max_usage_per_user=1).name + self.create_booking(coupon, user=self.booker).submit() -class TestValidateCouponAPI(IntegrationTestCase): - """Test the validate_coupon API endpoint.""" + with self.set_user(self.other_booker): + self.assertTrue(validate_coupon_payload(coupon, self.event.name)["valid"]) - def setUp(self): - self.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": "API Test Ticket", - "prices": [{"currency": "INR", "price": 100}], - "is_published": True, - } - ).insert() +class TestValidateCouponAPI(CouponTestCase): def test_validate_coupon_returns_discount_info(self): - """Test that validate_coupon returns correct discount info.""" - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "APITEST", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 25, - "is_active": True, - } - ).insert() + coupon = BuzzCouponCodeFactory.create("percentage", discount_value=25).name - result = validate_coupon("APITEST", str(self.test_event.name)) + response = validate_coupon_payload(coupon, self.event.name) - self.assertTrue(result["valid"]) - self.assertEqual(result["coupon_type"], "Discount") - self.assertEqual(result["discount_type"], "Percentage") - self.assertEqual(result["discount_value"], 25) + self.assertTrue(response["valid"]) + self.assertEqual( + (response["coupon_type"], response["discount_type"], response["discount_value"]), + ("Discount", "Percentage", 25), + ) def test_validate_coupon_returns_max_and_min_values(self): - """Test that validate_coupon returns max_discount_amount and min_order_value.""" - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "MAXMIN", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 30, - "maximum_discount_amount": 500, - "minimum_order_value": 200, - "is_active": True, - } - ).insert() + coupon = BuzzCouponCodeFactory.create( + "percentage", discount_value=30, maximum_discount_amount=500, minimum_order_value=200 + ).name - result = validate_coupon("MAXMIN", str(self.test_event.name)) + response = validate_coupon_payload(coupon, self.event.name) - self.assertTrue(result["valid"]) - self.assertEqual(result["max_discount_amount"], 500) - self.assertEqual(result["min_order_value"], 200) + self.assertTrue(response["valid"]) + self.assertEqual((response["max_discount_amount"], response["min_order_value"]), (500, 200)) def test_validate_coupon_returns_free_tickets_info(self): - """Test that validate_coupon returns correct free tickets info.""" - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "FREEAPI", - "coupon_type": "Free Tickets", - "applies_to": "Event", - "event": self.test_event.name, - "ticket_type": self.test_ticket_type.name, - "number_of_free_tickets": 3, - "is_active": True, - } - ).insert() + coupon = BuzzCouponCodeFactory.create( + "free_tickets", event=self.event.name, ticket_type=self.ticket_type.name, number_of_free_tickets=3 + ).name - result = validate_coupon("FREEAPI", str(self.test_event.name)) + response = validate_coupon_payload(coupon, self.event.name) - self.assertTrue(result["valid"]) - self.assertEqual(result["coupon_type"], "Free Tickets") - self.assertEqual(result["remaining_tickets"], 3) + self.assertTrue(response["valid"]) + self.assertEqual((response["coupon_type"], response["remaining_tickets"]), ("Free Tickets", 3)) def test_validate_coupon_invalid_code(self): - """Test that invalid coupon code returns error.""" - result = validate_coupon("INVALIDCODE", str(self.test_event.name)) - - self.assertFalse(result["valid"]) - self.assertIn("error", result) + response = validate_coupon_payload("INVALIDCODE", self.event.name) - # ==================== VALIDITY PERIOD TESTS ==================== + self.assertFalse(response["valid"]) + self.assertIn("error", response) def test_coupon_not_yet_active(self): - """Test that coupon with future valid_from is rejected.""" - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "FUTURESTART", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "valid_from": frappe.utils.add_days(frappe.utils.today(), 1), - "is_active": True, - } - ).insert() + coupon = BuzzCouponCodeFactory.create("percentage", valid_from=add_days(today(), 1)).name - result = validate_coupon("FUTURESTART", str(self.test_event.name)) + response = validate_coupon_payload(coupon, self.event.name) - self.assertFalse(result["valid"]) - self.assertIn("not yet active", result["error"].lower()) + self.assertFalse(response["valid"]) + self.assertIn("not yet active", response["error"].lower()) def test_expired_coupon_rejected(self): - """Test that expired coupon is rejected.""" - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "EXPIRED", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "valid_till": frappe.utils.add_days(frappe.utils.today(), -1), - "is_active": True, - } - ).insert() + coupon = BuzzCouponCodeFactory.create("percentage", valid_till=add_days(today(), -1)).name - result = validate_coupon("EXPIRED", str(self.test_event.name)) + response = validate_coupon_payload(coupon, self.event.name) - self.assertFalse(result["valid"]) - self.assertIn("expired", result["error"].lower()) + self.assertFalse(response["valid"]) + self.assertIn("expired", response["error"].lower()) def test_coupon_within_validity_period(self): - """Test that coupon within valid date range works.""" - frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "VALIDPERIOD", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "valid_from": frappe.utils.add_days(frappe.utils.today(), -1), - "valid_till": frappe.utils.add_days(frappe.utils.today(), 7), - "is_active": True, - } - ).insert() - - result = validate_coupon("VALIDPERIOD", str(self.test_event.name)) - - self.assertTrue(result["valid"]) - - # ==================== PER-USER LIMIT TESTS ==================== - - def test_max_usage_per_user_enforced(self): - """Test that user cannot exceed per-user usage limit.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "PERUSER", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "max_usage_per_user": 1, - "is_active": True, - } - ).insert() - - # Create and submit a booking with this coupon - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": frappe.session.user, - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Test User", - "email": "test@test.com", - }, - ], - } - ).insert() - booking.submit() - - # Second attempt should fail - result = validate_coupon("PERUSER", str(self.test_event.name)) - - self.assertFalse(result["valid"]) - self.assertIn("maximum usage limit", result["error"].lower()) - - def test_per_user_limit_does_not_affect_other_users(self): - """Test that per-user limit doesn't block other users.""" - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": "PERUSEROTHER", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "max_usage_per_user": 1, - "is_active": True, - } - ).insert() - - # Create a test user if not exists - if not frappe.db.exists("User", "testuser_b@example.com"): - frappe.get_doc( - { - "doctype": "User", - "email": "testuser_b@example.com", - "first_name": "Test User B", - "send_welcome_email": 0, - } - ).insert(ignore_permissions=True) - - # User A (Administrator) uses the coupon - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - { - "ticket_type": self.test_ticket_type.name, - "first_name": "Admin User", - "email": "admin@test.com", - }, - ], - } - ).insert() - booking.submit() - - # Switch to User B - frappe.set_user("testuser_b@example.com") + coupon = BuzzCouponCodeFactory.create( + "percentage", valid_from=add_days(today(), -1), valid_till=add_days(today(), 7) + ).name - # User B should still be able to use the coupon - result = validate_coupon("PERUSEROTHER", str(self.test_event.name)) + self.assertTrue(validate_coupon_payload(coupon, self.event.name)["valid"]) - # Switch back to Administrator - frappe.set_user("Administrator") - self.assertTrue(result["valid"]) +def validate_coupon_payload(coupon: str, event) -> dict: + return validate_coupon(coupon, str(event)).__json__() diff --git a/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_discounts.py b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_discounts.py new file mode 100644 index 00000000..2cbc1f02 --- /dev/null +++ b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_discounts.py @@ -0,0 +1,54 @@ +import frappe + +from buzz.tests.factories import BuzzCouponCodeFactory +from buzz.ticketing.doctype.buzz_coupon_code.coupon_test_case import CouponTestCase + + +class TestDiscountCoupon(CouponTestCase): + def test_percentage_discount_applies_correctly(self): + coupon = BuzzCouponCodeFactory.create("percentage", discount_value=20).name + + booking = self.create_booking(coupon, count=2) + + self.assert_amounts(booking, net=1000, discount=200, total=800) + + def test_flat_discount_applies_correctly(self): + coupon = BuzzCouponCodeFactory.create("flat", discount_value=300).name + + self.assert_amounts(self.create_booking(coupon), net=500, discount=300, total=200) + + def test_flat_discount_does_not_exceed_total(self): + coupon = BuzzCouponCodeFactory.create("flat", discount_value=1000).name + + self.assert_amounts(self.create_booking(coupon), net=500, discount=500, total=0) + + def test_percentage_discount_capped_at_max(self): + coupon = BuzzCouponCodeFactory.create("percentage", discount_value=50, maximum_discount_amount=500) + + booking = self.create_booking(coupon.name, count=4) + + self.assert_amounts(booking, net=2000, discount=500, total=1500) + + def test_min_order_value_enforced(self): + coupon = BuzzCouponCodeFactory.create("percentage", discount_value=20, minimum_order_value=1000) + + with self.assertRaises(frappe.ValidationError): + self.create_booking(coupon.name) + + def test_percentage_with_cap_and_min_order(self): + coupon = BuzzCouponCodeFactory.create( + "percentage", discount_value=50, maximum_discount_amount=300, minimum_order_value=500 + ).name + + self.assert_amounts(self.create_booking(coupon, count=1), net=500, discount=250, total=250) + self.assert_amounts(self.create_booking(coupon, count=2), net=1000, discount=300, total=700) + + def test_percentage_discount_cannot_exceed_100(self): + with self.assertRaises(frappe.ValidationError): + BuzzCouponCodeFactory.create("percentage", discount_value=150) + + def test_coupon_tracked_in_booking(self): + code = f"TRACK{frappe.generate_hash(length=6).upper()}" + BuzzCouponCodeFactory.create("percentage", code=code) + + self.assertEqual(self.create_booking(code).coupon_code, code) diff --git a/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_free_tickets.py b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_free_tickets.py new file mode 100644 index 00000000..886182ac --- /dev/null +++ b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_free_tickets.py @@ -0,0 +1,58 @@ +import frappe + +from buzz.tests.factories import BuzzCouponCodeFactory +from buzz.ticketing.doctype.buzz_coupon_code.coupon_test_case import CouponTestCase + + +class TestFreeTicketsCoupon(CouponTestCase): + def test_free_tickets_applied_correctly(self): + coupon = self.create_coupon(number_of_free_tickets=2) + + self.assert_amounts(self.create_booking(coupon, count=2), net=1000, discount=1000, total=0) + + def test_partial_free_tickets(self): + coupon = self.create_coupon(number_of_free_tickets=2) + + self.assert_amounts(self.create_booking(coupon, count=3), net=1500, discount=1000, total=500) + + def test_partial_free_tickets_with_paid_addon(self): + coupon = self.create_coupon(number_of_free_tickets=1) + + booking = self.create_booking_with_add_on(coupon, count=2) + + self.assert_amounts(booking, net=1200, discount=500, total=700) + + def test_free_tickets_tracking_across_bookings(self): + coupon = self.create_coupon(number_of_free_tickets=5) + + self.create_booking(coupon, count=2).submit() + self.assertEqual(self.free_tickets_claimed(coupon), 2) + self.create_booking(coupon, count=2).submit() + self.assertEqual(self.free_tickets_claimed(coupon), 4) + + booking = self.create_booking(coupon, count=3) + self.assertEqual((booking.discount_amount, booking.total_amount), (500, 1000)) + + def test_free_tickets_with_free_addons(self): + coupon = self.create_coupon(number_of_free_tickets=1, free_add_ons=[{"add_on": self.add_on.name}]) + + self.assert_amounts(self.create_booking_with_add_on(coupon), net=700, discount=700, total=0) + + def test_free_tickets_requires_event(self): + with self.assertRaises(frappe.ValidationError): + self.create_coupon(event=None) + + def test_free_tickets_requires_specific_event_restriction(self): + with self.assertRaises(frappe.ValidationError): + self.create_coupon(applies_to="Event Category", event_category=self.event.category) + + def test_free_tickets_rejects_all_events(self): + with self.assertRaises(frappe.ValidationError): + self.create_coupon(applies_to="") + + def create_coupon(self, **overrides) -> str: + values = {"event": self.event.name, "ticket_type": self.ticket_type.name, **overrides} + return BuzzCouponCodeFactory.create("free_tickets", **values).name + + def free_tickets_claimed(self, coupon: str) -> int: + return frappe.get_doc("Buzz Coupon Code", coupon).free_tickets_claimed diff --git a/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_scope.py b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_scope.py new file mode 100644 index 00000000..9b6cc6df --- /dev/null +++ b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_scope.py @@ -0,0 +1,65 @@ +import frappe + +from buzz.tests.factories import BuzzCouponCodeFactory, BuzzEventFactory, EventBookingFactory +from buzz.ticketing.doctype.buzz_coupon_code.coupon_test_case import ( + CouponTestCase, + attendee_rows, + create_paid_ticket_type, +) + + +class TestCouponScope(CouponTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # A new event comes with a new category, so it is outside both scopes of `event`. + cls.other_event = BuzzEventFactory.create() + cls.other_ticket_type = create_paid_ticket_type(cls.other_event.name, price=100) + + def test_coupon_event_scope_validation(self): + coupon = self.create_coupon(applies_to="Event", event=self.event.name) + + with self.assertRaises(frappe.ValidationError): + self.create_other_event_booking(coupon) + + def test_coupon_category_scope_validation(self): + coupon = self.create_coupon(applies_to="Event Category", event_category=self.event.category) + + self.assert_amounts(self.create_booking(coupon), net=500, discount=50, total=450) + with self.assertRaises(frappe.ValidationError): + self.create_other_event_booking(coupon) + + def test_coupon_global_scope(self): + coupon = self.create_coupon() + + self.assertEqual(self.create_booking(coupon).discount_amount, 50) + self.assertEqual(self.create_other_event_booking(coupon).discount_amount, 10) + + def test_specific_event_clears_event_category(self): + coupon = self.create_coupon_with_both_scopes("Event") + + self.assertIsNone(coupon.event_category) + + def test_event_category_clears_event(self): + coupon = self.create_coupon_with_both_scopes("Event Category") + + self.assertIsNone(coupon.event) + + def test_all_events_clears_both_scope_fields(self): + coupon = self.create_coupon_with_both_scopes("") + + self.assertEqual((coupon.event, coupon.event_category), (None, None)) + + def create_coupon(self, **overrides) -> str: + return BuzzCouponCodeFactory.create("percentage", discount_value=10, **overrides).name + + def create_coupon_with_both_scopes(self, applies_to: str): + return BuzzCouponCodeFactory.create( + applies_to=applies_to, event=self.event.name, event_category=self.event.category + ) + + def create_other_event_booking(self, coupon: str): + attendees = attendee_rows(self.other_ticket_type.name, 1) + return EventBookingFactory.create( + event=self.other_event.name, coupon_code=coupon, attendees=attendees + ) diff --git a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py index c1ad978c..e3eb33cd 100644 --- a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py @@ -6,243 +6,109 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.events.doctype.buzz_team.test_buzz_team import create_user -from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import set_team_settings -from buzz.utils import generate_qr_code_file, make_qr_image, render_email_template +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + EmailTemplateFactory, + EventTicketFactory, + UserFactory, +) +from buzz.utils import generate_qr_code_file, render_email_template -EXTRA_TEST_RECORD_DEPENDENCIES = [] -IGNORE_TEST_RECORD_DEPENDENCIES = ["Bulk Ticket Coupon"] +MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" +WEBINAR_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar" class TestEventTicketEmail(IntegrationTestCase): - """Tests for Event Ticket email sending with template fallback logic.""" - @classmethod def setUpClass(cls): super().setUpClass() - cls.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - cls.test_event.ticket_email_template = None - cls.test_event.save() - - set_team_settings(cls.test_event.team, default_ticket_email_template=None) + cls.event = BuzzEventFactory.create() + cls.event_template = EmailTemplateFactory.create(subject="Event ticket template").name + cls.team_template = EmailTemplateFactory.create(subject="Team ticket template").name def setUp(self): - # The rollback restores the settings row but not its cached copy. - self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.test_event.team) - self.test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": "Email Test Ticket", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() - - self.test_ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "event": self.test_event.name, - "ticket_type": self.test_ticket_type.name, - "attendee_name": "Test Attendee", - "attendee_email": "test@example.com", - } - ).insert() - - def tearDown(self): - frappe.delete_doc("Event Ticket", self.test_ticket.name, force=True) - frappe.delete_doc("Event Ticket Type", self.test_ticket_type.name, force=True) - - def set_team_default(self, template: str | None): - set_team_settings(self.test_event.team, default_ticket_email_template=template) - - def _create_template(self, name, subject_prefix): - if frappe.db.exists("Email Template", name): - frappe.delete_doc("Email Template", name, force=True) - return frappe.get_doc( - { - "doctype": "Email Template", - "name": name, - "subject": f"{subject_prefix} - {{{{ event_title }}}}", - "response": f"

{subject_prefix} content

", - } - ).insert() + self.set_templates(event_template=None, team_template=None) + self.ticket = EventTicketFactory.create(event=self.event.name) @patch("frappe.sendmail") def test_uses_event_template_when_set(self, mock_sendmail): - template = self._create_template("Event Ticket Template", "EVENT") - try: - self.test_event.ticket_email_template = template.name - self.test_event.save() + self.set_templates(event_template=self.event_template, team_template=None) - self.test_ticket.send_ticket_email(now=True) + self.ticket.send_ticket_email(now=True) - mock_sendmail.assert_called_once() - self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"]) - finally: - self.test_event.ticket_email_template = None - self.test_event.save() - frappe.delete_doc("Email Template", template.name, force=True) + mock_sendmail.assert_called_once() + self.assertEqual(mock_sendmail.call_args[1]["subject"], "Event ticket template") @patch("frappe.sendmail") def test_falls_back_to_the_teams_default_template(self, mock_sendmail): - template = self._create_template("Team Ticket Template", "TEAM") - try: - self.test_event.ticket_email_template = None - self.test_event.save() + self.set_templates(event_template=None, team_template=self.team_template) - self.set_team_default(template.name) + self.ticket.send_ticket_email(now=True) - self.test_ticket.send_ticket_email(now=True) - - mock_sendmail.assert_called_once() - self.assertIn("TEAM", mock_sendmail.call_args[1]["subject"]) - finally: - self.set_team_default(None) - frappe.delete_doc("Email Template", template.name, force=True) + mock_sendmail.assert_called_once() + self.assertEqual(mock_sendmail.call_args[1]["subject"], "Team ticket template") @patch("frappe.sendmail") def test_event_template_takes_precedence(self, mock_sendmail): - event_template = self._create_template("Event Template", "EVENT") - team_template = self._create_template("Team Template", "TEAM") - try: - self.test_event.ticket_email_template = event_template.name - self.test_event.save() - - self.set_team_default(team_template.name) - - self.test_ticket.send_ticket_email(now=True) - - mock_sendmail.assert_called_once() - self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"]) - self.assertNotIn("TEAM", mock_sendmail.call_args[1]["subject"]) - finally: - self.test_event.ticket_email_template = None - self.test_event.save() - self.set_team_default(None) - frappe.delete_doc("Email Template", event_template.name, force=True) - frappe.delete_doc("Email Template", team_template.name, force=True) + self.set_templates(event_template=self.event_template, team_template=self.team_template) - @patch("frappe.sendmail") - def test_uses_inline_template_when_none_configured(self, mock_sendmail): - self.test_event.ticket_email_template = None - self.test_event.save() + self.ticket.send_ticket_email(now=True) - self.set_team_default(None) + mock_sendmail.assert_called_once() + self.assertEqual(mock_sendmail.call_args[1]["subject"], "Event ticket template") - self.test_ticket.send_ticket_email(now=True) + @patch("frappe.sendmail") + def test_uses_inline_template_when_none_configured(self, mock_sendmail): + self.ticket.send_ticket_email(now=True) mock_sendmail.assert_called_once() self.assertEqual(mock_sendmail.call_args[1]["template"], "ticket") @patch("frappe.sendmail") def test_support_email_reaches_the_template_from_the_team(self, mock_sendmail): - set_team_settings(self.test_event.team, support_email="team-support@example.com") - self.addCleanup(set_team_settings, self.test_event.team, support_email=None) + BuzzTeamFactory.set_settings(self.event.team, {"support_email": "team-support@example.com"}) + self.addCleanup(BuzzTeamFactory.set_settings, self.event.team, {"support_email": None}) - self.test_ticket.send_ticket_email(now=True) + self.ticket.send_ticket_email(now=True) self.assertEqual(mock_sendmail.call_args[1]["args"]["support_email"], "team-support@example.com") + def set_templates(self, event_template: str | None, team_template: str | None): + frappe.db.set_value("Buzz Event", self.event.name, "ticket_email_template", event_template) + frappe.clear_document_cache("Buzz Event", self.event.name) + BuzzTeamFactory.set_settings(self.event.team, {"default_ticket_email_template": team_template}) -class TestQRCodeGeneration(IntegrationTestCase): - """Tests for QR code generation utility.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - def test_make_qr_image_returns_png_bytes(self): - """QR image generation should return valid PNG bytes.""" - result = make_qr_image("test-data-123") - - self.assertIsInstance(result, bytes) - # PNG magic bytes - self.assertTrue(result.startswith(b"\x89PNG")) +class TestQRCodeGeneration(IntegrationTestCase): def test_generate_qr_code_file_creates_attachment(self): - """QR code file should be created and attached to document.""" - file_url = generate_qr_code_file( - doc=self.test_event, - data="test-qr-data", - field_name="qr_code", - file_prefix="test-qr", - ) + event = BuzzEventFactory.create() - self.assertIsNotNone(file_url) - self.assertTrue(file_url.endswith(".png")) + file_url = generate_qr_code_file(doc=event, data="test-qr-data", file_prefix="test-qr") - # Verify file exists in File doctype - file_doc = frappe.get_doc("File", {"file_url": file_url}) - self.assertEqual(file_doc.attached_to_doctype, "Buzz Event") - self.assertEqual(str(file_doc.attached_to_name), str(self.test_event.name)) - - # Cleanup - file_doc.delete() + self.assertTrue(file_url.endswith(".png")) + qr_file = frappe.get_doc("File", {"file_url": file_url}) + self.addCleanup(qr_file.delete) + self.assertEqual(qr_file.attached_to_doctype, "Buzz Event") + self.assertEqual(qr_file.attached_to_name, str(event.name)) class TestEventTicketZoomMeeting(IntegrationTestCase): def setUp(self): - # tearDown rolls back, so the fixtures are rebuilt per test rather than per class. - super().setUp() - self.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "title": "Meeting TT", - "event": self.event.name, - "prices": [{"currency": "USD", "price": 0}], - } - ).insert(ignore_permissions=True, ignore_if_duplicate=True) - - def tearDown(self): - frappe.db.rollback() - - def _submit_ticket(self, email="alice@example.com"): - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "event": self.event.name, - "ticket_type": self.ticket_type.name, - "first_name": "Alice", - "last_name": "Smith", - "attendee_email": email, - } - ).insert(ignore_permissions=True) - ticket.submit() - return ticket + # Each test links its own Zoom session, so each needs its own event. + self.event = BuzzEventFactory.create() def test_ticket_registration_points_at_the_events_zoom_meeting(self): - from zoom_integration.tests.zoom_fixtures import ( - add_meeting_registrant_response, - create_meeting_response, - ) + from zoom_integration.tests.zoom_fixtures import add_meeting_registrant_response - meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" - - with patch(f"{meeting_controller}.create_zoom_session", return_value=create_meeting_response()): - meeting = frappe.get_doc( - { - "doctype": "Zoom Meeting", - "title": "Ticket Meeting", - "date": "2026-08-01", - "start_time": "10:00:00", - "duration": 3600, - "timezone": "Asia/Calcutta", - } - ).insert(ignore_permissions=True) - - self.event.db_set("zoom_meeting", meeting.name) + meeting = self.create_zoom_meeting() registrant = add_meeting_registrant_response() - with patch(f"{meeting_controller}.add_zoom_registrant", return_value=registrant): - ticket = self._submit_ticket() + with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=registrant): + ticket = self.submit_ticket() self.assertTrue(ticket.zoom_session_registration) - registration = frappe.get_doc("Zoom Session Registration", ticket.zoom_session_registration) - self.assertEqual(registration.reference_doctype, "Zoom Meeting") - self.assertEqual(registration.reference_name, meeting.name) - self.assertEqual(registration.registrant_id, registrant["registrant_id"]) + self.assert_registration(ticket, "Zoom Meeting", meeting, registrant) def test_ticket_registration_points_at_the_events_zoom_webinar(self): from zoom_integration.tests.zoom_fixtures import ( @@ -251,88 +117,62 @@ def test_ticket_registration_points_at_the_events_zoom_webinar(self): mock_zoom_post, ) - webinar_controller = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar" - - with mock_zoom_post(webinar_controller, 201, create_webinar_response()): - webinar = frappe.get_doc( - { - "doctype": "Zoom Webinar", - "title": "Ticket Webinar", - "date": "2026-08-01", - "start_time": "10:00:00", - "duration": 3600, - "timezone": "Asia/Calcutta", - } - ).insert(ignore_permissions=True) - - self.event.db_set("zoom_webinar", webinar.name) + with mock_zoom_post(WEBINAR_CONTROLLER, 201, create_webinar_response()): + webinar = self.event.create_webinar_on_zoom().name registrant = add_webinar_registrant_response() - with mock_zoom_post(webinar_controller, 200, registrant): - ticket = self._submit_ticket("carol@example.com") + with mock_zoom_post(WEBINAR_CONTROLLER, 200, registrant): + ticket = self.submit_ticket() - registration = frappe.get_doc("Zoom Session Registration", ticket.zoom_session_registration) - self.assertEqual(registration.reference_doctype, "Zoom Webinar") - self.assertEqual(registration.reference_name, webinar.name) - self.assertEqual(registration.registrant_id, registrant["registrant_id"]) + self.assert_registration(ticket, "Zoom Webinar", webinar, registrant) def test_ticket_details_expose_the_zoom_session_reference(self): - from zoom_integration.tests.zoom_fixtures import ( - add_meeting_registrant_response, - create_meeting_response, - ) + from zoom_integration.tests.zoom_fixtures import add_meeting_registrant_response from buzz.api.tickets import get_ticket_details - meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" - - with patch(f"{meeting_controller}.create_zoom_session", return_value=create_meeting_response()): - meeting = frappe.get_doc( - { - "doctype": "Zoom Meeting", - "title": "Details Meeting", - "date": "2026-08-01", - "start_time": "10:00:00", - "duration": 3600, - "timezone": "Asia/Calcutta", - } - ).insert(ignore_permissions=True) - - self.event.db_set("zoom_meeting", meeting.name) + meeting = self.create_zoom_meeting() registrant = add_meeting_registrant_response() - with patch(f"{meeting_controller}.add_zoom_registrant", return_value=registrant): - ticket = self._submit_ticket("dana@example.com") + with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=registrant): + ticket = self.submit_ticket() details = get_ticket_details(ticket.name) - self.assertEqual(details.zoom_join_url, registrant["join_url"]) - self.assertEqual(details.zoom_reference_doctype, "Zoom Meeting") - self.assertEqual(details.zoom_reference_name, meeting.name) + self.assertEqual( + (details.zoom_reference_doctype, details.zoom_reference_name), ("Zoom Meeting", meeting) + ) + + def create_zoom_meeting(self) -> str: + from zoom_integration.tests.zoom_fixtures import create_meeting_response + + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=create_meeting_response()): + return self.event.create_meeting_on_zoom().name + + def submit_ticket(self): + # The ticket reads the linked session from the cached event. + frappe.clear_document_cache("Buzz Event", self.event.name) + return EventTicketFactory.create("submitted", event=self.event.name) + + def assert_registration(self, ticket, reference_doctype: str, reference_name: str, registrant: dict): + registration = frappe.get_doc("Zoom Session Registration", ticket.zoom_session_registration) + self.assertEqual(registration.reference_doctype, reference_doctype) + self.assertEqual(registration.reference_name, reference_name) + self.assertEqual(registration.registrant_id, registrant["registrant_id"]) class TestRenderEmailTemplate(IntegrationTestCase): """Attendees and sponsors are Website Users; only Desk Users can read an Email Template.""" - def tearDown(self): - frappe.db.rollback() - def test_renders_for_a_user_without_email_template_permission(self): - template = frappe.get_doc( - { - "doctype": "Email Template", - "name": "Unprivileged Render Template", - "subject": "NOPERM - {{ event_title }}", - "response": "

NOPERM content

", - } - ).insert(ignore_permissions=True) - - attendee = create_user("attendee-render@example.com", "Attendee") - self.addCleanup(frappe.set_user, frappe.session.user) - frappe.set_user(attendee) - self.assertFalse(frappe.has_permission("Email Template", "read")) - - rendered = render_email_template(template.name, {"event_title": "Buzz Conf"}) + template = EmailTemplateFactory.create( + subject="NOPERM - {{ event_title }}", response="

NOPERM content

" + ).name + attendee = UserFactory.create_once("ticket-render-attendee@example.com").name + + with self.set_user(attendee): + self.assertFalse(frappe.has_permission("Email Template", "read")) + rendered = render_email_template(template, {"event_title": "Buzz Conf"}) self.assertEqual(rendered["subject"], "NOPERM - Buzz Conf") self.assertIn("NOPERM content", rendered["message"]) @@ -341,44 +181,16 @@ def test_renders_for_a_user_without_email_template_permission(self): class TestGuestTicketEmail(IntegrationTestCase): """Public bookings submit their tickets as Guest.""" - def setUp(self): - self.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.event.name, - "title": "Guest Email Ticket", - } - ).insert(ignore_permissions=True) - self.template = frappe.get_doc( - { - "doctype": "Email Template", - "name": "Guest Ticket Template", - "subject": "GUEST - {{ event_title }}", - "response": "

Guest content

", - } - ).insert(ignore_permissions=True) - self.event.db_set("ticket_email_template", self.template.name) - self.addCleanup(frappe.set_user, frappe.session.user) - - def tearDown(self): - frappe.db.rollback() - @patch("frappe.sendmail") def test_guest_can_render_the_ticket_email_template(self, mock_sendmail): - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "event": self.event.name, - "ticket_type": self.ticket_type.name, - "first_name": "Guest", - "attendee_email": "guest-booking@example.com", - } - ).insert(ignore_permissions=True) - - frappe.set_user("Guest") + event = BuzzEventFactory.create() + template = EmailTemplateFactory.create(subject="Guest ticket template").name + frappe.db.set_value("Buzz Event", event.name, "ticket_email_template", template) + frappe.clear_document_cache("Buzz Event", event.name) + ticket = EventTicketFactory.create(event=event.name) - ticket.send_ticket_email(now=True) + with self.set_user("Guest"): + ticket.send_ticket_email(now=True) mock_sendmail.assert_called_once() - self.assertIn("GUEST", mock_sendmail.call_args[1]["subject"]) + self.assertEqual(mock_sendmail.call_args[1]["subject"], "Guest ticket template") diff --git a/buzz/ticketing/doctype/event_ticket_type/test_event_ticket_type.py b/buzz/ticketing/doctype/event_ticket_type/test_event_ticket_type.py index f2eb272a..22016322 100644 --- a/buzz/ticketing/doctype/event_ticket_type/test_event_ticket_type.py +++ b/buzz/ticketing/doctype/event_ticket_type/test_event_ticket_type.py @@ -4,43 +4,31 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, EventTicketTypeFactory +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + EventBookingFactory, + EventTicketTypeFactory, +) from buzz.ticketing.doctype.event_ticket_type.event_ticket_type import PAID_EVENTS_FLAG class TestEventTicketTypePrices(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = BuzzEventFactory.create().name + def setUp(self): - frappe.set_user("Administrator") - self.event = frappe.db.get_value("Buzz Event", {"route": "test-route"}) - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.event, - "title": "Priced ticket", - "prices": [{"currency": "INR", "price": 1000}, {"currency": "USD", "price": 15}], - } - ).insert() - - def tearDown(self): - frappe.db.rollback() - - def sell(self, currency): - frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.event, - "user": "Administrator", - "currency": currency, - "payment_status": "Paid", - "attendees": [ - { - "ticket_type": self.ticket_type.name, - "first_name": "Buyer", - "email": "buyer@example.com", - } - ], - } - ).insert().submit() + prices = [{"currency": "INR", "price": 1000}, {"currency": "USD", "price": 15}] + self.ticket_type = EventTicketTypeFactory.create(event=self.event, prices=prices) + + def submit_paid_booking(self, currency): + attendee = {"ticket_type": self.ticket_type.name, "first_name": "Buyer", "email": "buyer@example.com"} + booking = EventBookingFactory.create( + event=self.event, currency=currency, payment_status="Paid", attendees=[attendee] + ) + booking.submit() def save_prices(self, *prices): self.ticket_type.reload() @@ -48,14 +36,12 @@ def save_prices(self, *prices): self.ticket_type.save() def test_saved_without_prices_is_free_in_inr(self): - ticket_type = frappe.get_doc( - {"doctype": "Event Ticket Type", "event": self.event, "title": "Free"} - ).insert() + ticket_type = EventTicketTypeFactory.create(event=self.event) self.assertEqual([(row.currency, row.price) for row in ticket_type.prices], [("INR", 0)]) def test_price_is_locked_after_a_sale_in_its_currency(self): - self.sell("USD") + self.submit_paid_booking("USD") self.save_prices(("INR", 1200), ("USD", 15)) with self.assertRaises(frappe.ValidationError): @@ -76,17 +62,20 @@ def test_rejects_a_currency_twice(self): self.save_prices(("INR", 1000), ("USD", 15), ("USD", 15)) def test_ticket_type_with_sales_cannot_be_deleted(self): - self.sell("INR") + self.submit_paid_booking("INR") with self.assertRaises(frappe.LinkExistsError): frappe.delete_doc("Event Ticket Type", self.ticket_type.name) class TestPaidEventsFlag(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = BuzzEventFactory.create() + def setUp(self): - frappe.set_user("Administrator") - self.event = BuzzEventFactory.create() - self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.event.team) + self.set_paid_events(1) def set_paid_events(self, value: int): BuzzTeamFactory.set_settings(self.event.team, {PAID_EVENTS_FLAG: value}) diff --git a/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py b/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py index baea4dbd..2e2aab25 100644 --- a/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py +++ b/buzz/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py @@ -1,826 +1,222 @@ # Copyright (c) 2025, BWH Studios and Contributors # See license.txt -import frappe from frappe.tests import IntegrationTestCase -from buzz.api.forms.test_forms import ensure_event_host +from buzz.api.booking.services import create_add_on_doc +from buzz.tests.factories import ( + BuzzCustomFieldFactory, + BuzzEventFactory, + EventBookingFactory, + EventTicketFactory, + EventTicketTypeFactory, + TicketAddOnFactory, + UserFactory, +) from buzz.ticketing.report.detailed_event_registrations.detailed_event_registrations import ( execute, - get_add_ons_for_event, - get_booking_additional_fields, - get_booking_map, - get_booking_utm_params, get_columns, - get_custom_fields_for_event, get_data, - get_ticket_add_ons, - get_ticket_additional_fields, - get_ticket_type_map, - get_utm_params_for_event, ) +FIXED_COLUMNS = ("ticket_id", "attendee_name", "attendee_email", "booking_id", "ticket_type", "booking_user") -class TestDetailedEventRegistrationsReport(IntegrationTestCase): - """Integration tests for the Detailed Event Registrations report.""" +class TestDetailedEventRegistrationsReport(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.test_event = cls._create_test_event() - cls.test_ticket_type = cls._create_test_ticket_type(cls.test_event) - cls.test_ticket_type_vip = cls._create_test_ticket_type(cls.test_event, title="VIP", price=500) - - @classmethod - def _create_test_event(cls): - """Create a test event for the report tests.""" - # Check if test event already exists - if frappe.db.exists("Buzz Event", {"route": "test-report-event"}): - return frappe.get_doc("Buzz Event", {"route": "test-report-event"}) - - # Create required dependencies - if not frappe.db.exists("Event Category", "Test Category"): - frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert() - - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Test Report Event", - "route": "test-report-event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "apply_tax": False, - } - ).insert() - return event - - @classmethod - def _create_test_ticket_type(cls, event, title="Standard", price=100): - """Create a test ticket type.""" - return frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": event.name, - "title": title, - "prices": [{"currency": "INR", "price": price}], - "is_published": True, - } - ).insert() - - def _create_booking_with_tickets( - self, - attendees_data=None, - utm_parameters=None, - additional_fields=None, - submit=True, - ): - """Helper to create a booking with tickets.""" - if attendees_data is None: - attendees_data = [ - { - "first_name": "John Doe", - "email": "john@test.com", - "ticket_type": self.test_ticket_type.name, - } - ] - - booking_data = { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "attendees": attendees_data, - } - - if utm_parameters: - booking_data["utm_parameters"] = utm_parameters + cls.booker = UserFactory.create_once("report-booker@example.com").name + cls.event = BuzzEventFactory.create().name + cls.filters = {"event": cls.event} + cls.standard_type = EventTicketTypeFactory.create(event=cls.event, title="Standard") + cls.vip_type = EventTicketTypeFactory.create(event=cls.event, title="VIP") - if additional_fields: - booking_data["additional_fields"] = additional_fields - - booking = frappe.get_doc(booking_data).insert() - - if submit: - booking.submit() - - return booking - - def _create_custom_field(self, label, fieldname, applied_to="Ticket", fieldtype="Data"): - """Helper to create a custom field for the event.""" - return frappe.get_doc( - { - "doctype": "Buzz Custom Field", - "event": self.test_event.name, - "label": label, - "fieldname": fieldname, - "fieldtype": fieldtype, - "applied_to": applied_to, - "enabled": 1, - "order": 1, - } - ).insert() - - def _create_ticket_add_on(self, title, price=50, user_selects_option=True, options="S\nM\nL\nXL"): - """Helper to create a ticket add-on for the event.""" - return frappe.get_doc( - { - "doctype": "Ticket Add-on", - "event": self.test_event.name, - "title": title, - "price": price, - "enabled": 1, - "user_selects_option": user_selects_option, - "options": options if user_selects_option else None, - } - ).insert() - - def _get_ticket_for_booking(self, booking_name, attendee_email=None): - """Helper to get a ticket from a booking.""" - filters = {"booking": booking_name} - if attendee_email: - filters["attendee_email"] = attendee_email - ticket_names = frappe.get_all("Event Ticket", filters=filters, pluck="name") - if ticket_names: - return frappe.get_doc("Event Ticket", ticket_names[0]) - return None - - # ==================== Test execute function ==================== - - def test_execute_returns_empty_without_filters(self): - """Test that execute returns empty results without filters.""" - columns, data = execute(None) - self.assertEqual(columns, []) - self.assertEqual(data, []) - - def test_execute_returns_empty_without_event_filter(self): - """Test that execute returns empty results without event filter.""" - columns, data = execute({}) - self.assertEqual(columns, []) - self.assertEqual(data, []) + def test_execute_returns_empty_without_an_event(self): + self.assertEqual(execute(None), ([], [])) + self.assertEqual(execute({}), ([], [])) def test_execute_returns_columns_and_data_with_event_filter(self): - """Test that execute returns proper columns and data with event filter.""" - # Create a submitted booking - self._create_booking_with_tickets() + self.create_booking() - columns, data = execute({"event": self.test_event.name}) + columns, data = execute(self.filters) - self.assertIsInstance(columns, list) - self.assertIsInstance(data, list) self.assertGreater(len(columns), 0) self.assertGreater(len(data), 0) - # ==================== Test get_columns function ==================== - def test_get_columns_returns_fixed_columns(self): - """Test that get_columns returns the required fixed columns.""" - columns = get_columns({"event": self.test_event.name}) - - fieldnames = [col["fieldname"] for col in columns] - expected_fieldnames = [ - "ticket_id", - "attendee_name", - "attendee_email", - "booking_id", - "ticket_type", - "booking_user", - ] + fieldnames = self.column_fieldnames() - for expected in expected_fieldnames: - self.assertIn(expected, fieldnames) + for fieldname in FIXED_COLUMNS: + self.assertIn(fieldname, fieldnames) - def test_get_columns_includes_custom_field_columns(self): - """Test that custom field columns are included.""" - # Create a custom field - custom_field = self._create_custom_field("Company Name", "company_name") + def test_get_columns_includes_enabled_custom_field_columns(self): + self.create_custom_field("company_name") + self.create_custom_field("disabled_field", enabled=0) - columns = get_columns({"event": self.test_event.name}) - fieldnames = [col["fieldname"] for col in columns] + fieldnames = self.column_fieldnames() self.assertIn("cf_company_name", fieldnames) + self.assertNotIn("cf_disabled_field", fieldnames) - # Clean up - custom_field.delete() - - def test_get_columns_includes_add_on_columns(self): - """Test that add-on columns are included.""" - # Create a ticket add-on - add_on = self._create_ticket_add_on("T-Shirt Size") + def test_get_columns_includes_enabled_add_on_columns(self): + add_on = self.create_add_on() + disabled_add_on = TicketAddOnFactory.create(event=self.event, enabled=0) - columns = get_columns({"event": self.test_event.name}) - fieldnames = [col["fieldname"] for col in columns] + fieldnames = self.column_fieldnames() self.assertIn(f"addon_{add_on.name}", fieldnames) + self.assertNotIn(f"addon_{disabled_add_on.name}", fieldnames) - # Clean up - add_on.delete() - - def test_get_columns_includes_utm_columns(self): - """Test that UTM parameter columns are included.""" - # Create a booking with UTM parameters - self._create_booking_with_tickets( + def test_get_columns_includes_each_utm_name_once(self): + self.create_booking(utm_parameters=[{"utm_name": "utm_source", "value": "google"}]) + self.create_booking( utm_parameters=[ - {"utm_name": "utm_source", "value": "google"}, + {"utm_name": "utm_source", "value": "facebook"}, {"utm_name": "utm_medium", "value": "cpc"}, ] ) - columns = get_columns({"event": self.test_event.name}) - fieldnames = [col["fieldname"] for col in columns] + fieldnames = self.column_fieldnames() - self.assertIn("utm_utm_source", fieldnames) + self.assertEqual(fieldnames.count("utm_utm_source"), 1) self.assertIn("utm_utm_medium", fieldnames) - # ==================== Test get_data function ==================== - def test_get_data_returns_only_submitted_tickets(self): - """Test that only submitted tickets are returned.""" - # Create a draft booking (not submitted) - self._create_booking_with_tickets( - attendees_data=[ - { - "first_name": "Draft User", - "email": "draft@test.com", - "ticket_type": self.test_ticket_type.name, - } - ], - submit=False, - ) - - # Create a submitted booking - self._create_booking_with_tickets( - attendees_data=[ - { - "first_name": "Submitted User", - "email": "submitted@test.com", - "ticket_type": self.test_ticket_type.name, - } - ], - submit=True, - ) + draft = EventTicketFactory.create(event=self.event, ticket_type=self.standard_type.name) + submitted = self.submit_ticket() - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) + ticket_ids = [row["ticket_id"] for row in self.report_rows()] - # Check that draft user is not in data - attendee_names = [row["attendee_name"] for row in data] - self.assertNotIn("Draft User", attendee_names) - self.assertIn("Submitted User", attendee_names) + self.assertNotIn(draft.name, ticket_ids) + self.assertIn(submitted.name, ticket_ids) def test_get_data_includes_correct_ticket_info(self): - """Test that ticket information is correctly included.""" - booking = self._create_booking_with_tickets( - attendees_data=[ - { - "first_name": "Test Attendee", - "email": "testattendee@test.com", - "ticket_type": self.test_ticket_type.name, - } - ] - ) - - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) + attendee = self.attendee(first_name="Test Attendee", email="testattendee@example.com") + booking = self.create_booking(attendees=[attendee]) - # Find the row for our test attendee - test_row = next((row for row in data if row["attendee_name"] == "Test Attendee"), None) + row = self.row_for_booking(booking.name) - self.assertIsNotNone(test_row) - self.assertEqual(test_row["attendee_email"], "testattendee@test.com") - self.assertEqual(test_row["booking_id"], booking.name) - # Check ticket type is the title from the ticket type we created - self.assertEqual(test_row["ticket_type"], self.test_ticket_type.title) - self.assertEqual(test_row["booking_user"], "Administrator") + self.assertEqual(row["attendee_name"], "Test Attendee") + self.assertEqual(row["attendee_email"], "testattendee@example.com") + self.assertEqual(row["ticket_type"], "Standard") + self.assertEqual(row["booking_user"], self.booker) def test_get_data_includes_custom_field_values_from_ticket(self): - """Test that custom field values from tickets are included.""" - # Create a custom field - custom_field = self._create_custom_field( - "Dietary Preference", "dietary_preference", applied_to="Ticket" - ) + self.create_custom_field("dietary_preference", applied_to="Ticket") + ticket = self.submit_ticket(additional_fields=[additional_field("dietary_preference", "Vegetarian")]) - # Create a standalone ticket with additional fields (not via booking) - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "ticket_type": self.test_ticket_type.name, - "attendee_name": "Dietary Test User", - "attendee_email": "dietary@test.com", - "additional_fields": [ - { - "fieldname": "dietary_preference", - "label": "Dietary Preference", - "value": "Vegetarian", - } - ], - } - ).insert() - ticket.submit() - - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) - - # Find the row for our ticket - test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) - - self.assertIsNotNone(test_row) - self.assertEqual(test_row["cf_dietary_preference"], "Vegetarian") - - # Clean up - custom_field.delete() + self.assertEqual(self.row_for_ticket(ticket.name)["cf_dietary_preference"], "Vegetarian") def test_get_data_custom_field_ticket_priority_over_booking(self): - """Test that ticket-level custom field values take priority over booking-level.""" - # Create a custom field - custom_field = self._create_custom_field("Organization", "organization", applied_to="Ticket") - - # Create a standalone ticket with both booking and ticket level custom fields - # Since we can't easily add ticket-level fields after submission, we use direct insertion - # First create the ticket with ticket-level field - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "ticket_type": self.test_ticket_type.name, - "attendee_name": "Priority Test User", - "attendee_email": "priority@test.com", - "additional_fields": [ - { - "fieldname": "organization", - "label": "Organization", - "value": "Ticket Org", - } - ], - } - ).insert() - - # Create a booking with organization field and link the ticket - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "attendees": [ - { - "first_name": "Priority Test User", - "email": "priority@test.com", - "ticket_type": self.test_ticket_type.name, - } - ], - "additional_fields": [ - { - "fieldname": "organization", - "label": "Organization", - "value": "Booking Org", - } - ], - } - ).insert() - - # Update the ticket to link to this booking - ticket.booking = booking.name - ticket.save() - - # Submit the ticket - ticket.submit() - - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) - - # Find the row for our ticket - test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) - - self.assertIsNotNone(test_row) - # Ticket value should take priority - self.assertEqual(test_row["cf_organization"], "Ticket Org") - - # Clean up - custom_field.delete() - - def test_get_data_falls_back_to_booking_custom_field(self): - """Test that booking-level custom field is used when ticket doesn't have it.""" - # Create a custom field - custom_field = self._create_custom_field("Company", "company", applied_to="Booking") - - # Create a booking with additional fields (no ticket-level fields) - booking = self._create_booking_with_tickets( - additional_fields=[ - { - "fieldname": "company", - "label": "Company", - "value": "Acme Inc", - } - ] + self.create_custom_field("organization", applied_to="Ticket") + booking = EventBookingFactory.create( + event=self.event, + attendees=[self.attendee()], + additional_fields=[additional_field("organization", "Booking Org")], + ) + ticket = self.submit_ticket( + booking=booking.name, additional_fields=[additional_field("organization", "Ticket Org")] ) - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) - - # Find the row for our ticket - ticket = self._get_ticket_for_booking(booking.name) - self.assertIsNotNone(ticket, "Ticket should be created with booking") - test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore + self.assertEqual(self.row_for_ticket(ticket.name)["cf_organization"], "Ticket Org") - self.assertIsNotNone(test_row) - self.assertEqual(test_row["cf_company"], "Acme Inc") + def test_get_data_falls_back_to_booking_custom_field(self): + self.create_custom_field("company", applied_to="Booking") + booking = self.create_booking(additional_fields=[additional_field("company", "Acme Inc")]) - # Clean up - custom_field.delete() + self.assertEqual(self.row_for_booking(booking.name)["cf_company"], "Acme Inc") def test_get_data_includes_add_on_values(self): - """Test that add-on values are correctly included.""" - # Create a ticket add-on - add_on = self._create_ticket_add_on("T-Shirt Size") - - # Create attendee add-on doc - attendee_add_on = frappe.get_doc( - { - "doctype": "Attendee Ticket Add-on", - "add_ons": [{"add_on": add_on.name, "value": "XL"}], - } - ).insert() - - # Create a booking with the add-on - booking = self._create_booking_with_tickets( - attendees_data=[ - { - "first_name": "AddOn User", - "email": "addon@test.com", - "ticket_type": self.test_ticket_type.name, - "add_ons": attendee_add_on.name, - } - ] - ) - - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) - - # Find the row for our ticket - ticket = self._get_ticket_for_booking(booking.name) - self.assertIsNotNone(ticket, "Ticket should be created with booking") - test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore - - self.assertIsNotNone(test_row) - self.assertEqual(test_row[f"addon_{add_on.name}"], "XL") + add_on = self.create_add_on() + add_ons = create_add_on_doc("AddOn User", [{"add_on": add_on.name, "value": "XL"}]) + booking = self.create_booking(attendees=[self.attendee(add_ons=add_ons.name)]) - # Clean up - add_on.delete() + self.assertEqual(self.row_for_booking(booking.name)[f"addon_{add_on.name}"], "XL") def test_get_data_includes_utm_values(self): - """Test that UTM parameter values are correctly included.""" - booking = self._create_booking_with_tickets( + booking = self.create_booking( utm_parameters=[ {"utm_name": "utm_source", "value": "facebook"}, {"utm_name": "utm_campaign", "value": "summer_promo"}, ] ) - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) + row = self.row_for_booking(booking.name) - # Find the row for our ticket - ticket = self._get_ticket_for_booking(booking.name) - self.assertIsNotNone(ticket, "Ticket should be created with booking") - test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore - - self.assertIsNotNone(test_row) - self.assertEqual(test_row["utm_utm_source"], "facebook") - self.assertEqual(test_row["utm_utm_campaign"], "summer_promo") + self.assertEqual((row["utm_utm_source"], row["utm_utm_campaign"]), ("facebook", "summer_promo")) def test_get_data_handles_multiple_tickets_per_booking(self): - """Test that multiple tickets per booking are handled correctly.""" - booking = self._create_booking_with_tickets( - attendees_data=[ - { - "first_name": "Attendee One", - "email": "one@test.com", - "ticket_type": self.test_ticket_type.name, - }, - { - "first_name": "Attendee Two", - "email": "two@test.com", - "ticket_type": self.test_ticket_type_vip.name, - }, - ] - ) + standard = self.attendee(email="one@example.com") + vip = self.attendee(email="two@example.com", ticket_type=self.vip_type.name) + booking = self.create_booking(attendees=[standard, vip]) - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) - - # Find rows for both attendees - attendee_names = [row["attendee_name"] for row in data] - self.assertIn("Attendee One", attendee_names) - self.assertIn("Attendee Two", attendee_names) - - # Check ticket types - attendee_one_row = next((row for row in data if row["attendee_name"] == "Attendee One"), None) - attendee_two_row = next((row for row in data if row["attendee_name"] == "Attendee Two"), None) - - self.assertIsNotNone(attendee_one_row) - self.assertIsNotNone(attendee_two_row) - self.assertEqual(attendee_one_row["ticket_type"], self.test_ticket_type.title) - self.assertEqual(attendee_two_row["ticket_type"], self.test_ticket_type_vip.title) - self.assertEqual(attendee_one_row["booking_id"], booking.name) - self.assertEqual(attendee_two_row["booking_id"], booking.name) - - # ==================== Test helper functions ==================== - - def test_get_custom_fields_for_event(self): - """Test get_custom_fields_for_event returns correct fields.""" - cf1 = self._create_custom_field("Field One", "field_one") - cf2 = self._create_custom_field("Field Two", "field_two") - - # Create a disabled field that should not be returned - cf3 = frappe.get_doc( - { - "doctype": "Buzz Custom Field", - "event": self.test_event.name, - "label": "Disabled Field", - "fieldname": "disabled_field", - "fieldtype": "Data", - "applied_to": "Ticket", - "enabled": 0, - "order": 1, - } - ).insert() - - result = get_custom_fields_for_event(self.test_event.name) - - fieldnames = [r.fieldname for r in result] - self.assertIn("field_one", fieldnames) - self.assertIn("field_two", fieldnames) - self.assertNotIn("disabled_field", fieldnames) - - # Clean up - cf1.delete() - cf2.delete() - cf3.delete() - - def test_get_add_ons_for_event(self): - """Test get_add_ons_for_event returns correct add-ons.""" - ao1 = self._create_ticket_add_on("Add-on One") - ao2 = self._create_ticket_add_on("Add-on Two") - - # Create a disabled add-on - ao3 = frappe.get_doc( - { - "doctype": "Ticket Add-on", - "event": self.test_event.name, - "title": "Disabled Add-on", - "price": 50, - "enabled": 0, - } - ).insert() - - result = get_add_ons_for_event(self.test_event.name) - - titles = [r.title for r in result] - self.assertIn("Add-on One", titles) - self.assertIn("Add-on Two", titles) - self.assertNotIn("Disabled Add-on", titles) - - # Clean up - ao1.delete() - ao2.delete() - ao3.delete() - - def test_get_utm_params_for_event(self): - """Test get_utm_params_for_event returns distinct UTM names.""" - # Create bookings with different UTM params - self._create_booking_with_tickets( - utm_parameters=[ - {"utm_name": "utm_source", "value": "google"}, - {"utm_name": "utm_medium", "value": "cpc"}, - ] - ) + rows = {row["attendee_email"]: row for row in self.report_rows() if row["booking_id"] == booking.name} - self._create_booking_with_tickets( - attendees_data=[ - { - "first_name": "Another User", - "email": "another@test.com", - "ticket_type": self.test_ticket_type.name, - } - ], - utm_parameters=[ - {"utm_name": "utm_source", "value": "facebook"}, # Duplicate name, different value - {"utm_name": "utm_campaign", "value": "winter"}, - ], - ) + self.assertEqual(rows["one@example.com"]["ticket_type"], "Standard") + self.assertEqual(rows["two@example.com"]["ticket_type"], "VIP") - result = get_utm_params_for_event(self.test_event.name) - - # Should have 3 distinct UTM names - self.assertIn("utm_source", result) - self.assertIn("utm_medium", result) - self.assertIn("utm_campaign", result) - self.assertEqual(len(set(result)), len(result)) # All unique - - def test_get_ticket_type_map(self): - """Test get_ticket_type_map returns correct mapping.""" - result = get_ticket_type_map(self.test_event.name) - - # Keys are strings due to autoincrement ID handling - self.assertIn(str(self.test_ticket_type.name), result) - self.assertEqual(result[str(self.test_ticket_type.name)], "Standard") - self.assertIn(str(self.test_ticket_type_vip.name), result) - self.assertEqual(result[str(self.test_ticket_type_vip.name)], "VIP") - - def test_get_booking_map(self): - """Test get_booking_map returns correct mapping.""" - booking = self._create_booking_with_tickets() - - result = get_booking_map([booking.name]) - - self.assertIn(booking.name, result) - self.assertEqual(result[booking.name]["user"], "Administrator") - - def test_get_booking_map_empty_list(self): - """Test get_booking_map handles empty list.""" - result = get_booking_map([]) - self.assertEqual(result, {}) - - def test_get_ticket_additional_fields(self): - """Test get_ticket_additional_fields returns correct data.""" - # Create a standalone ticket with additional fields - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "ticket_type": self.test_ticket_type.name, - "attendee_name": "Additional Fields Test", - "attendee_email": "addfields@test.com", - "additional_fields": [ - { - "fieldname": "test_field", - "label": "Test Field", - "value": "Test Value", - } - ], - } - ).insert() - - result = get_ticket_additional_fields([ticket.name]) - - self.assertIn(ticket.name, result) - self.assertEqual(result[ticket.name]["test_field"], "Test Value") - - def test_get_ticket_additional_fields_empty_list(self): - """Test get_ticket_additional_fields handles empty list.""" - result = get_ticket_additional_fields([]) - self.assertEqual(result, {}) - - def test_get_booking_additional_fields(self): - """Test get_booking_additional_fields returns correct data.""" - booking = self._create_booking_with_tickets( - additional_fields=[ - { - "fieldname": "booking_field", - "label": "Booking Field", - "value": "Booking Value", - } - ] - ) - - result = get_booking_additional_fields([booking.name]) - - self.assertIn(booking.name, result) - self.assertEqual(result[booking.name]["booking_field"], "Booking Value") - - def test_get_booking_additional_fields_empty_list(self): - """Test get_booking_additional_fields handles empty list.""" - result = get_booking_additional_fields([]) - self.assertEqual(result, {}) - - def test_get_ticket_add_ons(self): - """Test get_ticket_add_ons returns correct data.""" - add_on = self._create_ticket_add_on("Test Add-on") - - attendee_add_on = frappe.get_doc( - { - "doctype": "Attendee Ticket Add-on", - "add_ons": [{"add_on": add_on.name, "value": "Medium"}], - } - ).insert() - - booking = self._create_booking_with_tickets( - attendees_data=[ - { - "first_name": "Add-on Test", - "email": "addontest@test.com", - "ticket_type": self.test_ticket_type.name, - "add_ons": attendee_add_on.name, - } - ] - ) + def test_report_with_no_tickets(self): + event = BuzzEventFactory.create().name - ticket = self._get_ticket_for_booking(booking.name) - self.assertIsNotNone(ticket, "Ticket should be created with booking") - result = get_ticket_add_ons([ticket.name]) # type: ignore + columns, data = execute({"event": event}) - self.assertIn(ticket.name, result) # type: ignore - self.assertEqual(result[ticket.name][add_on.name], "Medium") # type: ignore + self.assertGreater(len(columns), 0) + self.assertEqual(data, []) - # Clean up - add_on.delete() + def test_report_with_missing_booking(self): + ticket = self.submit_ticket() - def test_get_ticket_add_ons_empty_list(self): - """Test get_ticket_add_ons handles empty list.""" - result = get_ticket_add_ons([]) - self.assertEqual(result, {}) + self.assertEqual(self.row_for_ticket(ticket.name)["booking_user"], "") - def test_get_booking_utm_params(self): - """Test get_booking_utm_params returns correct data.""" - booking = self._create_booking_with_tickets( - utm_parameters=[ - {"utm_name": "utm_source", "value": "twitter"}, - {"utm_name": "utm_medium", "value": "social"}, - ] - ) - - result = get_booking_utm_params([booking.name]) + def test_report_column_ordering(self): + self.create_custom_field("custom_col") + add_on = self.create_add_on() + self.create_booking(utm_parameters=[{"utm_name": "utm_test", "value": "test"}]) + + fieldnames = self.column_fieldnames() + positions = [ + fieldnames.index(fieldname) + for fieldname in ("booking_user", "cf_custom_col", f"addon_{add_on.name}", "utm_utm_test") + ] - self.assertIn(booking.name, result) - self.assertEqual(result[booking.name]["utm_source"], "twitter") - self.assertEqual(result[booking.name]["utm_medium"], "social") + self.assertEqual(positions, sorted(positions)) - def test_get_booking_utm_params_empty_list(self): - """Test get_booking_utm_params handles empty list.""" - result = get_booking_utm_params([]) - self.assertEqual(result, {}) + def create_booking(self, attendees: list[dict] | None = None, **overrides): + booking = EventBookingFactory.create( + event=self.event, user=self.booker, attendees=attendees or [self.attendee()], **overrides + ) + booking.submit() + return booking - # ==================== Edge cases ==================== + def attendee(self, first_name: str = "Attendee", email: str = "attendee@example.com", **values): + return {"ticket_type": self.standard_type.name, "first_name": first_name, "email": email, **values} - def test_report_with_no_tickets(self): - """Test report handles events with no tickets.""" - # Create a new event with no tickets - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Empty Event", - "route": "empty-event-" + frappe.generate_hash(length=6), - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - } - ).insert() - - columns, data = execute({"event": event.name}) - - self.assertIsInstance(columns, list) - self.assertEqual(data, []) - # Note: Not cleaning up the event as it may have linked dependencies in test DB + def submit_ticket(self, **overrides): + return EventTicketFactory.create( + "submitted", event=self.event, ticket_type=self.standard_type.name, **overrides + ) - def test_report_with_missing_booking(self): - """Test report handles tickets without booking reference gracefully.""" - # Create a standalone ticket without booking - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "ticket_type": self.test_ticket_type.name, - "attendee_name": "Standalone User", - "attendee_email": "standalone@test.com", - } - ).insert() - ticket.submit() - - columns = get_columns({"event": self.test_event.name}) - data = get_data({"event": self.test_event.name}, columns) - - # Should include the ticket even without booking - test_row = next((row for row in data if row["attendee_name"] == "Standalone User"), None) - self.assertIsNotNone(test_row) - self.assertEqual(test_row["booking_user"], "") + def create_custom_field(self, fieldname: str, **overrides): + return BuzzCustomFieldFactory.create( + event=self.event, label=fieldname.replace("_", " ").title(), fieldname=fieldname, **overrides + ) - def test_report_column_ordering(self): - """Test that columns are in the expected order.""" - # Create all types of dynamic columns - custom_field = self._create_custom_field("Custom Col", "custom_col") - add_on = self._create_ticket_add_on("Add-on Col") - self._create_booking_with_tickets(utm_parameters=[{"utm_name": "utm_test", "value": "test"}]) + def create_add_on(self): + return TicketAddOnFactory.create(event=self.event, user_selects_option=1, options="S\nM\nL\nXL") - columns = get_columns({"event": self.test_event.name}) - fieldnames = [col["fieldname"] for col in columns] + def column_fieldnames(self) -> list[str]: + return [column["fieldname"] for column in get_columns(self.filters)] - # Fixed columns should come first - fixed_end_index = fieldnames.index("booking_user") + def report_rows(self) -> list[dict]: + return get_data(self.filters, get_columns(self.filters)) - # Custom fields should come after fixed columns - cf_index = fieldnames.index("cf_custom_col") - self.assertGreater(cf_index, fixed_end_index) + def row_for_ticket(self, ticket: str) -> dict: + return next(row for row in self.report_rows() if row["ticket_id"] == ticket) - # Add-ons should come after custom fields - addon_index = fieldnames.index(f"addon_{add_on.name}") - self.assertGreater(addon_index, cf_index) + def row_for_booking(self, booking: str) -> dict: + return next(row for row in self.report_rows() if row["booking_id"] == booking) - # UTM params should come last - utm_index = fieldnames.index("utm_utm_test") - self.assertGreater(utm_index, addon_index) - # Clean up - custom_field.delete() - add_on.delete() +def additional_field(fieldname: str, value: str) -> dict: + return {"fieldname": fieldname, "label": fieldname.replace("_", " ").title(), "value": value}