diff --git a/.claude/skills/writing-tests/SKILL.md b/.claude/skills/writing-tests/SKILL.md index e36c3503..44a406fc 100644 --- a/.claude/skills/writing-tests/SKILL.md +++ b/.claude/skills/writing-tests/SKILL.md @@ -17,8 +17,10 @@ Faker comes with frappe. Do not add it to `pyproject.toml`. 1. **Never** build a fixture with `frappe.get_doc({...}).insert()` or `frappe.new_doc(...)`. Use a factory. If the doctype has none, write it first. 2. One factory per doctype: `buzz/tests/factories/_factory.py`, class - `Factory(BaseFactory[])`, re-exported from `__init__.py`. - The generic is the real controller class, so the result is typed. + `Factory(BaseFactory[])`, re-exported from + `buzz/tests/factories/__init__.py`; import it from `buzz.tests.factories`. The generic is + the real controller class, so the result is typed. `buzz/tests/test_factories.py` creates + every factory in `__all__`, so a new factory is smoke-tested once it is exported. 3. `default_attributes` sets only what `.insert()` needs. Leave out fields with a DocType default or a controller fallback (`Event Booking.status`, the INR 0 price row `Event Ticket Type.before_validate` adds). @@ -109,12 +111,24 @@ the session user. ## Traps -**Administrator must not own throwaway teams.** `create_default_team_for` picks the first -enabled Owner membership, and `process_booking` commits, so its fixtures survive rollback. -An Administrator-owned test team becomes Administrator's default team on that site, and -`setup_test_records()` fails with `Venue Test Venue belongs to another team.` Use +**Administrator must not own throwaway teams.** `setup_test_records()` gives Administrator +exactly one team, and Event Template and event proposal submissions fill `team` from a sole +membership. `process_booking` commits, so its fixtures survive rollback. A second +Administrator team leaks across runs and breaks that lookup. Use `BuzzTeamFactory.create_owned_by()`. +**`Buzz Event` names are autoincrement integers.** `.name` is an `int` in memory, and the +whitelisted APIs type-check `event: str`. Pass `str(event.name)`, and compare against `str(...)` +when the other side came from the database. + +**`get_fullname` caches per process.** A user rebuilt in a later class keeps the earlier +name in that cache. Set the name the test asserts, and read it from the field the code reads. + +**`frappe.set_user` is allowed only in `test_*.py`.** The `frappe-setuser` semgrep rule +fails CI anywhere else, for example in `buzz/tests/base_test_cases.py` or a factory. Use +`with self.set_user(user):`, `self.enterContext(self.set_user(user))` in `setUp`, or +`frappe.tests.classes.context_managers.set_user` outside a test case. + **User creation is throttled** at 60 new users an hour (`User.throttle_user_creation`), and test users leak. For a fixed identity use `UserFactory.create_once(email)`; use `create()` only for a distinct user. @@ -150,10 +164,17 @@ class BookingTestCase(IntegrationTestCase): `buzz/api/booking/test_booking.py` is the reference conversion. -Older modules still export ad-hoc helpers — `create_user` / `create_owned_team` / -`payload_for` in `test_buzz_team.py`, `ensure_prompt_named_record` in `test_forms.py`, -`create_event` / `create_ticket` in `test_permissions.py`. They retire module by module. Do -not add new callers. +Never import from another `test_*.py`. Shared base classes (`BookingTestCase`, +`CouponTestCase`, `BookingRefundTestCase`, `SponsorshipTestCase`, `TeamPermissionTestCase`) +live in `buzz/tests/base_test_cases.py`. Shared non-fixture helpers (email queue readers, +telemetry capture, `clear_map_link_cache`) live in `buzz/tests/utils.py`. + +Use the Frappe context managers rather than manual cleanup: `self.set_user`, +`self.change_settings` (Singles only), `self.freeze_time`, `self.patch_hooks`. +`Buzz Team Settings` is not a Single, so use `BuzzTeamFactory.set_settings(team, values)`. + +CI formats with ruff 0.8.1. Run `uvx ruff@0.8.1 format buzz && uvx ruff@0.8.1 check buzz` +before pushing; a newer ruff wraps lines differently. ## Running tests diff --git a/buzz/api/account/test_account.py b/buzz/api/account/test_account.py index da9c00e0..1ab942ce 100644 --- a/buzz/api/account/test_account.py +++ b/buzz/api/account/test_account.py @@ -6,7 +6,6 @@ from frappe.utils import set_request from buzz.api.account import ( - get_enabled_languages, get_translations, get_user_info, update_user_language, @@ -17,14 +16,7 @@ class LanguageTestCase(IntegrationTestCase): - """Base for the tests that exercise language resolution. - - `get_language` reads cookies and headers off the live request, which a test - process does not have until one is installed. - """ - - def tearDown(self): - frappe.set_user("Administrator") + """`get_language` reads cookies and headers off the live request, which a test process lacks.""" def install_request(self, cookies: dict[str, str] | None = None, accept_language: str = ""): headers = {} @@ -37,11 +29,7 @@ def install_request(self, cookies: dict[str, str] | None = None, accept_language self.addCleanup(setattr, frappe.local, "request", None) def other_enabled_language(self) -> str: - """An enabled language that is not the site default. - - Plain codes only: a regional one like `pt-BR` comes back as a different - string once werkzeug has parsed the Accept-Language header. - """ + """Plain codes only: werkzeug rewrites a regional one like `pt-BR` when it parses the header.""" for language in get_all_languages(): if language != get_default_language() and language.isalpha(): return language @@ -57,8 +45,8 @@ def set_user_language(self, language: str | None): class TestGetUserInfo(LanguageTestCase): def test_guest_payload_keys(self): self.install_request() - frappe.set_user("Guest") - info = get_user_info().__json__() + with self.set_user("Guest"): + info = get_user_info().__json__() self.assertEqual(set(info), {"is_logged_in", "brand_image", "language"}) self.assertFalse(info["is_logged_in"]) @@ -66,15 +54,15 @@ def test_guest_payload_keys(self): def test_guest_language_follows_the_preferred_language_cookie(self): language = self.other_enabled_language() self.install_request(cookies={"preferred_language": language}) - frappe.set_user("Guest") - self.assertEqual(get_user_info().__json__()["language"], language) + with self.set_user("Guest"): + self.assertEqual(get_user_info().__json__()["language"], language) def test_guest_language_falls_back_to_the_site_default(self): self.install_request() - frappe.set_user("Guest") - self.assertEqual(get_user_info().__json__()["language"], get_default_language()) + with self.set_user("Guest"): + self.assertEqual(get_user_info().__json__()["language"], get_default_language()) def test_logged_in_payload_shape(self): info = get_user_info().__json__() @@ -99,20 +87,8 @@ def test_logged_in_payload_shape(self): self.assertTrue(info["is_logged_in"]) self.assertEqual(info["name"], "Administrator") - def test_roles_stay_child_table_rows(self): - roles = get_user_info().__json__()["roles"] - - self.assertTrue(roles) - self.assertTrue(any(row.role == "Administrator" for row in roles)) - class TestLanguages(LanguageTestCase): - def test_enabled_languages_shape(self): - languages = [language.__json__() for language in get_enabled_languages()] - - self.assertTrue(languages) - self.assertEqual(set(languages[0]), {"name", "language_name", "language_code"}) - def test_update_rejects_unknown_language(self): frappe.clear_messages() @@ -123,9 +99,6 @@ def test_update_rejects_unknown_language(self): self.assertEqual(message["title"], "Language Not Available") self.assertIn("not-a-language", message["message"]) - def test_unknown_language_maps_to_400(self): - self.assertEqual(UnknownLanguage.http_status_code, 400) - def test_update_persists_language(self): self.set_user_language(None) @@ -139,14 +112,6 @@ def test_update_stays_closed_to_guests(self): class TestTimezones(IntegrationTestCase): - def tearDown(self): - frappe.set_user("Administrator") - - def set_user_timezone(self, time_zone: str | None): - original = frappe.db.get_value("User", "Administrator", "time_zone") - self.addCleanup(frappe.db.set_value, "User", "Administrator", "time_zone", original) - frappe.db.set_value("User", "Administrator", "time_zone", time_zone) - def test_update_rejects_unknown_timezone(self): frappe.clear_messages() @@ -157,9 +122,6 @@ def test_update_rejects_unknown_timezone(self): self.assertEqual(message["title"], "Timezone Not Available") self.assertIn("Mars/Olympus_Mons", message["message"]) - def test_unknown_timezone_maps_to_400(self): - self.assertEqual(UnknownTimezone.http_status_code, 400) - def test_update_persists_timezone(self): self.set_user_timezone(None) @@ -168,7 +130,6 @@ def test_update_persists_timezone(self): self.assertEqual(frappe.db.get_value("User", "Administrator", "time_zone"), "Asia/Kolkata") def test_update_accepts_utc(self): - """The dashboard's picker puts UTC at the top of the list.""" self.set_user_timezone(None) update_user_timezone("UTC") @@ -179,35 +140,32 @@ def test_update_stays_closed_to_guests(self): self.assertIn(update_user_timezone, frappe.whitelisted) self.assertNotIn(update_user_timezone, frappe.guest_methods) + def set_user_timezone(self, time_zone: str | None): + original = frappe.db.get_value("User", "Administrator", "time_zone") + self.addCleanup(frappe.db.set_value, "User", "Administrator", "time_zone", original) + frappe.db.set_value("User", "Administrator", "time_zone", time_zone) -class TestGetTranslations(LanguageTestCase): - def resolved_language(self) -> str: - """The language get_translations picked, without loading any.""" - with patch("buzz.api.account.get_all_translations", return_value={}) as translations: - get_translations() - - translations.assert_called_once() - return translations.call_args.args[0] +class TestGetTranslations(LanguageTestCase): def test_guest_translations_follow_the_preferred_language_cookie(self): language = self.other_enabled_language() self.install_request(cookies={"preferred_language": language}) - frappe.set_user("Guest") - self.assertEqual(self.resolved_language(), language) + with self.set_user("Guest"): + self.assertEqual(self.resolved_language(), language) def test_guest_translations_fall_back_to_the_accept_language_header(self): language = self.other_enabled_language() self.install_request(accept_language=language) - frappe.set_user("Guest") - self.assertEqual(self.resolved_language(), language) + with self.set_user("Guest"): + self.assertEqual(self.resolved_language(), language) def test_guest_translations_fall_back_to_the_site_default(self): self.install_request() - frappe.set_user("Guest") - self.assertEqual(self.resolved_language(), get_default_language()) + with self.set_user("Guest"): + self.assertEqual(self.resolved_language(), get_default_language()) def test_logged_in_translations_follow_the_user_document(self): language = self.other_enabled_language() @@ -219,3 +177,11 @@ def test_a_user_without_a_language_gets_the_site_default(self): self.set_user_language(None) self.assertEqual(self.resolved_language(), get_default_language()) + + def resolved_language(self) -> str: + """The language get_translations picked, without loading any.""" + with patch("buzz.api.account.get_all_translations", return_value={}) as translations: + get_translations() + + translations.assert_called_once() + return translations.call_args.args[0] diff --git a/buzz/api/auth/test_auth.py b/buzz/api/auth/test_auth.py index d28857a8..94c375f3 100644 --- a/buzz/api/auth/test_auth.py +++ b/buzz/api/auth/test_auth.py @@ -1,43 +1,17 @@ -import frappe from frappe.tests import IntegrationTestCase from buzz.api.auth import get_login_context class TestGetLoginContext(IntegrationTestCase): - def tearDown(self): - frappe.set_user("Administrator") - - def test_returns_expected_keys(self): - context = get_login_context().__json__() - self.assertEqual( - set(context), - { - "disable_signup", - "disable_user_pass_login", - "login_with_email_link", - "login_banner", - "provider_logins", - }, - ) - - def test_settings_flags_stay_integers(self): - context = get_login_context().__json__() - for key in ("disable_signup", "disable_user_pass_login", "login_with_email_link"): - self.assertIsInstance(context[key], int) - def test_banner_is_rendered_as_html(self): - frappe.db.set_single_value("Buzz Settings", "login_banner", "# Welcome") - self.addCleanup(frappe.db.set_single_value, "Buzz Settings", "login_banner", None) - - self.assertIn(" str: + return EventTicketTypeFactory.create(event=self.event.name, prices=list(prices)).name + + def create_booking(self, ticket_type=None, add_ons=None, **overrides): + attendee = { + "ticket_type": ticket_type or self.ticket_type, + "first_name": "Attendee", + "email": "attendee@example.com", + "add_ons": add_ons, + } + return EventBookingFactory.create(event=self.event.name, attendees=[attendee], **overrides) diff --git a/buzz/api/campaigns/test_campaigns.py b/buzz/api/campaigns/test_campaigns.py index 539142bd..12d4b528 100644 --- a/buzz/api/campaigns/test_campaigns.py +++ b/buzz/api/campaigns/test_campaigns.py @@ -4,50 +4,22 @@ from frappe.tests import IntegrationTestCase from buzz.api.campaigns import get_campaign_details -from buzz.api.campaigns.exceptions import AlreadyRegistered, CampaignNotActive, CRMNotAvailable +from buzz.api.campaigns.exceptions import CampaignNotActive +from buzz.tests.factories import BuzzCampaignFactory from buzz.utils import is_app_installed -def make_campaign(enabled: int = 1) -> str: - # Buzz Campaign uses autoname "prompt" -> name set explicitly. - campaign = frappe.new_doc("Buzz Campaign") - campaign.name = f"Campaign {frappe.generate_hash(length=6)}" - campaign.title = campaign.name - campaign.description = "Interest list for the next edition" - campaign.enabled = enabled - campaign.insert(ignore_permissions=True) - return campaign.name - - class TestCampaignErrors(IntegrationTestCase): def test_unknown_campaign_raises_not_found(self): with self.assertRaises(frappe.DoesNotExistError): get_campaign_details("no-such-campaign") - def test_status_codes(self): - self.assertEqual(CampaignNotActive.http_status_code, 409) - self.assertEqual(AlreadyRegistered.http_status_code, 409) - self.assertEqual(CRMNotAvailable.http_status_code, 400) - - def test_each_error_carries_its_own_copy(self): - titles = {str(error.title) for error in (CampaignNotActive, AlreadyRegistered, CRMNotAvailable)} - - self.assertEqual(len(titles), 3) - # Buzz Campaign refuses to save unless Frappe CRM is installed. @unittest.skipUnless(is_app_installed("crm"), "requires the crm app") class TestGetCampaignDetails(IntegrationTestCase): - def tearDown(self): - frappe.set_user("Administrator") - - def test_returns_expected_keys(self): - details = get_campaign_details(make_campaign()).__json__() - - self.assertEqual(set(details), {"title", "description", "event"}) - def test_disabled_campaign_reports_its_message(self): - campaign = make_campaign(enabled=0) + campaign = BuzzCampaignFactory.create(enabled=0).name frappe.clear_messages() with self.assertRaises(CampaignNotActive): diff --git a/buzz/api/checkin/test_checkin.py b/buzz/api/checkin/test_checkin.py index 612ec5ff..dcefacde 100644 --- a/buzz/api/checkin/test_checkin.py +++ b/buzz/api/checkin/test_checkin.py @@ -3,90 +3,36 @@ from buzz.api.checkin import checkin_ticket, validate_ticket_for_checkin from buzz.api.checkin.exceptions import AlreadyCheckedIn, TicketCancelled, TicketNotFound - -TICKET_FIELDS = { - "id", - "attendee_name", - "attendee_email", - "event_title", - "ticket_type", - "venue", - "start_date", - "start_time", - "end_date", - "end_time", - "is_checked_in", - "check_in_time", - "check_in_date", - "booking_id", - "add_ons", -} +from buzz.tests.factories import ( + BuzzEventFactory, + EventBookingFactory, + EventPaymentFactory, + EventTicketFactory, +) class CheckinTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.event = frappe.get_doc("Buzz Event", {"route": "test-route"}).name + cls.event = BuzzEventFactory.create().name def setUp(self): - frappe.set_user("Administrator") frappe.clear_messages() + self.ticket = EventTicketFactory.create(event=self.event).name - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.event, - "title": f"Checkin Test {frappe.generate_hash(length=6)}", - } - ).insert() - - self.ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "event": self.event, - "ticket_type": self.ticket_type.name, - "attendee_name": "Scanned Attendee", - "attendee_email": "scanned@example.com", - } - ).insert() - - def make_booked_ticket(self) -> str: - """Book the ticket type and pay for it, and return the ticket that came out.""" - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.event, - "user": "Administrator", - "attendees": [ - { - "ticket_type": self.ticket_type.name, - "first_name": "Booked", - "email": "booked@example.com", - } - ], - } - ).insert() - booking.payment_status = "Paid" + def create_paid_ticket(self, amount: int) -> str: + booking = EventBookingFactory.create(event=self.event, payment_status="Paid") booking.submit() - - frappe.get_doc( - { - "doctype": "Event Payment", - "user": "Administrator", - "amount": booking.total_amount, - "currency": booking.currency, - "reference_doctype": "Event Booking", - "reference_docname": booking.name, - "payment_received": 1, - } - ).insert() - + EventPaymentFactory.create( + "received", + reference_doctype="Event Booking", + reference_docname=booking.name, + amount=amount, + currency=booking.currency, + ) return frappe.db.get_value("Event Ticket", {"booking": booking.name}, "name") - def tearDown(self): - frappe.db.rollback() - class TestValidateTicketForCheckin(CheckinTestCase): def test_unknown_ticket_raises_not_found(self): @@ -95,61 +41,48 @@ def test_unknown_ticket_raises_not_found(self): self.assertEqual(frappe.local.message_log[-1]["title"], "Ticket Not Found") - def test_status_codes(self): - self.assertEqual(TicketNotFound.http_status_code, 404) - self.assertEqual(TicketCancelled.http_status_code, 409) - self.assertEqual(AlreadyCheckedIn.http_status_code, 409) - - def test_response_shape(self): - response = validate_ticket_for_checkin(self.ticket.name).__json__() - - self.assertEqual(set(response), {"message", "ticket", "payment_details"}) - self.assertEqual(set(response["ticket"]), TICKET_FIELDS) - def test_ticket_reads_as_not_checked_in(self): - ticket = validate_ticket_for_checkin(self.ticket.name).__json__()["ticket"] + ticket = validate_ticket_for_checkin(self.ticket).__json__()["ticket"] - self.assertEqual(ticket["id"], self.ticket.name) + self.assertEqual(ticket["id"], self.ticket) self.assertFalse(ticket["is_checked_in"]) self.assertIsNone(ticket["check_in_time"]) self.assertIsNone(ticket["check_in_date"]) def test_payment_details_are_reported_for_a_paid_booking(self): - # Event Payment is named by autoincrement, so its name arrives as an int. - ticket = self.make_booked_ticket() + ticket = self.create_paid_ticket(amount=250) response = validate_ticket_for_checkin(ticket).__json__() - self.assertEqual(response["payment_details"]["amount"], 0) + self.assertEqual(response["payment_details"]["amount"], 250) def test_cancelled_ticket_is_rejected(self): - frappe.db.set_value("Event Ticket", self.ticket.name, "docstatus", 2) - frappe.clear_document_cache("Event Ticket", self.ticket.name) + frappe.db.set_value("Event Ticket", self.ticket, "docstatus", 2) + frappe.clear_document_cache("Event Ticket", self.ticket) with self.assertRaises(TicketCancelled): - validate_ticket_for_checkin(self.ticket.name) + validate_ticket_for_checkin(self.ticket) class TestCheckinTicket(CheckinTestCase): def test_checkin_records_and_reports(self): - response = checkin_ticket(self.ticket.name).__json__() + response = checkin_ticket(self.ticket).__json__() - self.assertEqual(set(response["ticket"]), TICKET_FIELDS) self.assertTrue(response["ticket"]["is_checked_in"]) self.assertIsNotNone(response["ticket"]["check_in_time"]) - self.assertTrue(frappe.db.exists("Event Check In", {"ticket": self.ticket.name})) + self.assertTrue(frappe.db.exists("Event Check In", {"ticket": self.ticket})) def test_second_checkin_same_day_is_rejected(self): - checkin_ticket(self.ticket.name) + checkin_ticket(self.ticket) frappe.clear_messages() with self.assertRaises(AlreadyCheckedIn): - checkin_ticket(self.ticket.name) + checkin_ticket(self.ticket) self.assertIn("already checked in today", frappe.local.message_log[-1]["message"]) def test_validation_rejects_an_already_checked_in_ticket(self): - checkin_ticket(self.ticket.name) + checkin_ticket(self.ticket) with self.assertRaises(AlreadyCheckedIn): - validate_ticket_for_checkin(self.ticket.name) + validate_ticket_for_checkin(self.ticket) diff --git a/buzz/api/communications/test_communications.py b/buzz/api/communications/test_communications.py index a9e49b01..c5b93998 100644 --- a/buzz/api/communications/test_communications.py +++ b/buzz/api/communications/test_communications.py @@ -1,4 +1,3 @@ -from email import message_from_string from unittest.mock import patch import frappe @@ -13,36 +12,16 @@ ) from buzz.api.communications.exceptions import CannotSendCommunication, NoRecipients from buzz.api.events.exceptions import CannotManageEvent -from buzz.api.events.test_events import create_event, issue_ticket from buzz.api.teams.exceptions import CannotEditTeam -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.proposals.doctype.talk_proposal.test_talk_proposal import make_guest_proposal -from buzz.test_permissions import add_member, create_ticket - - -def queued_emails(communication: str) -> list: - return frappe.get_all( - "Email Queue", - filters={"reference_doctype": "Event Communication", "reference_name": communication}, - fields=["name", "send_after", "message"], - ) - - -def queued_recipients(communication: str) -> set[str]: - queue_names = [row.name for row in queued_emails(communication)] - if not queue_names: - return set() - return set( - frappe.get_all("Email Queue Recipient", filters={"parent": ["in", queue_names]}, pluck="recipient") - ) - - -def html_part(raw_message: str) -> str: - """The HTML body, decoded: the queue stores it quoted-printable, wrapped at 76 columns.""" - for part in message_from_string(raw_message).walk(): - if part.get_content_type() == "text/html": - return part.get_payload(decode=True).decode() - return "" +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventTicketFactory, + TalkProposalFactory, + UserFactory, +) +from buzz.tests.utils import html_part, queued_emails, queued_recipients def forget_outgoing_account() -> None: @@ -54,21 +33,18 @@ class CommunicationsTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - frappe.set_user("Administrator") - if not frappe.db.exists("Event Category", "Test Category"): - frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert( - ignore_permissions=True - ) - cls.owner = create_user("comms-owner@example.com", "Owner") - cls.viewer = create_user("comms-viewer@example.com", "Viewer") - cls.manager = create_user("comms-manager@example.com", "Manager") - cls.team = create_owned_team("Comms Team", cls.owner) - add_member(cls.team, cls.viewer, "Viewer") - add_member(cls.team, cls.manager, "Manager") + cls.owner = UserFactory.create_once("comms-owner@example.com").name + cls.viewer = UserFactory.create_once("comms-viewer@example.com").name + cls.manager = UserFactory.create_once("comms-manager@example.com").name + # The list reads User.full_name, so the test pins it rather than trusting a cached name. + frappe.db.set_value( + "User", cls.manager, {"first_name": "Mira", "last_name": "Sender", "full_name": "Mira Sender"} + ) + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.manager, team_role="Manager") def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") # CI has no outgoing account. Muted, frappe queues against a dummy one and never sends; # the dummy is cached on frappe.local, so it is dropped before the next module runs. frappe.flags.mute_emails = True @@ -76,130 +52,139 @@ def setUp(self): self.addCleanup(forget_outgoing_account) # CI never builds assets, and every email inlines email.bundle.css through this lookup. self.enterContext(patch("frappe.utils.get_assets_json", return_value={})) - self.event = create_event(f"Comms {frappe.generate_hash(length=6)}", self.team) + self.event = str(BuzzEventFactory.create(team=self.team).name) + + def issue_ticket(self, attendee_email: str) -> str: + return EventTicketFactory.create("submitted", event=self.event, attendee_email=attendee_email).name + + def create_talk_proposal(self, speaker_email: str) -> str: + speakers = [{"first_name": "Speaker", "email": speaker_email}] + return TalkProposalFactory.create(event=self.event, speakers=speakers).name + + def set_support_email(self, support_email: str | None): + BuzzTeamFactory.set_settings(self.team, {"support_email": support_email}) class TestCountRecipients(CommunicationsTestCase): def test_counts_every_submitted_ticket_holder_once(self): - issue_ticket(self.event, "one@example.com") - issue_ticket(self.event, "two@example.com") - create_ticket(self.event, "one@example.com") # a second ticket, same address - create_ticket(self.event, "draft@example.com") # unsubmitted: still being paid for - frappe.set_user(self.owner) + self.issue_ticket("one@example.com") + self.issue_ticket("two@example.com") + self.issue_ticket("one@example.com") # a second ticket, same address + EventTicketFactory.create( + event=self.event, attendee_email="draft@example.com" + ) # still being paid for - self.assertEqual(count_recipients(self.event, "Guests").count, 2) + with self.set_user(self.owner): + self.assertEqual(count_recipients(self.event, "Guests").count, 2) def test_narrows_guests_by_ticket_type(self): - ticket = issue_ticket(self.event, "tiered@example.com") - issue_ticket(self.event, "other@example.com") - ticket_type = frappe.db.get_value("Event Ticket", ticket, "ticket_type") - frappe.set_user(self.owner) + ticket = EventTicketFactory.create("submitted", event=self.event, attendee_email="tiered@example.com") + self.issue_ticket("other@example.com") - self.assertEqual(count_recipients(self.event, "Guests", ticket_types=str(ticket_type)).count, 1) + with self.set_user(self.owner): + count = count_recipients(self.event, "Guests", ticket_types=str(ticket.ticket_type)).count + + self.assertEqual(count, 1) def test_counts_proposal_speakers_once(self): - make_guest_proposal(self.event, "speaker@example.com") - make_guest_proposal(self.event, "speaker@example.com") - make_guest_proposal(self.event, "second@example.com") - frappe.set_user(self.owner) + self.create_talk_proposal("speaker@example.com") + self.create_talk_proposal("speaker@example.com") + self.create_talk_proposal("second@example.com") - self.assertEqual(count_recipients(self.event, "Speakers").count, 2) + with self.set_user(self.owner): + self.assertEqual(count_recipients(self.event, "Speakers").count, 2) def test_narrows_speakers_by_proposal_status(self): - accepted = make_guest_proposal(self.event, "yes@example.com") - make_guest_proposal(self.event, "pending@example.com") + accepted = self.create_talk_proposal("yes@example.com") + self.create_talk_proposal("pending@example.com") frappe.db.set_value("Talk Proposal", accepted, "status", "Accepted") - frappe.set_user(self.owner) - self.assertEqual(count_recipients(self.event, "Speakers", statuses="Accepted").count, 1) + with self.set_user(self.owner): + self.assertEqual(count_recipients(self.event, "Speakers", statuses="Accepted").count, 1) def test_an_outsider_is_refused(self): - frappe.set_user(create_user("comms-outsider@example.com", "Outsider")) + outsider = UserFactory.create_once("comms-outsider@example.com").name - with self.assertRaises(CannotManageEvent): + with self.set_user(outsider), self.assertRaises(CannotManageEvent): count_recipients(self.event, "Guests") class TestSendCommunication(CommunicationsTestCase): def test_queues_one_email_per_guest_with_the_team_reply_to(self): - frappe.db.set_value("Buzz Team Settings", self.team, "support_email", "help@example.com") - frappe.clear_document_cache("Buzz Team Settings", self.team) - issue_ticket(self.event, "a@example.com") - issue_ticket(self.event, "b@example.com") - frappe.set_user(self.manager) + self.set_support_email("help@example.com") + self.issue_ticket("a@example.com") + self.issue_ticket("b@example.com") - sent = send_communication(self.event, "Guests", "

Doors open at 9.

", subject="Doors") + with self.set_user(self.manager): + sent = send_communication(self.event, "Guests", "

Doors open at 9.

", subject="Doors") self.assertEqual(sent.recipient_count, 2) self.assertEqual(queued_recipients(sent.name), {"a@example.com", "b@example.com"}) self.assertIn("Reply-To: help@example.com", queued_emails(sent.name)[0].message) def test_without_a_support_email_replies_go_to_the_sender(self): - frappe.db.set_value("Buzz Team Settings", self.team, "support_email", None) - frappe.clear_document_cache("Buzz Team Settings", self.team) - issue_ticket(self.event, "c@example.com") - frappe.set_user(self.manager) + self.set_support_email(None) + self.issue_ticket("c@example.com") - sent = send_communication(self.event, "Guests", "

Hi

") + with self.set_user(self.manager): + sent = send_communication(self.event, "Guests", "

Hi

") self.assertIn(f"Reply-To: {self.manager}", queued_emails(sent.name)[0].message) def test_a_schedule_lands_on_the_queue_as_send_after(self): - issue_ticket(self.event, "later@example.com") + self.issue_ticket("later@example.com") later = add_to_date(now_datetime(), hours=2).replace(microsecond=0) - frappe.set_user(self.manager) - sent = send_communication(self.event, "Guests", "

Soon.

", scheduled_at=later) + with self.set_user(self.manager): + sent = send_communication(self.event, "Guests", "

Soon.

", scheduled_at=later) self.assertEqual(sent.scheduled_at, later) self.assertEqual(queued_emails(sent.name)[0].send_after, later) def test_no_subject_falls_back_to_the_event_title(self): - issue_ticket(self.event, "titled@example.com") - frappe.set_user(self.manager) + title = "Comms Launch Night" + frappe.db.set_value("Buzz Event", self.event, "title", title) + frappe.clear_document_cache("Buzz Event", self.event) + self.issue_ticket("titled@example.com") - sent = send_communication(self.event, "Guests", "

Hi

") + with self.set_user(self.manager): + sent = send_communication(self.event, "Guests", "

Hi

") - title = frappe.db.get_value("Buzz Event", self.event, "title") self.assertEqual(sent.subject, "") self.assertIn(f"Subject: {title}", queued_emails(sent.name)[0].message) def test_nobody_to_send_to_is_refused(self): - frappe.set_user(self.manager) - - with self.assertRaises(NoRecipients): + with self.set_user(self.manager), self.assertRaises(NoRecipients): send_communication(self.event, "Speakers", "

Hi

") def test_a_viewer_cannot_send(self): - issue_ticket(self.event, "v@example.com") - frappe.set_user(self.viewer) + self.issue_ticket("v@example.com") - with self.assertRaises(CannotSendCommunication): + with self.set_user(self.viewer), self.assertRaises(CannotSendCommunication): send_communication(self.event, "Guests", "

Hi

") class TestGetEventCommunications(CommunicationsTestCase): def test_lists_what_was_sent_newest_first_with_the_sender(self): - issue_ticket(self.event, "list@example.com") - frappe.set_user(self.manager) - first = send_communication(self.event, "Guests", "

One

", subject="First") - second = send_communication(self.event, "Guests", "

Two

", subject="Second") - frappe.set_user(self.viewer) + self.issue_ticket("list@example.com") + with self.set_user(self.manager): + first = send_communication(self.event, "Guests", "

One

", subject="First") + second = send_communication(self.event, "Guests", "

Two

", subject="Second") - page = get_event_communications(self.event) + with self.set_user(self.viewer): + page = get_event_communications(self.event) self.assertEqual([row.name for row in page.communications], [second.name, first.name]) - self.assertEqual(page.communications[0].sent_by, "Manager") + self.assertEqual(page.communications[0].sent_by, "Mira Sender") self.assertFalse(page.can_write) self.assertFalse(page.can_edit_settings) def test_carries_the_filter_options_and_the_support_email(self): - frappe.db.set_value("Buzz Team Settings", self.team, "support_email", "team@example.com") - frappe.clear_document_cache("Buzz Team Settings", self.team) - issue_ticket(self.event, "opt@example.com") - frappe.set_user(self.owner) + self.set_support_email("team@example.com") + self.issue_ticket("opt@example.com") - page = get_event_communications(self.event) + with self.set_user(self.owner): + page = get_event_communications(self.event) self.assertEqual(page.support_email, "team@example.com") self.assertTrue(page.ticket_types) @@ -209,42 +194,27 @@ def test_carries_the_filter_options_and_the_support_email(self): class TestUpdateSupportEmail(CommunicationsTestCase): def test_an_owner_sets_the_team_support_email(self): - frappe.set_user(self.owner) - - update_support_email(self.event, "reply@example.com") + with self.set_user(self.owner): + update_support_email(self.event, "reply@example.com") self.assertEqual( frappe.db.get_value("Buzz Team Settings", self.team, "support_email"), "reply@example.com" ) def test_a_manager_cannot(self): - frappe.set_user(self.manager) - - with self.assertRaises(CannotEditTeam): + with self.set_user(self.manager), self.assertRaises(CannotEditTeam): update_support_email(self.event, "reply@example.com") def test_a_bad_address_is_refused(self): - frappe.set_user(self.owner) - - with self.assertRaises(frappe.InvalidEmailAddressError): + with self.set_user(self.owner), self.assertRaises(frappe.InvalidEmailAddressError): update_support_email(self.event, "not-an-email") class TestCommunicationTemplate(CommunicationsTestCase): - def sent_html(self, **event_values) -> str: - if event_values: - frappe.db.set_value("Buzz Event", self.event, event_values) - frappe.clear_document_cache("Buzz Event", self.event) - issue_ticket(self.event, "template@example.com") - frappe.set_user(self.manager) - sent = send_communication(self.event, "Guests", "

Bring a jacket.

") - return html_part(queued_emails(sent.name)[0].message) - def test_wraps_the_message_in_the_event_header_and_footer(self): - html = self.sent_html(route=f"comms-{frappe.generate_hash(length=6)}", is_published=1) + title, route = "Comms Template Night", f"comms-{frappe.generate_hash(length=6)}" + html = self.sent_email_html(title=title, route=route) - title = frappe.db.get_value("Buzz Event", self.event, "title") - route = frappe.db.get_value("Buzz Event", self.event, "route") self.assertIn(title, html) self.assertIn("Bring a jacket.", html) self.assertIn("View event", html) @@ -253,9 +223,18 @@ def test_wraps_the_message_in_the_event_header_and_footer(self): self.assertNotIn("Unsubscribe", html) def test_shows_the_banner_only_when_the_event_has_one(self): - self.assertNotIn("data-banner", self.sent_html()) + self.assertNotIn("data-banner", self.sent_email_html()) def test_shows_the_banner_when_the_event_has_one(self): - html = self.sent_html(banner_image="/files/banner.png") + html = self.sent_email_html(banner_image="/files/banner.png") self.assertIn("/files/banner.png", html) + + def sent_email_html(self, **event_values) -> str: + if event_values: + frappe.db.set_value("Buzz Event", self.event, event_values) + frappe.clear_document_cache("Buzz Event", self.event) + self.issue_ticket("template@example.com") + with self.set_user(self.manager): + sent = send_communication(self.event, "Guests", "

Bring a jacket.

") + return html_part(queued_emails(sent.name)[0].message) diff --git a/buzz/api/events/test_archive_event.py b/buzz/api/events/test_archive_event.py new file mode 100644 index 00000000..3f51604f --- /dev/null +++ b/buzz/api/events/test_archive_event.py @@ -0,0 +1,65 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.api.events import archive_event +from buzz.api.events.exceptions import CannotManageEvent +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, UserFactory + + +class TestArchiveEvent(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("archive-owner@example.com").name + cls.outsider = UserFactory.create_once("archive-outsider@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + + def test_archiving_unpublishes_the_event_and_its_forms(self): + event = self.create_event(is_published=1) + publish_forms(event) + + self.archive_as(self.owner, event) + + doc = frappe.get_doc("Buzz Event", event) + self.assertFalse(doc.is_published) + self.assertFalse(any(row.publish for row in doc.custom_forms)) + + def test_archiving_closes_registrations(self): + """The booking gates stop at `is_published`, but the cutoff has to agree with them.""" + event = self.create_event(is_published=1) + frappe.db.set_value("Buzz Event", event, "registrations_close_at", None) + + self.archive_as(self.owner, event) + + self.assertIsNotNone(frappe.db.get_value("Buzz Event", event, "registrations_close_at")) + + def test_archiving_an_archived_event_is_a_no_op(self): + """validate() runs on the way through, so a second archive must not throw.""" + event = self.create_event("unpublished") + + self.archive_as(self.owner, event) + + self.assertFalse(frappe.db.get_value("Buzz Event", event, "is_published")) + + def test_someone_outside_the_team_cannot_archive(self): + event = self.create_event(is_published=1) + + with self.assertRaises(CannotManageEvent): + self.archive_as(self.outsider, event) + + self.assertTrue(frappe.db.get_value("Buzz Event", event, "is_published")) + + def create_event(self, *traits: str, **overrides) -> str: + return str(BuzzEventFactory.create(*traits, team=self.team, **overrides).name) + + def archive_as(self, user: str, event: str): + with self.set_user(user): + archive_event(event) + + +def publish_forms(event: str): + """`publish` defaults to 0, so the rows have to be opened before a close means anything.""" + doc = frappe.get_doc("Buzz Event", event) + for row in doc.custom_forms: + row.publish = 1 + doc.save(ignore_permissions=True) diff --git a/buzz/api/events/test_co_hosts.py b/buzz/api/events/test_co_hosts.py new file mode 100644 index 00000000..d559743e --- /dev/null +++ b/buzz/api/events/test_co_hosts.py @@ -0,0 +1,93 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.api.events import add_co_host, get_event, remove_co_host +from buzz.api.events.exceptions import CannotManageEvent +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory + + +class TestEventCoHosts(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("co-host-owner@example.com").name + cls.viewer = UserFactory.create_once("co-host-viewer@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner, team_name="Co-host Team").name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") + + def setUp(self): + self.event = str(BuzzEventFactory.create(team=self.team).name) + self.enterContext(self.set_user(self.owner)) + + def test_the_team_is_the_primary_host(self): + detail = get_event(self.event).__json__() + + self.assertEqual(detail["primary_host"]["host"], self.team) + self.assertEqual(detail["primary_host"]["label"], "Co-host Team") + self.assertEqual(detail["co_hosts"], []) + + def test_adding_and_removing_a_co_host_round_trips(self): + added = add_co_host(self.event, "Acme Corp", by_line="We make things") + + self.assertEqual(self.co_hosts()[0]["label"], "Acme Corp") + self.assertEqual(frappe.db.get_value("Event Host", added.host, "team"), self.team) + + remove_co_host(self.event, added.host) + self.assertEqual(self.co_hosts(), []) + + def test_the_same_organisation_cannot_be_added_twice(self): + added = add_co_host(self.event, "Acme Corp") + event = frappe.get_doc("Buzz Event", self.event) + event.append("co_hosts", {"host": added.host}) + + with self.assertRaises(frappe.ValidationError): + event.save() + + def test_the_same_name_is_not_added_twice(self): + added = add_co_host(self.event, "Acme Corp") + + with self.assertRaises(frappe.ValidationError): + add_co_host(self.event, "Acme Corp") + + # The second call reuses the team's host rather than minting a second record. + self.assertEqual(frappe.db.count("Event Host", {"host_name": "Acme Corp", "team": self.team}), 1) + self.assertEqual(self.co_hosts()[0]["host"], added.host) + + def test_external_links_come_back_in_table_order(self): + self.save_links( + {"icon": "map-pin", "label": "Venue map", "url": "https://maps.example.com"}, + {"label": "Slides", "url": "https://slides.example.com"}, + ) + + links = get_event(self.event).__json__()["external_links"] + + self.assertEqual([link["label"] for link in links], ["Venue map", "Slides"]) + self.assertEqual(links[0]["icon"], "map-pin") + self.assertIsNone(links[1]["icon"]) + + def test_an_external_link_needs_a_valid_url(self): + with self.assertRaises(frappe.ValidationError): + self.save_links({"label": "Broken", "url": "not a url"}) + + def test_an_external_link_must_be_a_web_address(self): + with self.assertRaises(frappe.ValidationError): + self.save_links({"label": "Script", "url": "javascript:alert(1)"}) + + def test_a_viewer_cannot_add_a_co_host(self): + with self.set_user(self.viewer), self.assertRaises(CannotManageEvent): + add_co_host(self.event, "Acme Corp") + + def test_a_viewer_cannot_remove_a_co_host(self): + added = add_co_host(self.event, "Acme Corp") + + with self.set_user(self.viewer), self.assertRaises(CannotManageEvent): + remove_co_host(self.event, added.host) + + def co_hosts(self) -> list[dict]: + return get_event(self.event).__json__()["co_hosts"] + + def save_links(self, *links: dict): + event = frappe.get_doc("Buzz Event", self.event) + for link in links: + event.append("external_links", link) + event.save() diff --git a/buzz/api/events/test_event_guests.py b/buzz/api/events/test_event_guests.py new file mode 100644 index 00000000..58bc100b --- /dev/null +++ b/buzz/api/events/test_event_guests.py @@ -0,0 +1,133 @@ +import json + +from frappe.tests import IntegrationTestCase + +from buzz.api.events import get_event, get_event_guests +from buzz.api.events.exceptions import CannotManageEvent, EventNotFound +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventTicketFactory, + EventTicketTypeFactory, + TicketAddOnFactory, + UserFactory, +) + + +class GuestsTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("guests-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + + def setUp(self): + self.event = str(BuzzEventFactory.create(team=self.team, title="Guest List Event").name) + + def guests_as(self, user: str, **arguments): + with self.set_user(user): + return get_event_guests(self.event, **arguments) + + +class TestGetEventGuests(GuestsTestCase): + def test_counts_and_lists_the_submitted_tickets(self): + for email in ("guest-one@example.com", "guest-two@example.com"): + EventTicketFactory.create("submitted", event=self.event, attendee_email=email) + + guests = self.guests_as(self.owner).__json__() + + self.assertEqual(guests["total"], 2) + emails = {guest["attendee_email"] for guest in guests["guests"]} + self.assertEqual(emails, {"guest-one@example.com", "guest-two@example.com"}) + + def test_leaves_out_a_ticket_that_was_never_submitted(self): + EventTicketFactory.create(event=self.event) + + self.assertEqual(self.guests_as(self.owner).total, 0) + + def test_carries_the_add_ons_a_ticket_holds(self): + add_on = TicketAddOnFactory.create(event=self.event, title="T-Shirt").name + add_ons = [{"add_on": add_on, "value": "Large"}] + EventTicketFactory.create("submitted", event=self.event, add_ons=add_ons) + + guest = self.guests_as(self.owner).guests[0] + + self.assertEqual([(row.title, row.value) for row in guest.add_ons], [("T-Shirt", "Large")]) + + def test_names_the_ticket_type_rather_than_its_docname(self): + ticket_type = EventTicketTypeFactory.create(event=self.event, title="Early Bird").name + EventTicketFactory.create("submitted", event=self.event, ticket_type=ticket_type) + + self.assertEqual(self.guests_as(self.owner).guests[0].ticket_type, "Early Bird") + + def test_an_event_with_no_guests_is_empty_rather_than_an_error(self): + guests = self.guests_as(self.owner) + + self.assertEqual(guests.total, 0) + self.assertEqual(guests.guests, []) + + def test_names_the_event_for_a_member_who_cannot_edit_it(self): + """The header labels the page off this payload, so read access has to be enough.""" + viewer = UserFactory.create_once("guests-viewer@example.com").name + BuzzTeamMembershipFactory.create(team=self.team, user=viewer, team_role="Viewer") + + self.assertEqual(self.guests_as(viewer).title, "Guest List Event") + with self.set_user(viewer), self.assertRaises(CannotManageEvent): + get_event(self.event) + + def test_a_non_member_cannot_read_the_guest_list(self): + outsider = UserFactory.create_once("guests-stranger@example.com").name + EventTicketFactory.create("submitted", event=self.event) + + with self.assertRaises(CannotManageEvent): + self.guests_as(outsider) + + def test_an_unknown_event_is_not_found(self): + with self.set_user(self.owner), self.assertRaises(EventNotFound): + get_event_guests("999999999") + + +class TestGetEventGuestsTicketTypeFilter(GuestsTestCase): + def setUp(self): + super().setUp() + self.first = self.submit_ticket_on_new_type("early@example.com") + self.second = self.submit_ticket_on_new_type("late@example.com") + + def test_lists_the_types_the_event_sells(self): + """Every type, not only the ones someone has bought — an empty tier is still a filter.""" + unsold = str(EventTicketTypeFactory.create(event=self.event).name) + + filter_fields = self.guests_as(self.owner).filter_fields + ticket_type = next(field for field in filter_fields if field.key == "ticket_type") + + self.assertLessEqual( + {self.first, self.second, unsold}, {option.value for option in ticket_type.options} + ) + + def test_narrows_the_list_to_the_chosen_type(self): + guests = self.guests_as(self.owner, filters=types_filter(self.first)) + + self.assertEqual([guest.attendee_email for guest in guests.guests], ["early@example.com"]) + self.assertEqual(guests.matched, 1) + self.assertEqual(guests.total, 2) + + def test_several_types_are_read_as_any_of_them(self): + self.assertEqual(self.guests_as(self.owner, filters=types_filter(self.first, self.second)).matched, 2) + + def test_no_chosen_type_is_not_a_filter(self): + self.assertEqual(len(self.guests_as(self.owner, filters=types_filter()).guests), 2) + + def test_search_and_type_narrow_together(self): + late = self.guests_as(self.owner, search="late@", filters=types_filter(self.first)) + early = self.guests_as(self.owner, search="early@", filters=types_filter(self.first)) + + self.assertEqual((late.matched, early.matched), (0, 1)) + + def submit_ticket_on_new_type(self, email: str) -> str: + """Each ticket comes on a ticket type of its own.""" + return str(EventTicketFactory.create("submitted", event=self.event, attendee_email=email).ticket_type) + + +def types_filter(*types: str) -> str: + return json.dumps([["ticket_type", "in", list(types)]]) diff --git a/buzz/api/events/test_event_guests_paging.py b/buzz/api/events/test_event_guests_paging.py new file mode 100644 index 00000000..96272a17 --- /dev/null +++ b/buzz/api/events/test_event_guests_paging.py @@ -0,0 +1,129 @@ +from datetime import datetime + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, get_datetime, today + +from buzz.api.events import get_event_guests +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, EventTicketFactory, UserFactory + + +class TestGetEventGuestsPaging(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("paging-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + + def setUp(self): + self.event = str(BuzzEventFactory.create(team=self.team).name) + + def test_carries_the_time_the_ticket_was_raised(self): + registered_at = self.register_guest("Ana", "Diaz", days_ago=3) + + self.assertEqual(self.guests().guests[0].registered_at, registered_at) + + def test_newest_registration_comes_first_by_default(self): + self.register_three_guests() + + self.assertEqual(self.names(self.guests()), ["Cy Ferreira", "Bo Chen", "Ana Diaz"]) + + def test_asc_walks_from_the_oldest_registration(self): + self.register_three_guests() + + self.assertEqual(self.names(self.guests(order="asc")), ["Ana Diaz", "Bo Chen", "Cy Ferreira"]) + + def test_an_unknown_order_falls_back_to_newest_first(self): + self.register_three_guests() + + guests = self.guests(order="name desc; drop table") + + self.assertEqual(guests.guests[0].attendee_name, "Cy Ferreira") + + def test_a_page_carries_only_its_own_slice(self): + self.register_three_guests() + + first = self.guests(limit=2) + + self.assertEqual(self.names(first), ["Cy Ferreira", "Bo Chen"]) + self.assertTrue(first.has_next_page) + + def test_the_last_page_says_there_is_nothing_after_it(self): + self.register_three_guests() + + last = self.guests(start=2, limit=2) + + self.assertEqual(self.names(last), ["Ana Diaz"]) + self.assertFalse(last.has_next_page) + + def test_a_full_final_page_is_still_the_end(self): + """Four guests read two at a time: the second page fills, and nothing follows.""" + self.register_three_guests() + self.register_guest("Di", "Okafor", days_ago=0) + + self.assertFalse(self.guests(start=2, limit=2).has_next_page) + + def test_search_matches_a_name(self): + self.register_three_guests() + + self.assertEqual(self.names(self.guests(search="chen")), ["Bo Chen"]) + + def test_search_matches_an_email(self): + self.register_three_guests() + + guests = self.guests(search="cy@") + + self.assertEqual([guest.attendee_email for guest in guests.guests], ["cy@example.com"]) + + def test_search_reports_the_match_count_beside_the_registered_count(self): + self.register_three_guests() + + guests = self.guests(search="chen") + + self.assertEqual((guests.total, guests.matched), (3, 1)) + + def test_without_a_search_every_guest_is_a_match(self): + self.register_three_guests() + + guests = self.guests() + + self.assertEqual(guests.matched, guests.total) + + def test_a_search_that_matches_nobody_is_empty_rather_than_an_error(self): + self.register_three_guests() + + guests = self.guests(search="nobody-here") + + self.assertEqual(guests.guests, []) + self.assertEqual((guests.matched, guests.total), (0, 3)) + self.assertFalse(guests.has_next_page) + + def test_blank_search_is_not_a_filter(self): + self.register_three_guests() + + self.assertEqual(len(self.guests(search=" ").guests), 3) + + def register_three_guests(self): + self.register_guest("Ana", "Diaz", days_ago=3) + self.register_guest("Bo", "Chen", days_ago=2) + self.register_guest("Cy", "Ferreira", days_ago=1) + + def register_guest(self, first_name: str, last_name: str, days_ago: int) -> datetime: + """Tickets a test writes land in the same second, so `creation` is set to order them.""" + ticket = EventTicketFactory.create( + "submitted", + event=self.event, + first_name=first_name, + last_name=last_name, + attendee_email=f"{first_name.lower()}@example.com", + ) + registered_at = get_datetime(f"{add_days(today(), -days_ago)} 09:00:00") + frappe.db.set_value("Event Ticket", ticket.name, "creation", registered_at, update_modified=False) + return registered_at + + def guests(self, **arguments): + with self.set_user(self.owner): + return get_event_guests(self.event, **arguments) + + def names(self, guests) -> list[str]: + return [guest.attendee_name for guest in guests.guests] diff --git a/buzz/api/events/test_event_route.py b/buzz/api/events/test_event_route.py new file mode 100644 index 00000000..2e9f2e11 --- /dev/null +++ b/buzz/api/events/test_event_route.py @@ -0,0 +1,44 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.api.events import check_event_route +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, UserFactory + + +class TestCheckEventRoute(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("check-route-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + + def setUp(self): + self.route = f"route-{frappe.generate_hash(length=8)}" + + def test_an_unused_route_is_available(self): + self.assertTrue(self.route_availability(self.route).available) + + def test_a_route_another_event_holds_is_taken(self): + BuzzEventFactory.create(team=self.team, route=self.route) + + self.assertFalse(self.route_availability(self.route).available) + + def test_an_unpublished_event_still_holds_its_route(self): + BuzzEventFactory.create("unpublished", team=self.team, route=self.route) + + self.assertFalse(self.route_availability(self.route).available) + + def test_an_event_does_not_block_its_own_route(self): + event = str(BuzzEventFactory.create(team=self.team, route=self.route).name) + + self.assertTrue(self.route_availability(self.route, event=event).available) + + def test_a_reserved_route_is_refused(self): + self.assertFalse(self.route_availability("account").available) + + def test_a_blank_route_is_not_available(self): + self.assertFalse(self.route_availability(" ").available) + + def route_availability(self, route: str, event: str | None = None): + with self.set_user(self.owner): + return check_event_route(route, event=event) diff --git a/buzz/api/events/test_events.py b/buzz/api/events/test_events.py index 6a85c59c..bf8cce7c 100644 --- a/buzz/api/events/test_events.py +++ b/buzz/api/events/test_events.py @@ -1,126 +1,61 @@ -import json - import frappe from frappe.tests import IntegrationTestCase -from frappe.utils import add_days, getdate, today +from frappe.utils import add_days, today from pydantic import ValidationError -from buzz.api.events import ( - add_co_host, - archive_event, - check_event_route, - get_event, - get_event_guests, - get_event_registration_trend, - get_event_ticket_types, - get_my_events, - get_verification_methods, - remove_co_host, - set_registration_state, -) -from buzz.api.events import create_event as create_event_endpoint -from buzz.api.events.exceptions import ( - CannotCreateEvents, - CannotManageEvent, - EventNotFound, - ZoomNotAvailable, +from buzz.api.events import get_my_events +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventTicketFactory, + UserFactory, ) -from buzz.api.events.schemas import NewEvent -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user, payload_for -from buzz.test_permissions import add_member, create_ticket -from buzz.utils import is_app_installed - - -def create_event(title: str, team: str, **overrides) -> str: - payload = payload_for("Buzz Event", title) | { - "start_date": add_days(today(), 30), - "end_date": add_days(today(), 31), - } - event = frappe.get_doc({**payload, "team": team, **overrides}).insert(ignore_permissions=True) - return str(event.name) - - -def issue_ticket(event: str, user: str) -> str: - """The booking flow submits every ticket it generates; the fixture stops at insert.""" - ticket = create_ticket(event, user) - frappe.get_doc("Event Ticket", ticket).submit() - return ticket class TestGetMyEvents(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - frappe.set_user("Administrator") - - if not frappe.db.exists("Event Category", "Test Category"): - frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert( - ignore_permissions=True - ) - - cls.host_user = create_user("events-host@example.com", "Host") - cls.attendee = create_user("events-attendee@example.com", "Attendee") - cls.host_team = create_owned_team("My Events Host Team", cls.host_user) - cls.other_team = create_owned_team("My Events Other Team", cls.attendee) - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def events_of(self, user: str, **filters) -> dict[str, list[dict]]: - frappe.set_user(user) - return get_my_events(filters or None).__json__() - - def names_in(self, events: list[dict]) -> list[str]: - return [event["name"] for event in events] + cls.host_user = UserFactory.create_once("events-host@example.com").name + cls.attendee = UserFactory.create_once("events-attendee@example.com").name + cls.host_team = BuzzTeamFactory.create_owned_by(cls.host_user, team_name="My Events Host Team").name + cls.other_team = BuzzTeamFactory.create_owned_by(cls.attendee).name def test_returns_unpublished_event_of_own_team_as_host(self): - event = create_event("Own Draft", self.host_team, is_published=0) + event = self.create_team_event(self.host_team, "unpublished") - upcoming = self.events_of(self.host_user)["upcoming"] - - self.assertIn(event, self.names_in(upcoming)) - self.assertTrue(next(row for row in upcoming if row["name"] == event)["is_host"]) + self.assertTrue(self.row_of(self.upcoming_of(self.host_user), event)["is_host"]) def test_returns_ticketed_event_of_another_team_as_guest(self): - event = create_event("Ticketed Elsewhere", self.host_team) - issue_ticket(event, self.attendee) - - upcoming = self.events_of(self.attendee)["upcoming"] + event = self.create_team_event(self.host_team) + self.submit_ticket(event, self.attendee) - self.assertIn(event, self.names_in(upcoming)) - self.assertFalse(next(row for row in upcoming if row["name"] == event)["is_host"]) + self.assertFalse(self.row_of(self.upcoming_of(self.attendee), event)["is_host"]) def test_returns_hosted_and_ticketed_event_once_as_host(self): - event = create_event("Hosting And Attending", self.host_team) - issue_ticket(event, self.host_user) + event = self.create_team_event(self.host_team) + self.submit_ticket(event, self.host_user) - upcoming = self.events_of(self.host_user)["upcoming"] + upcoming = self.upcoming_of(self.host_user) self.assertEqual(self.names_in(upcoming).count(event), 1) - self.assertTrue(next(row for row in upcoming if row["name"] == event)["is_host"]) + self.assertTrue(self.row_of(upcoming, event)["is_host"]) def test_ignores_a_draft_ticket(self): - event = create_event("Never Booked", self.other_team) - create_ticket(event, self.host_user) + event = self.create_team_event(self.other_team) + EventTicketFactory.create(event=event, attendee_email=self.host_user) - events = self.events_of(self.host_user) - - self.assertNotIn(event, self.names_in(events["upcoming"] + events["past"])) + self.assertNotIn(event, self.all_events_of(self.host_user)) def test_excludes_another_teams_event_without_a_ticket(self): - event = create_event("Someone Elses", self.other_team, is_published=1) + event = self.create_team_event(self.other_team, is_published=1) - events = self.events_of(self.host_user) - - self.assertNotIn(event, self.names_in(events["upcoming"] + events["past"])) + self.assertNotIn(event, self.all_events_of(self.host_user)) def test_keeps_an_event_in_progress_upcoming(self): - event = create_event( - "Still Running", - self.host_team, - start_date=add_days(today(), -1), - end_date=add_days(today(), 1), + event = self.create_team_event( + self.host_team, start_date=add_days(today(), -1), end_date=add_days(today(), 1) ) events = self.events_of(self.host_user) @@ -129,11 +64,8 @@ def test_keeps_an_event_in_progress_upcoming(self): self.assertNotIn(event, self.names_in(events["past"])) def test_moves_a_finished_event_to_past(self): - event = create_event( - "Wrapped Up", - self.host_team, - start_date=add_days(today(), -3), - end_date=add_days(today(), -1), + event = self.create_team_event( + self.host_team, start_date=add_days(today(), -3), end_date=add_days(today(), -1) ) events = self.events_of(self.host_user) @@ -142,73 +74,65 @@ def test_moves_a_finished_event_to_past(self): self.assertNotIn(event, self.names_in(events["upcoming"])) def test_drops_hosted_events_once_the_membership_is_disabled(self): - member = create_user("events-lapsed@example.com", "Lapsed") - membership = add_member(self.host_team, member, "Manager") - event = create_event("Lapsed Access", self.host_team) - self.assertIn(event, self.names_in(self.events_of(member)["upcoming"])) + member = UserFactory.create_once("events-lapsed@example.com").name + membership = BuzzTeamMembershipFactory.create(team=self.host_team, user=member, team_role="Manager") + event = self.create_team_event(self.host_team) + self.assertIn(event, self.names_in(self.upcoming_of(member))) - frappe.set_user("Administrator") - frappe.db.set_value("Buzz Team Membership", membership, "enabled", 0) + frappe.db.set_value("Buzz Team Membership", membership.name, "enabled", 0) - self.assertNotIn(event, self.names_in(self.events_of(member)["upcoming"])) + self.assertNotIn(event, self.names_in(self.upcoming_of(member))) def test_carries_the_organising_team(self): frappe.db.set_value("Buzz Team", self.host_team, "logo", "/files/team-logo.png") - event = create_event("Team Stamped", self.host_team) + event = self.create_team_event(self.host_team) - row = next(row for row in self.events_of(self.host_user)["upcoming"] if row["name"] == event) + row = self.row_of(self.upcoming_of(self.host_user), event) self.assertEqual(row["team"], self.host_team) self.assertEqual(row["team_name"], "My Events Host Team") self.assertEqual(row["team_logo"], "/files/team-logo.png") def test_survives_an_event_with_no_team(self): - event = create_event("Unstamped", self.host_team) + event = self.create_team_event(self.host_team) frappe.db.set_value("Buzz Event", event, "team", None) - ticket = issue_ticket(event, self.host_user) - self.assertTrue(ticket) + self.submit_ticket(event, self.host_user) - row = next(row for row in self.events_of(self.host_user)["upcoming"] if row["name"] == event) + row = self.row_of(self.upcoming_of(self.host_user), event) self.assertIsNone(row["team"]) self.assertIsNone(row["team_name"]) self.assertIsNone(row["team_logo"]) def test_a_role_filter_keeps_only_hosted_events(self): - hosted = create_event("Role Filter Hosted", self.host_team) - ticketed = create_event("Role Filter Ticketed", self.other_team) - issue_ticket(ticketed, self.host_user) + hosted, ticketed = self.hosted_and_ticketed() - names = self.names_in(self.events_of(self.host_user, role="hosting")["upcoming"]) + names = self.names_in(self.upcoming_of(self.host_user, role="hosting")) self.assertIn(hosted, names) self.assertNotIn(ticketed, names) def test_a_role_filter_keeps_only_ticketed_events(self): - hosted = create_event("Role Filter Own", self.host_team) - ticketed = create_event("Role Filter Guest", self.other_team) - issue_ticket(ticketed, self.host_user) + hosted, ticketed = self.hosted_and_ticketed() - names = self.names_in(self.events_of(self.host_user, role="attending")["upcoming"]) + names = self.names_in(self.upcoming_of(self.host_user, role="attending")) self.assertIn(ticketed, names) self.assertNotIn(hosted, names) def test_a_team_filter_keeps_only_that_teams_events(self): - mine = create_event("Team Filter Mine", self.host_team) - theirs = create_event("Team Filter Theirs", self.other_team) - issue_ticket(theirs, self.host_user) + hosted, ticketed = self.hosted_and_ticketed() - names = self.names_in(self.events_of(self.host_user, team=self.host_team)["upcoming"]) + names = self.names_in(self.upcoming_of(self.host_user, team=self.host_team)) - self.assertIn(mine, names) - self.assertNotIn(theirs, names) + self.assertIn(hosted, names) + self.assertNotIn(ticketed, names) def test_a_medium_filter_keeps_only_that_medium(self): - online = create_event("Medium Online", self.host_team, medium="Online") - in_person = create_event("Medium In Person", self.host_team, medium="In Person") + online = self.create_team_event(self.host_team, medium="Online") + in_person = self.create_team_event(self.host_team, "in_person") - names = self.names_in(self.events_of(self.host_user, medium="Online")["upcoming"]) + names = self.names_in(self.upcoming_of(self.host_user, medium="Online")) self.assertIn(online, names) self.assertNotIn(in_person, names) @@ -217,1046 +141,33 @@ def test_an_unknown_filter_value_is_refused(self): with self.assertRaises(ValidationError): self.events_of(self.host_user, role="lurking") - def test_serializes_every_declared_field(self): - create_event("Payload Shape", self.host_team) - - event = self.events_of(self.host_user)["upcoming"][0] - - self.assertEqual( - set(event), - { - "name", - "title", - "route", - "start_date", - "end_date", - "start_time", - "end_time", - "venue", - "medium", - "banner_image", - "is_host", - "is_attendee", - "team", - "team_name", - "team_logo", - }, - ) - - -class TestCreateEvent(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - if not frappe.db.exists("Event Category", "Meetups"): - frappe.get_doc({"doctype": "Event Category", "name": "Meetups"}).insert(ignore_permissions=True) - - cls.owner = create_user("create-event-owner@example.com", "Owner") - cls.viewer = create_user("create-event-viewer@example.com", "Viewer") - cls.team = create_owned_team("Create Event Team", cls.owner) - add_member(cls.team, cls.viewer, "Viewer") - - def setUp(self): - frappe.set_user(self.owner) - self.addCleanup(frappe.set_user, "Administrator") - - def payload(self, **overrides) -> NewEvent: - return NewEvent( - **{ - "team": self.team, - "title": "Frappeverse Mumbai", - "start_date": add_days(today(), 30), - "start_time": "09:00:00", - "end_time": "17:00:00", - **overrides, - } - ) - - def test_creates_an_event_the_team_owns(self): - created = create_event_endpoint(self.payload()) - - event = frappe.get_doc("Buzz Event", created.name) - self.assertEqual(created.title, "Frappeverse Mumbai") - self.assertEqual(event.team, self.team) - self.assertEqual(event.medium, "In Person") - self.assertEqual(event.category, "Meetups") - - def test_carries_the_optional_fields_through(self): - created = create_event_endpoint( - self.payload( - end_date=add_days(today(), 31), - about="

Come along

", - time_zone="Asia/Kolkata", - ) - ) - - event = frappe.get_doc("Buzz Event", created.name) - self.assertEqual(event.about, "

Come along

") - self.assertEqual(event.time_zone, "Asia/Kolkata") - # Derived on validate from the zone, so it proves the zone reached the document. - self.assertEqual(event.time_zone_label, "IST") - - def test_a_viewer_cannot_create_events(self): - frappe.set_user(self.viewer) - - with self.assertRaises(CannotCreateEvents): - create_event_endpoint(self.payload()) - - def test_a_non_member_cannot_create_events(self): - stranger = create_user("create-event-stranger@example.com", "Stranger") - frappe.set_user(stranger) - - with self.assertRaises(CannotCreateEvents): - create_event_endpoint(self.payload()) - - def test_zoom_is_refused_when_the_app_is_missing(self): - if is_app_installed("zoom_integration"): - self.skipTest("zoom_integration is installed on this site") - - with self.assertRaises(ZoomNotAvailable): - create_event_endpoint(self.payload(zoom_meeting=True)) - - -class TestGetEvent(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("get-event-owner@example.com", "Owner") - cls.viewer = create_user("get-event-viewer@example.com", "Viewer") - cls.stranger = create_user("get-event-stranger@example.com", "Stranger") - cls.team = create_owned_team("Get Event Team", cls.owner) - add_member(cls.team, cls.viewer, "Viewer") - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def test_returns_the_fields_the_manage_page_edits(self): - event = create_event( - "Detailed Event", - self.team, - short_description="A short one", - about="

A long one

", - medium="Online", - meeting_link="https://example.com/join", - ) - frappe.set_user(self.owner) - - detail = get_event(event).__json__() - - self.assertEqual(detail["name"], event) - self.assertEqual(detail["short_description"], "A short one") - self.assertEqual(detail["about"], "

A long one

") - self.assertEqual(detail["medium"], "Online") - self.assertEqual(detail["meeting_link"], "https://example.com/join") - self.assertIsNone(detail["venue"]) - self.assertTrue(detail["modified"]) - - def test_resolves_the_venue_with_its_address(self): - venue = frappe.get_doc( - { - "doctype": "Event Venue", - "venue_name": "Get Event Venue", - "address": "12 Example Street", - "team": self.team, - } - ).insert(ignore_permissions=True) - event = create_event("Venued Event", self.team, venue=venue.name) - frappe.set_user(self.owner) - - detail = get_event(event).__json__() - - self.assertEqual(detail["venue"]["name"], venue.name) - self.assertEqual(detail["venue"]["address"], "12 Example Street") - - def test_a_viewer_cannot_open_the_manage_payload(self): - event = create_event("Viewer Event", self.team) - frappe.set_user(self.viewer) - - with self.assertRaises(CannotManageEvent): - get_event(event) - - def test_a_non_member_cannot_open_the_manage_payload(self): - event = create_event("Stranger Event", self.team) - frappe.set_user(self.stranger) - - with self.assertRaises(CannotManageEvent): - get_event(event) - - def test_an_unknown_event_is_not_found(self): - frappe.set_user(self.owner) - - with self.assertRaises(EventNotFound): - get_event("999999999") - - -class TestEventCoHosts(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("co-host-owner@example.com", "Owner") - cls.viewer = create_user("co-host-viewer@example.com", "Viewer") - cls.team = create_owned_team("Co-host Team", cls.owner) - add_member(cls.team, cls.viewer, "Viewer") - - def setUp(self): - frappe.set_user("Administrator") - self.event = create_event("Co-hosted Event", self.team) - frappe.set_user(self.owner) - self.addCleanup(frappe.set_user, "Administrator") - - def test_the_team_is_the_primary_host(self): - detail = get_event(self.event).__json__() - - self.assertEqual(detail["primary_host"]["host"], self.team) - self.assertEqual(detail["primary_host"]["label"], "Co-host Team") - self.assertEqual(detail["co_hosts"], []) - - def test_adding_and_removing_a_co_host_round_trips(self): - added = add_co_host(self.event, "Acme Corp", by_line="We make things") - - self.assertEqual(get_event(self.event).__json__()["co_hosts"][0]["label"], "Acme Corp") - self.assertEqual(frappe.db.get_value("Event Host", added.host, "team"), self.team) - - remove_co_host(self.event, added.host) - self.assertEqual(get_event(self.event).__json__()["co_hosts"], []) - - def test_the_same_organisation_cannot_be_added_twice(self): - added = add_co_host(self.event, "Acme Corp") - event = frappe.get_doc("Buzz Event", self.event) - event.append("co_hosts", {"host": added.host}) - - with self.assertRaises(frappe.ValidationError): - event.save() - - def test_the_same_name_is_not_added_twice(self): - added = add_co_host(self.event, "Acme Corp") - - with self.assertRaises(frappe.ValidationError): - add_co_host(self.event, "Acme Corp") - - # The second call reuses the team's host rather than minting a second record. - self.assertEqual(frappe.db.count("Event Host", {"host_name": "Acme Corp", "team": self.team}), 1) - self.assertEqual(get_event(self.event).__json__()["co_hosts"][0]["host"], added.host) - - def test_external_links_come_back_in_table_order(self): - event = frappe.get_doc("Buzz Event", self.event) - event.append( - "external_links", {"icon": "map-pin", "label": "Venue map", "url": "https://maps.example.com"} - ) - event.append("external_links", {"label": "Slides", "url": "https://slides.example.com"}) - event.save() - - links = get_event(self.event).__json__()["external_links"] - - self.assertEqual([link["label"] for link in links], ["Venue map", "Slides"]) - self.assertEqual(links[0]["icon"], "map-pin") - self.assertIsNone(links[1]["icon"]) - - def test_an_external_link_needs_a_valid_url(self): - event = frappe.get_doc("Buzz Event", self.event) - event.append("external_links", {"label": "Broken", "url": "not a url"}) - - with self.assertRaises(frappe.ValidationError): - event.save() - - def test_an_external_link_must_be_a_web_address(self): - event = frappe.get_doc("Buzz Event", self.event) - event.append("external_links", {"label": "Script", "url": "javascript:alert(1)"}) - - with self.assertRaises(frappe.ValidationError): - event.save() - - def test_a_viewer_cannot_add_a_co_host(self): - frappe.set_user(self.viewer) - - with self.assertRaises(CannotManageEvent): - add_co_host(self.event, "Acme Corp") - - def test_a_viewer_cannot_remove_a_co_host(self): - added = add_co_host(self.event, "Acme Corp") - frappe.set_user(self.viewer) - - with self.assertRaises(CannotManageEvent): - remove_co_host(self.event, added.host) - - -class TestCheckEventRoute(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("check-route-owner@example.com", "Owner") - cls.team = create_owned_team("Check Route Team", cls.owner) - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def test_an_unused_route_is_available(self): - frappe.set_user(self.owner) - - self.assertTrue(check_event_route("a-route-nobody-has").available) - - def test_a_route_another_event_holds_is_taken(self): - create_event("Route Holder", self.team, route="taken-route") - frappe.set_user(self.owner) - - self.assertFalse(check_event_route("taken-route").available) - - def test_an_unpublished_event_still_holds_its_route(self): - create_event("Draft Route Holder", self.team, route="draft-route", is_published=0) - frappe.set_user(self.owner) - - self.assertFalse(check_event_route("draft-route").available) - - def test_an_event_does_not_block_its_own_route(self): - event = create_event("Self Route", self.team, route="own-route") - frappe.set_user(self.owner) - - self.assertTrue(check_event_route("own-route", event=event).available) - - def test_a_reserved_route_is_refused(self): - frappe.set_user(self.owner) - - self.assertFalse(check_event_route("account").available) - - def test_a_blank_route_is_not_available(self): - frappe.set_user(self.owner) - - self.assertFalse(check_event_route(" ").available) - - -class TestGetEventGuests(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("guests-owner@example.com", "Owner") - cls.stranger = create_user("guests-stranger@example.com", "Stranger") - cls.team = create_owned_team("Guests Team", cls.owner) - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def test_counts_and_lists_the_submitted_tickets(self): - event = create_event("Guested Event", self.team) - issue_ticket(event, "guest-one@example.com") - issue_ticket(event, "guest-two@example.com") - frappe.set_user(self.owner) - - guests = get_event_guests(event).__json__() - - self.assertEqual(guests["total"], 2) - self.assertEqual(len(guests["guests"]), 2) - emails = {guest["attendee_email"] for guest in guests["guests"]} - self.assertEqual(emails, {"guest-one@example.com", "guest-two@example.com"}) - - def test_carries_the_guest_registration_settings(self): - event = create_event("Guest Setting Event", self.team) - frappe.db.set_value( - "Buzz Event", - event, - {"allow_guest_booking": 1, "guest_verification_method": "Phone OTP"}, - ) - frappe.set_user(self.owner) - - registration = get_event_ticket_types(event) - - self.assertTrue(registration.allow_guest_booking) - self.assertEqual(registration.guest_verification_method, "Phone OTP") - - def test_carries_the_tax_settings(self): - event = create_event("Tax Setting Event", self.team) - frappe.db.set_value( - "Buzz Event", - event, - {"apply_tax": 1, "tax_inclusive": 1, "tax_label": "VAT", "tax_percentage": 20}, - ) - frappe.set_user(self.owner) - - registration = get_event_ticket_types(event) - - self.assertTrue(registration.apply_tax) - self.assertTrue(registration.tax_inclusive) - self.assertEqual(registration.tax_label, "VAT") - self.assertEqual(registration.tax_percentage, 20) - - def test_tax_settings_fall_back_to_gst_defaults(self): - event = create_event("Untaxed Event", self.team) - frappe.db.set_value("Buzz Event", event, {"tax_label": None, "tax_percentage": 0}) - frappe.set_user(self.owner) - - registration = get_event_ticket_types(event) - - self.assertFalse(registration.apply_tax) - self.assertEqual(registration.tax_label, "GST") - self.assertEqual(registration.tax_percentage, 18) - - def test_leaves_out_a_ticket_that_was_never_submitted(self): - event = create_event("Draft Ticket Event", self.team) - create_ticket(event, "draft-guest@example.com") - frappe.set_user(self.owner) - - self.assertEqual(get_event_guests(event).total, 0) - - def test_carries_the_add_ons_a_ticket_holds(self): - event = create_event("Add-on Event", self.team) - ticket = issue_ticket(event, "addon-guest@example.com") - add_on = frappe.get_doc( - {"doctype": "Ticket Add-on", "event": event, "title": "T-Shirt", "price": 0} - ).insert(ignore_permissions=True) - # Submitted, so the row goes on through db_insert rather than a save. - frappe.get_doc( - { - "doctype": "Ticket Add-on Value", - "parenttype": "Event Ticket", - "parentfield": "add_ons", - "parent": ticket, - "add_on": add_on.name, - "value": "Large", - } - ).db_insert() - frappe.set_user(self.owner) - - guest = get_event_guests(event).guests[0] - - self.assertEqual(len(guest.add_ons), 1) - self.assertEqual(guest.add_ons[0].title, "T-Shirt") - self.assertEqual(guest.add_ons[0].value, "Large") - - def test_names_the_ticket_type_rather_than_its_docname(self): - event = create_event("Typed Ticket Event", self.team) - ticket = issue_ticket(event, "typed-guest@example.com") - ticket_type = frappe.db.get_value("Event Ticket", ticket, "ticket_type") - title = frappe.db.get_value("Event Ticket Type", ticket_type, "title") - frappe.set_user(self.owner) - - guest = get_event_guests(event).guests[0] - - self.assertEqual(guest.ticket_type, title) - self.assertNotEqual(guest.ticket_type, ticket_type) - - def test_an_event_with_no_guests_is_empty_rather_than_an_error(self): - event = create_event("Quiet Event", self.team) - frappe.set_user(self.owner) - - guests = get_event_guests(event) - - self.assertEqual(guests.total, 0) - self.assertEqual(guests.guests, []) - - def test_reports_registrations_closed_once_the_cutoff_has_passed(self): - event = create_event("Closed Event", self.team, registrations_close_at="2020-01-01 00:00:00") - frappe.set_user(self.owner) - - self.assertTrue(get_event_ticket_types(event).registrations_closed) - - def test_a_new_event_starts_with_registrations_closed(self): - event = create_event("New Event", self.team) - frappe.set_user(self.owner) - - self.assertTrue(get_event_ticket_types(event).registrations_closed) - - def test_a_cutoff_given_at_creation_is_kept(self): - event = create_event("Scheduled Event", self.team, registrations_close_at="2099-01-01 00:00:00") - - close_at = frappe.db.get_value("Buzz Event", event, "registrations_close_at") - self.assertEqual(str(close_at), "2099-01-01 00:00:00") - - def test_a_duplicated_event_starts_with_registrations_closed(self): - event = create_event("Source Event", self.team, registrations_close_at="2099-01-01 00:00:00") - source = frappe.get_doc("Buzz Event", event) - duplicate = frappe.copy_doc(source, ignore_no_copy=False).insert(ignore_permissions=True) - frappe.set_user(self.owner) - - self.assertTrue(get_event_ticket_types(str(duplicate.name)).registrations_closed) - - def test_reports_registrations_open_before_the_event_ends(self): - event = create_event("Open Event", self.team) - frappe.db.set_value("Buzz Event", event, "registrations_close_at", None) - frappe.set_user(self.owner) - - self.assertFalse(get_event_ticket_types(event).registrations_closed) - - def test_names_the_event_for_a_member_who_cannot_edit_it(self): - """The header labels the page off this payload, so read access has to be enough.""" - event = create_event("Named Event", self.team) - viewer = create_user("guests-viewer@example.com", "Viewer") - add_member(self.team, viewer, "Viewer") - frappe.set_user(viewer) - - self.assertEqual(get_event_guests(event).title, "Event Named Event") - with self.assertRaises(CannotManageEvent): - get_event(event) - - def test_a_non_member_cannot_read_the_guest_list(self): - event = create_event("Private Guests", self.team) - issue_ticket(event, "private-guest@example.com") - frappe.set_user(self.stranger) - - with self.assertRaises(CannotManageEvent): - get_event_guests(event) - - def test_an_unknown_event_is_not_found(self): - frappe.set_user(self.owner) - - with self.assertRaises(EventNotFound): - get_event_guests("999999999") - + def hosted_and_ticketed(self) -> tuple[str, str]: + """One event the host's team runs, and one of another team the host holds a ticket to.""" + ticketed = self.create_team_event(self.other_team) + self.submit_ticket(ticketed, self.host_user) + return self.create_team_event(self.host_team), ticketed -class TestGetEventGuestsPaging(IntegrationTestCase): - """Search, order and paging — the arguments the guest list walks the roll with.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("paging-owner@example.com", "Owner") - cls.team = create_owned_team("Paging Team", cls.owner) - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def registered(self, event: str, email: str, name: str, at: str) -> str: - """A ticket with a name on it and a registration time of its own. - - Tickets a test writes land in the same second, so `creation` is set explicitly — - otherwise the order under test is decided by the name hash. - """ - ticket = issue_ticket(event, email) - frappe.db.set_value( - "Event Ticket", - ticket, - {"first_name": name, "attendee_name": name, "creation": at}, - update_modified=False, - ) - return ticket - - def roll_call(self, event: str) -> None: - self.registered(event, "ana@example.com", "Ana Diaz", "2026-01-01 09:00:00") - self.registered(event, "bo@example.com", "Bo Chen", "2026-01-02 09:00:00") - self.registered(event, "cy@example.com", "Cy Ferreira", "2026-01-03 09:00:00") - - def test_carries_the_time_the_ticket_was_raised(self): - event = create_event("Registered At Event", self.team) - self.registered(event, "ana@example.com", "Ana Diaz", "2026-01-01 09:00:00") - frappe.set_user(self.owner) - - guest = get_event_guests(event).guests[0] - - self.assertEqual(str(guest.registered_at), "2026-01-01 09:00:00") - - def test_newest_registration_comes_first_by_default(self): - event = create_event("Ordered Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - names = [guest.attendee_name for guest in get_event_guests(event).guests] - - self.assertEqual(names, ["Cy Ferreira", "Bo Chen", "Ana Diaz"]) - - def test_asc_walks_from_the_oldest_registration(self): - event = create_event("Ascending Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - names = [guest.attendee_name for guest in get_event_guests(event, order="asc").guests] - - self.assertEqual(names, ["Ana Diaz", "Bo Chen", "Cy Ferreira"]) - - def test_an_unknown_order_falls_back_to_newest_first(self): - event = create_event("Odd Order Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event, order="name desc; drop table") - - self.assertEqual(guests.guests[0].attendee_name, "Cy Ferreira") - - def test_a_page_carries_only_its_own_slice(self): - event = create_event("Paged Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - first = get_event_guests(event, limit=2) - - self.assertEqual([guest.attendee_name for guest in first.guests], ["Cy Ferreira", "Bo Chen"]) - self.assertTrue(first.has_next_page) - - def test_the_last_page_says_there_is_nothing_after_it(self): - event = create_event("Last Page Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - last = get_event_guests(event, start=2, limit=2) - - self.assertEqual([guest.attendee_name for guest in last.guests], ["Ana Diaz"]) - self.assertFalse(last.has_next_page) - - def test_a_full_final_page_is_still_the_end(self): - """Three guests read two at a time: the second page fills, and nothing follows.""" - event = create_event("Even Page Event", self.team) - self.roll_call(event) - self.registered(event, "di@example.com", "Di Okafor", "2026-01-04 09:00:00") - frappe.set_user(self.owner) - - self.assertFalse(get_event_guests(event, start=2, limit=2).has_next_page) - - def test_search_matches_a_name(self): - event = create_event("Name Search Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event, search="chen") - - self.assertEqual([guest.attendee_name for guest in guests.guests], ["Bo Chen"]) - - def test_search_matches_an_email(self): - event = create_event("Email Search Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event, search="cy@") - - self.assertEqual([guest.attendee_email for guest in guests.guests], ["cy@example.com"]) - - def test_search_reports_the_match_count_beside_the_registered_count(self): - event = create_event("Counted Search Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event, search="chen") - - self.assertEqual(guests.total, 3) - self.assertEqual(guests.matched, 1) - - def test_without_a_search_every_guest_is_a_match(self): - event = create_event("Unsearched Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event) - - self.assertEqual(guests.matched, guests.total) - - def test_a_search_that_matches_nobody_is_empty_rather_than_an_error(self): - event = create_event("Empty Search Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event, search="nobody-here") - - self.assertEqual(guests.guests, []) - self.assertEqual(guests.matched, 0) - self.assertEqual(guests.total, 3) - self.assertFalse(guests.has_next_page) - - def test_blank_search_is_not_a_filter(self): - event = create_event("Blank Search Event", self.team) - self.roll_call(event) - frappe.set_user(self.owner) - - self.assertEqual(len(get_event_guests(event, search=" ").guests), 3) - - -def types_filter(*types: str) -> str: - return json.dumps([["ticket_type", "in", list(types)]]) - - -class TestGetEventGuestsTicketTypeFilter(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("types-owner@example.com", "Owner") - cls.team = create_owned_team("Ticket Types Team", cls.owner) - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def two_types(self, event: str) -> tuple[str, str]: - """One ticket on each of two types, returning the types in that order.""" - first = frappe.db.get_value("Event Ticket", issue_ticket(event, "early@example.com"), "ticket_type") - second = frappe.db.get_value("Event Ticket", issue_ticket(event, "late@example.com"), "ticket_type") - return str(first), str(second) - - def test_lists_the_types_the_event_sells(self): - """Every type, not only the ones someone has bought — an empty tier is still a filter.""" - event = create_event("Typed Filter Event", self.team) - first, second = self.two_types(event) - frappe.set_user(self.owner) - - ticket_type = next( - field for field in get_event_guests(event).filter_fields if field.key == "ticket_type" - ) - names = {option.value for option in ticket_type.options} - - self.assertLessEqual({first, second}, names) - - def test_narrows_the_list_to_the_chosen_type(self): - event = create_event("Narrowed Event", self.team) - first, _ = self.two_types(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event, filters=types_filter(first)) - - self.assertEqual([guest.attendee_email for guest in guests.guests], ["early@example.com"]) - self.assertEqual(guests.matched, 1) - self.assertEqual(guests.total, 2) - - def test_several_types_are_read_as_any_of_them(self): - event = create_event("Any Type Event", self.team) - first, second = self.two_types(event) - frappe.set_user(self.owner) - - guests = get_event_guests(event, filters=types_filter(first, second)) - - self.assertEqual(guests.matched, 2) - - def test_no_chosen_type_is_not_a_filter(self): - event = create_event("Untyped Filter Event", self.team) - self.two_types(event) - frappe.set_user(self.owner) - - self.assertEqual(len(get_event_guests(event, filters=types_filter()).guests), 2) - - def test_search_and_type_narrow_together(self): - event = create_event("Combined Filter Event", self.team) - first, _ = self.two_types(event) - frappe.set_user(self.owner) - - self.assertEqual(get_event_guests(event, search="late@", filters=types_filter(first)).matched, 0) - self.assertEqual(get_event_guests(event, search="early@", filters=types_filter(first)).matched, 1) - - -class TestGetEventRegistrationTrend(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("trend-owner@example.com", "Owner") - cls.stranger = create_user("trend-stranger@example.com", "Stranger") - cls.team = create_owned_team("Trend Team", cls.owner) - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def registered_on(self, event: str, email: str, day: str) -> str: - ticket = issue_ticket(event, email) - frappe.db.set_value("Event Ticket", ticket, "creation", f"{day} 10:00:00", update_modified=False) - return frappe.db.get_value("Event Ticket", ticket, "ticket_type") - - def totals_by_day(self, trend) -> dict: - """The stack's own height: every type of a day summed back together.""" - totals: dict = {} - for row in trend.per_day: - totals[row.date] = totals.get(row.date, 0) + row.count - return totals - - def test_counts_the_tickets_raised_on_each_day(self): - event = create_event("Trended Event", self.team) - self.registered_on(event, "today-one@example.com", today()) - self.registered_on(event, "today-two@example.com", today()) - self.registered_on(event, "yesterday@example.com", add_days(today(), -1)) - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event, days=3) - - totals = self.totals_by_day(trend) - self.assertEqual(totals[getdate(today())], 2) - self.assertEqual(totals[getdate(add_days(today(), -1))], 1) - self.assertEqual(trend.total, 3) - - def test_splits_a_day_by_ticket_type(self): - event = create_event("Split Event", self.team) - # create_ticket raises a type of its own per ticket, so these are two tiers. - first = self.registered_on(event, "tier-one@example.com", today()) - second = self.registered_on(event, "tier-two@example.com", today()) - titles = { - str(first): frappe.db.get_value("Event Ticket Type", first, "title"), - str(second): frappe.db.get_value("Event Ticket Type", second, "title"), - } - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event, days=2) - - today_rows = {row.ticket_type: row.count for row in trend.per_day if row.date == getdate(today())} - for title in titles.values(): - self.assertEqual(today_rows[title], 1) - - def test_a_quiet_day_is_a_zero_rather_than_a_gap(self): - event = create_event("Quiet Day Event", self.team) - self.registered_on(event, "quiet@example.com", today()) - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event, days=7) - - totals = self.totals_by_day(trend) - self.assertEqual(len(totals), 7) - self.assertEqual(sorted(totals.values()), [0] * 6 + [1]) - - def test_every_type_is_drawn_on_every_day(self): - """A band that vanishes mid-stack reads as a break in the chart, not as nobody buying.""" - event = create_event("Gridded Event", self.team) - self.registered_on(event, "gridded@example.com", today()) - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event, days=4) - - types = {row.ticket_type for row in trend.per_day} - self.assertEqual(len(trend.per_day), 4 * len(types)) - - def test_the_window_ends_on_today(self): - event = create_event("Windowed Event", self.team) - self.registered_on(event, "windowed@example.com", today()) - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event, days=5) - - self.assertEqual(trend.per_day[-1].date, getdate(today())) - self.assertEqual(trend.per_day[0].date, getdate(add_days(today(), -4))) - - def test_names_the_ticket_type_rather_than_its_docname(self): - event = create_event("Named Type Trend", self.team) - ticket_type = self.registered_on(event, "named-type@example.com", today()) - title = frappe.db.get_value("Event Ticket Type", ticket_type, "title") - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event, days=2) - - self.assertIn(title, {row.ticket_type for row in trend.per_day}) - self.assertNotIn(str(ticket_type), {row.ticket_type for row in trend.per_day}) - - def test_leaves_out_a_ticket_that_was_never_submitted(self): - event = create_event("Draft Trend Event", self.team) - create_ticket(event, "draft-trend@example.com") - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event) - - self.assertEqual(trend.total, 0) - self.assertEqual({row.count for row in trend.per_day}, {0}) - - def test_the_type_breakdown_counts_registrations_older_than_the_window(self): - """It sits beside the all-time total, so a fortnight's slice would not add up to it.""" - event = create_event("Long Running Event", self.team) - self.registered_on(event, "ancient@example.com", add_days(today(), -60)) - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event, days=7) - - self.assertEqual(sum(row.count for row in trend.by_ticket_type), trend.total) - self.assertEqual({row.count for row in trend.per_day}, {0}) - - def test_the_type_breakdown_names_the_type_rather_than_its_docname(self): - event = create_event("Named Breakdown", self.team) - ticket_type = self.registered_on(event, "named-breakdown@example.com", today()) - title = frappe.db.get_value("Event Ticket Type", ticket_type, "title") - frappe.set_user(self.owner) - - trend = get_event_registration_trend(event) - - self.assertIn(title, {row.ticket_type for row in trend.by_ticket_type}) - - def test_a_non_member_cannot_read_the_trend(self): - event = create_event("Private Trend", self.team) - frappe.set_user(self.stranger) - - with self.assertRaises(CannotManageEvent): - get_event_registration_trend(event) - - def test_an_unknown_event_is_not_found(self): - frappe.set_user(self.owner) - - with self.assertRaises(EventNotFound): - get_event_registration_trend("999999999") - - -class TestSetRegistrationState(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - - cls.owner = create_user("registration-owner@example.com", "Owner") - cls.viewer = create_user("registration-viewer@example.com", "Viewer") - cls.team = create_owned_team("Registration Team", cls.owner) - add_member(cls.team, cls.viewer, "Viewer") - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def test_closing_stops_registrations_now_in_the_events_own_timezone(self): - """A UTC wall clock would sit hours ahead of an event west of UTC, leaving it open.""" - event = create_event("Pacific Event", self.team, time_zone="US/Pacific") - frappe.set_user(self.owner) - - self.assertTrue(set_registration_state(event, closed=True).registrations_closed) - self.assertTrue(get_event_ticket_types(event).registrations_closed) - - def test_opening_clears_the_cutoff(self): - event = create_event("Reopened Event", self.team, registrations_close_at="2020-01-01 00:00:00") - frappe.set_user(self.owner) - - self.assertFalse(set_registration_state(event, closed=False).registrations_closed) - self.assertIsNone(frappe.db.get_value("Buzz Event", event, "registrations_close_at")) - - def test_an_ended_event_stays_closed_when_it_is_opened(self): - """Clearing the cutoff cannot reopen it, so the answer says closed rather than done.""" - event = create_event( - "Finished Event", - self.team, - start_date=add_days(today(), -10), - end_date=add_days(today(), -9), - ) - frappe.set_user(self.owner) - - self.assertTrue(set_registration_state(event, closed=False).registrations_closed) - - def test_a_reader_cannot_change_the_registration_state(self): - event = create_event("Guarded Event", self.team) - frappe.set_user(self.viewer) - - with self.assertRaises(CannotManageEvent): - set_registration_state(event, closed=True) - - def test_the_registration_page_tells_a_reader_they_cannot_write(self): - event = create_event("Read Only Event", self.team) - - frappe.set_user(self.viewer) - self.assertFalse(get_event_ticket_types(event).can_write) - - frappe.set_user(self.owner) - self.assertTrue(get_event_ticket_types(event).can_write) - - def test_an_external_registration_page_is_linked_instead_of_the_buzz_one(self): - event = create_event( - "External Event", - self.team, - external_registration_page=1, - registration_url="https://tickets.example.com/buzz", - ) - frappe.set_user(self.owner) - - self.assertEqual(get_event_ticket_types(event).registration_link, "https://tickets.example.com/buzz") - - def test_an_ordinary_event_is_linked_to_its_own_registration_page(self): - event = create_event("Hosted Event", self.team, route="hosted-event") - frappe.set_user(self.owner) - - self.assertEqual(get_event_ticket_types(event).registration_link, "/b/register/hosted-event") - - -class TestVerificationMethods(IntegrationTestCase): - """Site configuration, so every case here writes SMS Settings rather than an event.""" - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - # Cleanups run last-registered-first, so the cache is cleared after the rollback: - # the Single is cached per request and would otherwise be read back undone. - self.addCleanup(frappe.clear_document_cache, "SMS Settings", "SMS Settings") - self.addCleanup(frappe.db.rollback) - - def test_phone_needs_a_gateway(self): - # Written through the db: an unconfigured Single cannot pass its own mandatory check. - frappe.db.set_single_value("SMS Settings", "sms_gateway_url", "") - - self.assertFalse(get_verification_methods().phone) - - def test_phone_needs_the_guest_role_to_be_allowed(self): - settings = frappe.get_single("SMS Settings") - settings.sms_gateway_url = "https://sms.example.com/send" - settings.message_parameter = "message" - settings.receiver_parameter = "to" - settings.set("allowed_roles", [{"role": "System Manager"}]) - settings.save() - - self.assertFalse(get_verification_methods().phone) - - settings.append("allowed_roles", {"role": "Guest"}) - settings.save() - - self.assertTrue(get_verification_methods().phone) - - def test_email_follows_the_outgoing_account(self): - # Whatever this site is configured with, the answer is what frappe.sendmail resolves. - from frappe.email.doctype.email_account.email_account import EmailAccount - - self.assertEqual(get_verification_methods().email, bool(EmailAccount.find_default_outgoing())) - - -def publish_forms(event: str) -> None: - """`publish` defaults to 0, so the rows have to be opened before a close means anything.""" - doc = frappe.get_doc("Buzz Event", event) - for row in doc.custom_forms: - row.publish = 1 - doc.save(ignore_permissions=True) - - -class TestArchiveEvent(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - cls.owner = create_user("archive-owner@example.com", "Archive Owner") - cls.outsider = create_user("archive-outsider@example.com", "Archive Outsider") - cls.team = create_owned_team("Archive Team", cls.owner) - - def setUp(self): - frappe.set_user("Administrator") - - def test_archiving_unpublishes_the_event_and_its_forms(self): - event = create_event("Archivable Event", self.team, is_published=1) - publish_forms(event) - frappe.set_user(self.owner) - - archive_event(event) - - doc = frappe.get_doc("Buzz Event", event) - self.assertFalse(doc.is_published) - self.assertFalse(any(row.publish for row in doc.custom_forms)) - - def test_archiving_closes_registrations(self): - """The booking gates stop at `is_published`, but the cutoff has to agree with them.""" - event = create_event("Open Registrations Event", self.team, is_published=1) - frappe.set_user(self.owner) - - archive_event(event) - - self.assertIsNotNone(frappe.db.get_value("Buzz Event", event, "registrations_close_at")) + def events_of(self, user: str, **filters) -> dict[str, list[dict]]: + with self.set_user(user): + return get_my_events(filters or None).__json__() - def test_archiving_an_archived_event_is_a_no_op(self): - """validate() runs on the way through, so a second archive must not throw.""" - event = create_event("Already Archived Event", self.team, is_published=0) - frappe.set_user(self.owner) + def upcoming_of(self, user: str, **filters) -> list[dict]: + return self.events_of(user, **filters)["upcoming"] - archive_event(event) + def all_events_of(self, user: str) -> list[str]: + events = self.events_of(user) + return self.names_in(events["upcoming"] + events["past"]) - self.assertFalse(frappe.db.get_value("Buzz Event", event, "is_published")) + def row_of(self, events: list[dict], event: str) -> dict: + rows = [row for row in events if row["name"] == event] + self.assertEqual(len(rows), 1) + return rows[0] - def test_someone_outside_the_team_cannot_archive(self): - event = create_event("Guarded Event", self.team, is_published=1) - frappe.set_user(self.outsider) + def create_team_event(self, team: str, *traits: str, **overrides) -> str: + return str(BuzzEventFactory.create(*traits, team=team, **overrides).name) - with self.assertRaises(CannotManageEvent): - archive_event(event) + def submit_ticket(self, event: str, email: str): + EventTicketFactory.create("submitted", event=event, attendee_email=email) - self.assertTrue(frappe.db.get_value("Buzz Event", event, "is_published")) + def names_in(self, events: list[dict]) -> list[str]: + return [event["name"] for event in events] diff --git a/buzz/api/events/test_manage_event.py b/buzz/api/events/test_manage_event.py new file mode 100644 index 00000000..32cf478e --- /dev/null +++ b/buzz/api/events/test_manage_event.py @@ -0,0 +1,146 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.api.events import create_event, get_event +from buzz.api.events.exceptions import ( + CannotCreateEvents, + CannotManageEvent, + EventNotFound, + ZoomNotAvailable, +) +from buzz.api.events.schemas import NewEvent +from buzz.api.events.services import DEFAULT_CATEGORY +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventCategoryFactory, + EventVenueFactory, + UserFactory, +) +from buzz.utils import is_app_installed + + +class TestCreateEvent(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + if not frappe.db.exists("Event Category", DEFAULT_CATEGORY): + EventCategoryFactory.create(name=DEFAULT_CATEGORY) + cls.owner = UserFactory.create_once("create-event-owner@example.com").name + cls.viewer = UserFactory.create_once("create-event-viewer@example.com").name + cls.outsider = UserFactory.create_once("create-event-stranger@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") + + def setUp(self): + self.enterContext(self.set_user(self.owner)) + + def test_creates_an_event_the_team_owns(self): + created = create_event(self.new_event(title="Frappeverse Mumbai")) + + event = frappe.get_doc("Buzz Event", created.name) + self.assertEqual(created.title, "Frappeverse Mumbai") + self.assertEqual(event.team, self.team) + self.assertEqual(event.medium, "In Person") + self.assertEqual(event.category, DEFAULT_CATEGORY) + + def test_carries_the_optional_fields_through(self): + created = create_event( + self.new_event( + end_date=add_days(today(), 31), about="

Come along

", time_zone="Asia/Kolkata" + ) + ) + + event = frappe.get_doc("Buzz Event", created.name) + self.assertEqual(event.about, "

Come along

") + self.assertEqual(event.time_zone, "Asia/Kolkata") + # Derived on validate from the zone, so it proves the zone reached the document. + self.assertEqual(event.time_zone_label, "IST") + + def test_a_viewer_cannot_create_events(self): + with self.set_user(self.viewer), self.assertRaises(CannotCreateEvents): + create_event(self.new_event()) + + def test_a_non_member_cannot_create_events(self): + with self.set_user(self.outsider), self.assertRaises(CannotCreateEvents): + create_event(self.new_event()) + + def test_zoom_is_refused_when_the_app_is_missing(self): + if is_app_installed("zoom_integration"): + self.skipTest("zoom_integration is installed on this site") + + with self.assertRaises(ZoomNotAvailable): + create_event(self.new_event(zoom_meeting=True)) + + def new_event(self, **overrides) -> NewEvent: + values = { + "team": self.team, + "title": "Frappeverse Mumbai", + "start_date": add_days(today(), 30), + "start_time": "09:00:00", + "end_time": "17:00:00", + } + return NewEvent(**values | overrides) + + +class TestGetEvent(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("get-event-owner@example.com").name + cls.viewer = UserFactory.create_once("get-event-viewer@example.com").name + cls.outsider = UserFactory.create_once("get-event-stranger@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") + + def test_returns_the_fields_the_manage_page_edits(self): + event = self.create_team_event( + short_description="A short one", + about="

A long one

", + medium="Online", + meeting_link="https://example.com/join", + ) + + detail = self.detail_as(self.owner, event) + + self.assertEqual(detail["name"], event) + self.assertEqual(detail["short_description"], "A short one") + self.assertEqual(detail["about"], "

A long one

") + self.assertEqual(detail["medium"], "Online") + self.assertEqual(detail["meeting_link"], "https://example.com/join") + self.assertIsNone(detail["venue"]) + self.assertTrue(detail["modified"]) + + def test_resolves_the_venue_with_its_address(self): + venue = EventVenueFactory.create(team=self.team, address="12 Example Street").name + event = self.create_team_event("in_person", venue=venue) + + detail = self.detail_as(self.owner, event) + + self.assertEqual(detail["venue"]["name"], venue) + self.assertEqual(detail["venue"]["address"], "12 Example Street") + + def test_a_viewer_cannot_open_the_manage_payload(self): + event = self.create_team_event() + + with self.assertRaises(CannotManageEvent): + self.detail_as(self.viewer, event) + + def test_a_non_member_cannot_open_the_manage_payload(self): + event = self.create_team_event() + + with self.assertRaises(CannotManageEvent): + self.detail_as(self.outsider, event) + + def test_an_unknown_event_is_not_found(self): + with self.assertRaises(EventNotFound): + self.detail_as(self.owner, "999999999") + + def create_team_event(self, *traits: str, **overrides) -> str: + return str(BuzzEventFactory.create(*traits, team=self.team, **overrides).name) + + def detail_as(self, user: str, event: str) -> dict: + with self.set_user(user): + return get_event(event).__json__() diff --git a/buzz/api/events/test_registration_state.py b/buzz/api/events/test_registration_state.py new file mode 100644 index 00000000..5d2e284c --- /dev/null +++ b/buzz/api/events/test_registration_state.py @@ -0,0 +1,173 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, get_datetime, today + +from buzz.api.events import get_event_ticket_types, get_verification_methods, set_registration_state +from buzz.api.events.exceptions import CannotManageEvent +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory + + +class RegistrationTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("registration-owner@example.com").name + cls.viewer = UserFactory.create_once("registration-viewer@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") + + def create_event(self, **overrides) -> str: + return str(BuzzEventFactory.create(team=self.team, **overrides).name) + + def registration_as(self, user: str, event: str): + with self.set_user(user): + return get_event_ticket_types(event) + + +class TestSetRegistrationState(RegistrationTestCase): + def test_closing_stops_registrations_now_in_the_events_own_timezone(self): + """A UTC wall clock would sit hours ahead of an event west of UTC, leaving it open.""" + event = self.create_event(time_zone="US/Pacific") + + with self.set_user(self.owner): + self.assertTrue(set_registration_state(event, closed=True).registrations_closed) + self.assertTrue(self.registration_as(self.owner, event).registrations_closed) + + def test_opening_clears_the_cutoff(self): + event = self.create_event(registrations_close_at=days_from_now(-30)) + + with self.set_user(self.owner): + self.assertFalse(set_registration_state(event, closed=False).registrations_closed) + self.assertIsNone(frappe.db.get_value("Buzz Event", event, "registrations_close_at")) + + def test_an_ended_event_stays_closed_when_it_is_opened(self): + """Clearing the cutoff cannot reopen it, so the answer says closed rather than done.""" + event = self.create_event(start_date=add_days(today(), -10), end_date=add_days(today(), -9)) + + with self.set_user(self.owner): + self.assertTrue(set_registration_state(event, closed=False).registrations_closed) + + def test_a_reader_cannot_change_the_registration_state(self): + event = self.create_event() + + with self.set_user(self.viewer), self.assertRaises(CannotManageEvent): + set_registration_state(event, closed=True) + + def test_the_registration_page_tells_a_reader_they_cannot_write(self): + event = self.create_event() + + self.assertFalse(self.registration_as(self.viewer, event).can_write) + self.assertTrue(self.registration_as(self.owner, event).can_write) + + def test_an_external_registration_page_is_linked_instead_of_the_buzz_one(self): + event = self.create_event( + external_registration_page=1, registration_url="https://tickets.example.com/buzz" + ) + + link = self.registration_as(self.owner, event).registration_link + + self.assertEqual(link, "https://tickets.example.com/buzz") + + def test_an_ordinary_event_is_linked_to_its_own_registration_page(self): + route = f"hosted-{frappe.generate_hash(length=8)}" + event = self.create_event(route=route) + + self.assertEqual(self.registration_as(self.owner, event).registration_link, f"/b/register/{route}") + + +class TestRegistrationSettings(RegistrationTestCase): + def test_carries_the_guest_registration_settings(self): + event = self.create_event() + # Written through the db: validate refuses Phone OTP while SMS is not configured. + guest_booking = {"allow_guest_booking": 1, "guest_verification_method": "Phone OTP"} + frappe.db.set_value("Buzz Event", event, guest_booking) + + registration = self.registration_as(self.owner, event) + + self.assertTrue(registration.allow_guest_booking) + self.assertEqual(registration.guest_verification_method, "Phone OTP") + + def test_carries_the_tax_settings(self): + event = self.create_event() + tax = {"apply_tax": 1, "tax_inclusive": 1, "tax_label": "VAT", "tax_percentage": 20} + frappe.db.set_value("Buzz Event", event, tax) + + registration = self.registration_as(self.owner, event) + + self.assertTrue(registration.apply_tax) + self.assertTrue(registration.tax_inclusive) + self.assertEqual((registration.tax_label, registration.tax_percentage), ("VAT", 20)) + + def test_tax_settings_fall_back_to_gst_defaults(self): + event = self.create_event() + frappe.db.set_value("Buzz Event", event, {"tax_label": None, "tax_percentage": 0}) + + registration = self.registration_as(self.owner, event) + + self.assertFalse(registration.apply_tax) + self.assertEqual((registration.tax_label, registration.tax_percentage), ("GST", 18)) + + def test_reports_registrations_closed_once_the_cutoff_has_passed(self): + event = self.create_event(registrations_close_at=days_from_now(-30)) + + self.assertTrue(self.registration_as(self.owner, event).registrations_closed) + + def test_a_new_event_starts_with_registrations_closed(self): + self.assertTrue(self.registration_as(self.owner, self.create_event()).registrations_closed) + + def test_a_cutoff_given_at_creation_is_kept(self): + close_at = days_from_now(60) + event = self.create_event(registrations_close_at=close_at) + + saved = frappe.db.get_value("Buzz Event", event, "registrations_close_at") + self.assertEqual(get_datetime(saved), get_datetime(close_at)) + + def test_a_duplicated_event_starts_with_registrations_closed(self): + source = frappe.get_doc("Buzz Event", self.create_event(registrations_close_at=days_from_now(60))) + duplicate = frappe.copy_doc(source, ignore_no_copy=False).insert(ignore_permissions=True) + + self.assertTrue(self.registration_as(self.owner, str(duplicate.name)).registrations_closed) + + def test_reports_registrations_open_before_the_event_ends(self): + event = self.create_event() + frappe.db.set_value("Buzz Event", event, "registrations_close_at", None) + + self.assertFalse(self.registration_as(self.owner, event).registrations_closed) + + +class TestVerificationMethods(IntegrationTestCase): + def setUp(self): + # Cleanups run in reverse, so the cached Single is cleared after the rollback. + self.addCleanup(frappe.clear_document_cache, "SMS Settings", "SMS Settings") + self.addCleanup(frappe.db.rollback) + + def test_phone_needs_a_gateway(self): + # Written through the db: an unconfigured Single cannot pass its own mandatory check. + frappe.db.set_single_value("SMS Settings", "sms_gateway_url", "") + + self.assertFalse(get_verification_methods().phone) + + def test_phone_needs_the_guest_role_to_be_allowed(self): + settings = frappe.get_single("SMS Settings") + settings.sms_gateway_url = "https://sms.example.com/send" + settings.message_parameter = "message" + settings.receiver_parameter = "to" + settings.set("allowed_roles", [{"role": "System Manager"}]) + settings.save() + + self.assertFalse(get_verification_methods().phone) + + settings.append("allowed_roles", {"role": "Guest"}) + settings.save() + + self.assertTrue(get_verification_methods().phone) + + def test_email_follows_the_outgoing_account(self): + # Whatever this site is configured with, the answer is what frappe.sendmail resolves. + from frappe.email.doctype.email_account.email_account import EmailAccount + + self.assertEqual(get_verification_methods().email, bool(EmailAccount.find_default_outgoing())) + + +def days_from_now(days: int) -> str: + return f"{add_days(today(), days)} 00:00:00" diff --git a/buzz/api/events/test_registration_trend.py b/buzz/api/events/test_registration_trend.py new file mode 100644 index 00000000..39fb4f41 --- /dev/null +++ b/buzz/api/events/test_registration_trend.py @@ -0,0 +1,129 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, getdate, today + +from buzz.api.events import get_event_registration_trend +from buzz.api.events.exceptions import CannotManageEvent, EventNotFound +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + EventTicketFactory, + EventTicketTypeFactory, + UserFactory, +) + + +class TestGetEventRegistrationTrend(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("trend-owner@example.com").name + cls.outsider = UserFactory.create_once("trend-stranger@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + + def setUp(self): + self.event = str(BuzzEventFactory.create(team=self.team).name) + + def test_counts_the_tickets_raised_on_each_day(self): + self.register_on(today()) + self.register_on(today()) + self.register_on(add_days(today(), -1)) + + trend = self.trend(days=3) + + totals = totals_by_day(trend) + self.assertEqual(totals[getdate(today())], 2) + self.assertEqual(totals[getdate(add_days(today(), -1))], 1) + self.assertEqual(trend.total, 3) + + def test_splits_a_day_by_ticket_type(self): + for title in ("Early Bird", "Regular"): + self.register_on(today(), self.ticket_type(title)) + + trend = self.trend(days=2) + + today_rows = {row.ticket_type: row.count for row in trend.per_day if row.date == getdate(today())} + self.assertEqual((today_rows["Early Bird"], today_rows["Regular"]), (1, 1)) + + def test_a_quiet_day_is_a_zero_rather_than_a_gap(self): + self.register_on(today()) + + totals = totals_by_day(self.trend(days=7)) + + self.assertEqual(len(totals), 7) + self.assertEqual(sorted(totals.values()), [0] * 6 + [1]) + + def test_every_type_is_drawn_on_every_day(self): + """A band that vanishes mid-stack reads as a break in the chart, not as nobody buying.""" + self.register_on(today()) + + trend = self.trend(days=4) + + types = {row.ticket_type for row in trend.per_day} + self.assertEqual(len(trend.per_day), 4 * len(types)) + + def test_the_window_ends_on_today(self): + self.register_on(today()) + + trend = self.trend(days=5) + + self.assertEqual(trend.per_day[-1].date, getdate(today())) + self.assertEqual(trend.per_day[0].date, getdate(add_days(today(), -4))) + + def test_names_the_ticket_type_rather_than_its_docname(self): + ticket_type = self.ticket_type("Early Bird") + self.register_on(today(), ticket_type) + + names = {row.ticket_type for row in self.trend(days=2).per_day} + + self.assertIn("Early Bird", names) + self.assertNotIn(str(ticket_type), names) + + def test_leaves_out_a_ticket_that_was_never_submitted(self): + EventTicketFactory.create(event=self.event) + + trend = self.trend() + + self.assertEqual(trend.total, 0) + self.assertEqual({row.count for row in trend.per_day}, {0}) + + def test_the_type_breakdown_counts_registrations_older_than_the_window(self): + """It sits beside the all-time total, so a fortnight's slice would not add up to it.""" + self.register_on(add_days(today(), -60)) + + trend = self.trend(days=7) + + self.assertEqual(sum(row.count for row in trend.by_ticket_type), trend.total) + self.assertEqual({row.count for row in trend.per_day}, {0}) + + def test_the_type_breakdown_names_the_type_rather_than_its_docname(self): + self.register_on(today(), self.ticket_type("Early Bird")) + + self.assertIn("Early Bird", {row.ticket_type for row in self.trend().by_ticket_type}) + + def test_a_non_member_cannot_read_the_trend(self): + with self.set_user(self.outsider), self.assertRaises(CannotManageEvent): + get_event_registration_trend(self.event) + + def test_an_unknown_event_is_not_found(self): + with self.set_user(self.owner), self.assertRaises(EventNotFound): + get_event_registration_trend("999999999") + + def register_on(self, day: str, ticket_type: str | None = None): + ticket_type = ticket_type or EventTicketTypeFactory.create(event=self.event).name + ticket = EventTicketFactory.create("submitted", event=self.event, ticket_type=ticket_type) + frappe.db.set_value("Event Ticket", ticket.name, "creation", f"{day} 10:00:00", update_modified=False) + + def ticket_type(self, title: str) -> str: + return EventTicketTypeFactory.create(event=self.event, title=title).name + + def trend(self, **arguments): + with self.set_user(self.owner): + return get_event_registration_trend(self.event, **arguments) + + +def totals_by_day(trend) -> dict: + totals: dict = {} + for row in trend.per_day: + totals[row.date] = totals.get(row.date, 0) + row.count + return totals diff --git a/buzz/api/events/test_taxes.py b/buzz/api/events/test_taxes.py index df422c09..014f4093 100644 --- a/buzz/api/events/test_taxes.py +++ b/buzz/api/events/test_taxes.py @@ -3,10 +3,8 @@ from buzz.api.events import get_event_ticket_types, update_tax_settings, update_team_tax_details from buzz.api.events.exceptions import CannotManageEvent, TaxDetailsMissing -from buzz.api.events.test_events import create_event from buzz.api.teams.exceptions import CannotEditTeam -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.test_permissions import add_member +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory TAX_DETAILS = { "legal_name": "Acme Events Pvt Ltd", @@ -19,81 +17,70 @@ class TaxesTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - frappe.set_user("Administrator") - cls.owner = create_user("taxes-owner@example.com", "Owner") - cls.manager = create_user("taxes-manager@example.com", "Manager") - cls.viewer = create_user("taxes-viewer@example.com", "Viewer") - cls.team = create_owned_team("Taxes Team", cls.owner) - add_member(cls.team, cls.manager, "Manager") - add_member(cls.team, cls.viewer, "Viewer") + cls.owner = UserFactory.create_once("taxes-owner@example.com").name + cls.manager = UserFactory.create_once("taxes-manager@example.com").name + cls.viewer = UserFactory.create_once("taxes-viewer@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.manager, team_role="Manager") + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - self.event = create_event("Taxes Event", self.team) - frappe.db.set_value("Buzz Team Settings", self.team, dict.fromkeys(TAX_DETAILS)) - frappe.clear_document_cache("Buzz Team Settings", self.team) - - def turn_tax_on(self): - update_tax_settings(self.event, True, True, "GST", 12) + self.event = str(BuzzEventFactory.create(team=self.team).name) + BuzzTeamFactory.set_settings(self.team, dict.fromkeys(TAX_DETAILS)) def test_refuses_tax_until_the_team_has_tax_details(self): - frappe.set_user(self.owner) - with self.assertRaises(TaxDetailsMissing): + with self.set_user(self.owner), self.assertRaises(TaxDetailsMissing): self.turn_tax_on() def test_charges_tax_once_the_team_has_tax_details(self): - frappe.set_user(self.owner) - update_team_tax_details(self.event, **TAX_DETAILS) - - self.turn_tax_on() + with self.set_user(self.owner): + update_team_tax_details(self.event, **TAX_DETAILS) + self.turn_tax_on() event = frappe.get_doc("Buzz Event", self.event) self.assertEqual((event.apply_tax, event.tax_inclusive, event.tax_percentage), (1, 1, 12)) def test_turns_tax_off_without_tax_details(self): - frappe.db.set_value("Buzz Event", self.event, "apply_tax", 1) - frappe.set_user(self.owner) + event = str(BuzzEventFactory.create("with_tax", team=self.team).name) - update_tax_settings(self.event, False, False, "GST", 18) + with self.set_user(self.owner): + update_tax_settings(event, False, False, "GST", 18) - self.assertEqual(frappe.db.get_value("Buzz Event", self.event, "apply_tax"), 0) + self.assertEqual(frappe.db.get_value("Buzz Event", event, "apply_tax"), 0) def test_viewer_cannot_change_tax_settings(self): - frappe.set_user(self.viewer) - with self.assertRaises(CannotManageEvent): + with self.set_user(self.viewer), self.assertRaises(CannotManageEvent): update_tax_settings(self.event, False, False, "GST", 18) def test_saves_team_tax_details(self): - frappe.set_user(self.owner) - update_team_tax_details(self.event, **TAX_DETAILS) + with self.set_user(self.owner): + update_team_tax_details(self.event, **TAX_DETAILS) saved = frappe.db.get_value("Buzz Team Settings", self.team, list(TAX_DETAILS), as_dict=True) self.assertEqual(saved.tax_id, "29ABCDE1234F1Z5") self.assertEqual(saved.legal_name, TAX_DETAILS["legal_name"]) def test_refuses_incomplete_team_tax_details(self): - frappe.set_user(self.owner) - with self.assertRaises(frappe.ValidationError): + with self.set_user(self.owner), self.assertRaises(frappe.ValidationError): update_team_tax_details(self.event, **{**TAX_DETAILS, "billing_address": " "}) def test_refuses_empty_team_tax_details(self): - frappe.set_user(self.owner) - with self.assertRaises(frappe.ValidationError): + with self.set_user(self.owner), self.assertRaises(frappe.ValidationError): update_team_tax_details(self.event, legal_name="", tax_id=" ", billing_address="") def test_only_team_admins_add_tax_details(self): - frappe.set_user(self.manager) - with self.assertRaises(CannotEditTeam): + with self.set_user(self.manager), self.assertRaises(CannotEditTeam): update_team_tax_details(self.event, **TAX_DETAILS) def test_payload_says_whether_the_team_has_tax_details(self): - frappe.set_user(self.owner) - self.assertIsNone(get_event_ticket_types(self.event).team_tax_id) - - update_team_tax_details(self.event, **TAX_DETAILS) + with self.set_user(self.owner): + self.assertIsNone(get_event_ticket_types(self.event).team_tax_id) + update_team_tax_details(self.event, **TAX_DETAILS) + registration = get_event_ticket_types(self.event) - registration = get_event_ticket_types(self.event) self.assertEqual(registration.team_tax_id, "29ABCDE1234F1Z5") self.assertEqual(registration.team_legal_name, TAX_DETAILS["legal_name"]) self.assertTrue(registration.can_edit_team) + + def turn_tax_on(self): + update_tax_settings(self.event, True, True, "GST", 12) diff --git a/buzz/api/events/test_ticket_types.py b/buzz/api/events/test_ticket_types.py index fc0c12a9..ec2c8629 100644 --- a/buzz/api/events/test_ticket_types.py +++ b/buzz/api/events/test_ticket_types.py @@ -5,10 +5,15 @@ from buzz.api.events import get_event_ticket_types from buzz.api.events.exceptions import CannotManageEvent -from buzz.api.events.test_events import create_event -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.test_permissions import add_member -from buzz.tests.factories import BuzzTeamFactory +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventBookingFactory, + EventTicketTypeFactory, + PaymentGatewayFactory, + UserFactory, +) from buzz.ticketing.doctype.event_ticket_type.event_ticket_type import PAID_EVENTS_FLAG @@ -16,129 +21,96 @@ class TicketTypesTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - frappe.set_user("Administrator") - cls.owner = create_user("ticket-types-owner@example.com", "Owner") - cls.viewer = create_user("ticket-types-viewer@example.com", "Viewer") - cls.stranger = create_user("ticket-types-stranger@example.com", "Stranger") - cls.team = create_owned_team("Ticket Types Team", cls.owner) - add_member(cls.team, cls.viewer, "Viewer") + cls.owner = UserFactory.create_once("ticket-types-owner@example.com").name + cls.viewer = UserFactory.create_once("ticket-types-viewer@example.com").name + cls.outsider = UserFactory.create_once("ticket-types-stranger@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - self.event = create_event("Ticket Types Event", self.team) - self.ticket_type = self.make_ticket_type("General admission", price=1000, seats=200) - - def make_ticket_type(self, title, price=0, seats=0): - return str( - frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.event, - "title": title, - "prices": [{"currency": "INR", "price": price}], - "max_tickets_available": seats, - } - ) - .insert(ignore_permissions=True) - .name + self.event = str(BuzzEventFactory.create(team=self.team).name) + self.ticket_type = str( + EventTicketTypeFactory.create( + event=self.event, prices=[{"currency": "INR", "price": 1000}], max_tickets_available=200 + ).name ) - def sell(self, currency="INR", attendees=1, payment_status="Paid", ticket_type=None): - attendee = { - "ticket_type": ticket_type or self.ticket_type, - "first_name": "Buyer", - "email": "buyer@example.com", - } - return ( - frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.event, - "user": "Administrator", - "currency": currency, - "payment_status": payment_status, - "attendees": [attendee] * attendees, - } - ) - .insert(ignore_permissions=True) - .submit() + def create_booking(self, currency="INR", attendees=1, payment_status="Paid", ticket_type=None): + attendee = {"ticket_type": ticket_type or self.ticket_type, "first_name": "Buyer"} + booking = EventBookingFactory.create( + event=self.event, + currency=currency, + payment_status=payment_status, + attendees=[{**attendee, "email": f"buyer-{index}@example.com"} for index in range(attendees)], ) + booking.submit() + return booking def add_usd_price(self): ticket_type = frappe.get_doc("Event Ticket Type", self.ticket_type) ticket_type.append("prices", {"currency": "USD", "price": 15}) ticket_type.save(ignore_permissions=True) - def revenue(self): - frappe.set_user(self.owner) - payload = get_event_ticket_types(self.event).__json__() - return {row["currency"]: row for row in payload["revenue"]} + def payload_as(self, user: str) -> dict: + with self.set_user(user): + return get_event_ticket_types(self.event).__json__() + + def revenue(self) -> dict: + return {row["currency"]: row for row in self.payload_as(self.owner)["revenue"]} + + def ticket_type_row(self, payload: dict) -> dict: + return next(row for row in payload["ticket_types"] if row["name"] == self.ticket_type) class TestGetEventTicketTypes(TicketTypesTestCase): def test_lists_ticket_types_with_tickets_sold(self): - self.sell() - frappe.set_user(self.owner) + self.create_booking() - payload = get_event_ticket_types(self.event).__json__() + payload = self.payload_as(self.owner) self.assertTrue(payload["can_write"]) - row = next(row for row in payload["ticket_types"] if row["name"] == self.ticket_type) - self.assertEqual(row["name"], self.ticket_type) + row = self.ticket_type_row(payload) self.assertEqual(row["prices"], [{"currency": "INR", "price": 1000, "tickets_sold": 1}]) self.assertEqual(row["max_tickets_available"], 200) self.assertEqual(row["tickets_sold"], 1) def test_reports_whether_the_team_can_sell_paid_tickets(self): - frappe.set_user(self.owner) - self.assertTrue(get_event_ticket_types(self.event).paid_events_enabled) + self.assertTrue(self.payload_as(self.owner)["paid_events_enabled"]) # The team is shared by the class, and rollback is per class. - self.addCleanup(BuzzTeamFactory.set_feature, self.team, PAID_EVENTS_FLAG, 1) - BuzzTeamFactory.set_feature(self.team, PAID_EVENTS_FLAG, 0) - self.assertFalse(get_event_ticket_types(self.event).paid_events_enabled) + self.addCleanup(BuzzTeamFactory.set_settings, self.team, {PAID_EVENTS_FLAG: 1}) + BuzzTeamFactory.set_settings(self.team, {PAID_EVENTS_FLAG: 0}) + self.assertFalse(self.payload_as(self.owner)["paid_events_enabled"]) def test_viewer_reads_without_write_access(self): - frappe.set_user(self.viewer) - - payload = get_event_ticket_types(self.event).__json__() - - self.assertFalse(payload["can_write"]) - - def test_stranger_cannot_read(self): - frappe.set_user(self.stranger) + self.assertFalse(self.payload_as(self.viewer)["can_write"]) + def test_outsider_cannot_read(self): with self.assertRaises(CannotManageEvent): - get_event_ticket_types(self.event) + self.payload_as(self.outsider) def test_lists_sales_per_currency(self): self.add_usd_price() - self.sell("USD") - frappe.set_user(self.owner) + self.create_booking("USD") - payload = get_event_ticket_types(self.event).__json__() + row = self.ticket_type_row(self.payload_as(self.owner)) - row = next(row for row in payload["ticket_types"] if row["name"] == self.ticket_type) self.assertEqual([price["tickets_sold"] for price in row["prices"]], [0, 1]) def test_lists_payment_providers_and_flags_the_default(self): - gateway = {"doctype": "Payment Gateway", "gateway": f"Default Gateway {frappe.generate_hash(6)}"} - default = frappe.get_doc(gateway).insert().name - frappe.db.set_single_value("Buzz Settings", "default_payment_gateway", default) - self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") - frappe.set_user(self.owner) + default = PaymentGatewayFactory.create().name - payload = get_event_ticket_types(self.event).__json__() + with self.change_settings("Buzz Settings", default_payment_gateway=default): + providers = self.payload_as(self.owner)["payment_providers"] - self.assertEqual(payload["payment_providers"], [{"name": default, "is_default": True}]) + self.assertEqual(providers, [{"name": default, "is_default": True}]) class TestRegistrationRevenue(TicketTypesTestCase): def test_totals_paid_bookings_per_currency(self): self.add_usd_price() - self.sell(attendees=2) - self.sell("USD") + self.create_booking(attendees=2) + self.create_booking("USD") revenue = self.revenue() @@ -152,13 +124,13 @@ def test_totals_paid_bookings_per_currency(self): ) def test_leaves_out_unpaid_and_free_bookings(self): - self.sell(payment_status="Unpaid") - self.sell(ticket_type=self.make_ticket_type("Community pass")) + self.create_booking(payment_status="Unpaid") + self.create_booking(ticket_type=EventTicketTypeFactory.create(event=self.event).name) self.assertEqual(self.revenue(), {}) def test_reports_refunds_beside_the_amount_collected(self): - booking = self.sell(attendees=2) + booking = self.create_booking(attendees=2) frappe.db.set_value("Event Booking", booking.name, "refunded_amount", 1000) self.assertEqual(self.revenue()["INR"]["refunded"], 1000) @@ -167,7 +139,7 @@ def test_reports_refunds_beside_the_amount_collected(self): # Cancelling a ticket mails the holder, and CI has no outgoing email account. @patch("buzz.ticketing.doctype.event_ticket.event_ticket.send_message_email") def test_counts_only_tickets_still_held(self, _send_message_email): - booking = self.sell(attendees=2) + booking = self.create_booking(attendees=2) ticket = frappe.get_all("Event Ticket", filters={"booking": booking.name}, pluck="name")[0] frappe.get_doc("Event Ticket", ticket).cancel() diff --git a/buzz/api/events/test_zoom.py b/buzz/api/events/test_zoom.py index 776227e0..de4bdcf1 100644 --- a/buzz/api/events/test_zoom.py +++ b/buzz/api/events/test_zoom.py @@ -6,50 +6,42 @@ from buzz.api.events import convert_to_zoom_meeting from buzz.api.events.exceptions import CannotManageEvent, EventEnded -from buzz.api.events.test_events import create_event from buzz.events.doctype.buzz_event.buzz_event import BuzzEvent -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.test_permissions import add_member +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory from buzz.utils import is_app_installed - -def book_meeting(event: BuzzEvent): - """The real booking calls Zoom on insert, so the row goes straight to the table.""" - meeting = frappe.get_doc({"doctype": "Zoom Meeting", "name": "ZOOM-TEST", "title": event.title}) - meeting.db_insert() - event.db_set("zoom_meeting", meeting.name) +MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" +CREATE_MEETING_ON_ZOOM = BuzzEvent.create_meeting_on_zoom class TestConvertToZoomMeeting(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - frappe.set_user("Administrator") - cls.owner = create_user("zoom-owner@example.com", "Owner") - cls.viewer = create_user("zoom-viewer@example.com", "Viewer") - cls.team = create_owned_team("Zoom Team", cls.owner) - add_member(cls.team, cls.viewer, "Viewer") + cls.owner = UserFactory.create_once("zoom-owner@example.com").name + cls.viewer = UserFactory.create_once("zoom-viewer@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + BuzzTeamMembershipFactory.create(team=cls.team, user=cls.viewer, team_role="Viewer") def setUp(self): if not is_app_installed("zoom_integration"): self.skipTest("zoom_integration is not installed on this site") - frappe.set_user("Administrator") - venue = frappe.get_doc( - { - "doctype": "Event Venue", - "venue_name": "Zoom Hall", - "address": "1 Zoom Street", - "team": self.team, - } - ).insert(ignore_permissions=True) - self.event = create_event("Zoom Event", self.team, medium="In Person", venue=venue.name) - frappe.set_user(self.owner) - self.addCleanup(frappe.set_user, "Administrator") + self.event = str(BuzzEventFactory.create("in_person", team=self.team).name) + self.enterContext(self.set_user(self.owner)) def test_converts_and_reuses_the_linked_meeting(self): - with patch.object( - BuzzEvent, "create_meeting_on_zoom", autospec=True, side_effect=book_meeting - ) as book: + from zoom_integration.tests.zoom_fixtures import create_meeting_response + + response = create_meeting_response() + with ( + patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=response) as create_zoom_session, + patch.object( + BuzzEvent, + "create_meeting_on_zoom", + autospec=True, + side_effect=self.create_meeting_as_administrator, + ), + ): convert_to_zoom_meeting(self.event) frappe.db.set_value("Buzz Event", self.event, "medium", "In Person") convert_to_zoom_meeting(self.event) @@ -57,8 +49,8 @@ def test_converts_and_reuses_the_linked_meeting(self): event = frappe.get_doc("Buzz Event", self.event) self.assertEqual(event.medium, "Online") self.assertFalse(event.venue) - self.assertEqual(event.zoom_meeting, "ZOOM-TEST") - book.assert_called_once() + self.assertEqual(event.zoom_meeting, str(response["id"])) + create_zoom_session.assert_called_once() def test_a_zoom_failure_changes_nothing(self): # The request rolls back on the error; the savepoint stands in for it. @@ -75,26 +67,29 @@ def test_a_zoom_failure_changes_nothing(self): def test_a_failed_save_books_no_meeting(self): with ( patch.object(BuzzEvent, "validate", side_effect=frappe.ValidationError), - patch.object(BuzzEvent, "create_meeting_on_zoom") as book, + patch.object(BuzzEvent, "create_meeting_on_zoom") as create_meeting, ): with self.assertRaises(frappe.ValidationError): convert_to_zoom_meeting(self.event) - book.assert_not_called() + create_meeting.assert_not_called() def test_a_viewer_cannot_convert(self): - frappe.set_user(self.viewer) - - with self.assertRaises(CannotManageEvent): + with self.set_user(self.viewer), self.assertRaises(CannotManageEvent): convert_to_zoom_meeting(self.event) def test_an_ended_event_cannot_be_converted(self): yesterday = add_days(today(), -1) frappe.db.set_value("Buzz Event", self.event, {"start_date": yesterday, "end_date": None}) - with patch.object(BuzzEvent, "create_meeting_on_zoom") as book: + with patch.object(BuzzEvent, "create_meeting_on_zoom") as create_meeting: with self.assertRaises(EventEnded): convert_to_zoom_meeting(self.event) - book.assert_not_called() + create_meeting.assert_not_called() self.assertEqual(frappe.db.get_value("Buzz Event", self.event, "medium"), "In Person") + + def create_meeting_as_administrator(self, event: BuzzEvent): + """The real booking, as Administrator: an organiser holds no Zoom Meeting permission.""" + with self.set_user("Administrator"): + return CREATE_MEETING_ON_ZOOM(event) diff --git a/buzz/api/forms/test_custom_forms.py b/buzz/api/forms/test_custom_forms.py new file mode 100644 index 00000000..9450dd36 --- /dev/null +++ b/buzz/api/forms/test_custom_forms.py @@ -0,0 +1,261 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_to_date, now_datetime + +from buzz.api.forms import get_custom_form_data, submit_custom_form +from buzz.api.forms.exceptions import ( + EventNotFound, + FormNotAvailable, + LoginRequired, + MandatoryFieldsHidden, + SubmissionsClosed, + UnknownExcludedFields, +) +from buzz.tests.factories import ( + BuzzCustomFieldFactory, + BuzzEventFactory, + BuzzTeamFactory, + SponsorshipTierFactory, +) + +SPEAKERS = [{"first_name": "Jane", "email": "jane@example.com"}] + + +class FormsTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.team = BuzzTeamFactory.create_owned_by().name + + def setUp(self): + frappe.clear_messages() + + def build_event(self, **form_row): + form_row = { + "form_doctype": "Talk Proposal", + "route": f"propose-{frappe.generate_hash(length=6)}", + "publish": 1, + **form_row, + } + event = BuzzEventFactory.create(team=self.team, custom_forms=[form_row]) + event.reload() + return event, form_row["route"] + + def submitted_proposal(self, title: str): + return frappe.get_last_doc("Talk Proposal", filters={"title": title}) + + +class TestCustomFormResponse(FormsTestCase): + def test_event_name_travels_as_a_string(self): + event, form_route = self.build_event() + + data = get_custom_form_data(event.route, form_route) + + self.assertEqual(data.event.name, str(event.name)) + self.assertIsInstance(data.__json__()["event"]["name"], str) + + def test_open_form_carries_success_copy(self): + event, form_route = self.build_event(success_title="Nice one", success_message="See you there") + + data = get_custom_form_data(event.route, form_route) + + self.assertFalse(data.closed) + self.assertEqual((data.success_title, data.success_message), ("Nice one", "See you there")) + self.assertEqual(data.form_title, "Talk Proposal") + + +class TestCustomFormExcludedFields(FormsTestCase): + def test_get_custom_form_data_hides_excluded_fields(self): + returned = self.form_fieldnames(excluded_fields="phone") + + self.assertNotIn("phone", returned) + self.assertTrue({"title", "description", "speakers"} <= returned) + + def test_get_custom_form_data_empty_excluded_fields_returns_all(self): + self.assertTrue( + {"title", "description", "speakers", "phone"} <= self.form_fieldnames(excluded_fields="") + ) + + def test_submit_drops_excluded_field_value(self): + event, form_route = self.build_event(excluded_fields="phone") + data = {"title": "Hidden Phone", "description": "

desc

", "speakers": SPEAKERS} + + submit_custom_form(event.route, form_route, data={**data, "phone": "+919999999999"}) + + created = self.submitted_proposal("Hidden Phone") + self.assertEqual(str(created.event), str(event.name)) + self.assertFalse(created.phone, f"phone should be dropped but was {created.phone!r}") + self.assertEqual(created.description, "

desc

") + self.assertEqual( + [(row.first_name, row.email) for row in created.speakers], [("Jane", "jane@example.com")] + ) + + def test_save_hiding_mandatory_throws(self): + with self.assertRaises(MandatoryFieldsHidden): + self.build_event(excluded_fields="speakers") + + def test_save_with_unknown_field_throws(self): + with self.assertRaises(UnknownExcludedFields): + self.build_event(excluded_fields="phone, bogus_field") + + def test_save_hiding_optional_field_succeeds(self): + event, _ = self.build_event(excluded_fields="phone") + + self.assertTrue(frappe.db.exists("Buzz Event", event.name)) + + def form_fieldnames(self, **form_row) -> set[str]: + event, form_route = self.build_event(**form_row) + return {f["fieldname"] for f in get_custom_form_data(event.route, form_route).form_fields} + + +class TestCustomFormAccess(FormsTestCase): + def test_unknown_event_route(self): + with self.assertRaises(EventNotFound): + get_custom_form_data("no-such-event-route", "feedback") + + self.assertEqual(frappe.local.message_log[-1]["title"], "Not Found") + self.assertIn("Event not found", frappe.local.message_log[-1]["message"]) + + def test_unpublished_event_is_not_found(self): + # A route is only assigned on publish, so unpublish after the fact to keep one. + event, form_route = self.build_event() + frappe.db.set_value("Buzz Event", event.name, "is_published", 0) + frappe.clear_document_cache("Buzz Event", event.name) + + with self.assertRaises(EventNotFound): + get_custom_form_data(event.route, form_route) + + def test_unknown_form_route(self): + event, _ = self.build_event() + + with self.assertRaises(FormNotAvailable): + get_custom_form_data(event.route, "no-such-form-route") + + self.assertIn("not available for this event", frappe.local.message_log[-1]["message"]) + + def test_unpublished_form_row_is_not_available(self): + event, form_route = self.build_event(publish=0) + + with self.assertRaises(FormNotAvailable): + get_custom_form_data(event.route, form_route) + + def test_guest_needs_login_when_form_requires_it(self): + event, form_route = self.build_event(login_required=1) + + with self.set_user("Guest"), self.assertRaises(LoginRequired): + get_custom_form_data(event.route, form_route) + + self.assertEqual(frappe.local.message_log[-1]["title"], "Login Required") + + def test_guest_cannot_submit_a_login_only_form(self): + event, form_route = self.build_event(login_required=1) + + with self.set_user("Guest"), self.assertRaises(LoginRequired): + submit_custom_form(event.route, form_route, data={"title": "Nope"}) + + def test_logged_in_user_passes_the_login_gate(self): + event, form_route = self.build_event(login_required=1) + + self.assertFalse(get_custom_form_data(event.route, form_route).closed) + + +class TestCustomFormClosing(FormsTestCase): + def test_closed_form_returns_the_closed_copy_and_no_fields(self): + event, form_route = self.build_event( + auto_close_at=add_to_date(now_datetime(), days=-1), + closed_title="All done", + closed_message="Come back next year", + ) + + data = get_custom_form_data(event.route, form_route) + + self.assertTrue(data.closed) + self.assertEqual((data.form_fields, data.custom_fields), ([], [])) + self.assertEqual((data.closed_title, data.closed_message), ("All done", "Come back next year")) + self.assertEqual((data.success_title, data.success_message), ("", "")) + + def test_future_close_time_leaves_the_form_open(self): + event, form_route = self.build_event(auto_close_at=add_to_date(now_datetime(), days=30)) + + self.assertFalse(get_custom_form_data(event.route, form_route).closed) + + def test_submitting_a_closed_form_conflicts(self): + event, form_route = self.build_event(auto_close_at=add_to_date(now_datetime(), days=-1)) + + with self.assertRaises(SubmissionsClosed): + submit_custom_form(event.route, form_route, data={"title": "Too late"}) + + self.assertEqual(frappe.local.message_log[-1]["title"], "Submissions Closed") + + +class TestCustomFormCustomFields(FormsTestCase): + def test_definitions_are_returned_with_the_form(self): + event, form_route = self.build_event_with_custom_field(mandatory=1, order=2, placeholder="Veg?") + + custom_fields = get_custom_form_data(event.route, form_route).custom_fields + + self.assertEqual([field.label for field in custom_fields], ["Dietary Preference"]) + # Check fields travel as 0/1, not booleans. + self.assertEqual((custom_fields[0].mandatory, custom_fields[0].order), (1, 2)) + + def test_submitted_values_land_in_additional_fields(self): + created = self.submit_with_custom_fields( + "Custom Field", {"dietary_preference": "Vegetarian", "not_a_custom_field": "dropped"} + ) + + values = [(row.fieldname, row.value) for row in created.additional_fields] + self.assertEqual(values, [("dietary_preference", "Vegetarian")]) + + def test_blank_values_are_not_appended(self): + created = self.submit_with_custom_fields("Blank Custom Field", {"dietary_preference": ""}) + + self.assertEqual(created.additional_fields, []) + + def build_event_with_custom_field(self, **field): + event, form_route = self.build_event() + BuzzCustomFieldFactory.create( + event=event.name, + applied_to="Custom Form", + custom_form_doctype="Talk Proposal", + label="Dietary Preference", + fieldtype="Data", + **field, + ) + frappe.clear_document_cache("Buzz Event", event.name) + return event, form_route + + def submit_with_custom_fields(self, title: str, custom_fields_data: dict): + event, form_route = self.build_event_with_custom_field() + data = {"title": title, "speakers": SPEAKERS} + submit_custom_form(event.route, form_route, data=data, custom_fields_data=custom_fields_data) + return self.submitted_proposal(title) + + +class TestCustomFormLinkEventFilter(FormsTestCase): + def test_tier_options_filtered_to_form_event(self): + event, form_route = self.build_sponsorship_event() + other_event, _ = self.build_sponsorship_event() + tier = SponsorshipTierFactory.create(event=event.name, title="Gold A").name + other_tier = SponsorshipTierFactory.create(event=other_event.name, title="Gold B").name + + option_values = [option["value"] for option in self.link_options(event, form_route, "tier")] + + self.assertIn(tier, option_values) + self.assertNotIn(other_tier, option_values, "tiers from other events must not leak") + + def test_link_field_without_event_is_not_filtered(self): + # Country has no `event` field, so it stays unfiltered. + event, form_route = self.build_sponsorship_event() + + self.assertTrue(len(self.link_options(event, form_route, "country")) > 1) + + def build_sponsorship_event(self): + event, _ = self.build_event() + form = frappe.get_doc("Sponsor Enquiry Form", {"event": event.name}) + form.publish = 1 + form.save() + return event, form.route + + def link_options(self, event, form_route: str, fieldname: str) -> list[dict]: + fields = get_custom_form_data(event.route, form_route).form_fields + return next(f for f in fields if f["fieldname"] == fieldname)["link_options"] diff --git a/buzz/api/forms/test_event_proposal_form.py b/buzz/api/forms/test_event_proposal_form.py new file mode 100644 index 00000000..bac8e6bb --- /dev/null +++ b/buzz/api/forms/test_event_proposal_form.py @@ -0,0 +1,81 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.api.forms import get_event_proposal_form_data, submit_event_proposal +from buzz.api.forms.exceptions import LoginRequired, ProposalsNotAccepted +from buzz.tests.factories import EventCategoryFactory + +GUEST_PROPOSAL_SETTINGS = {"accept_event_proposals": 1, "allow_guest_event_proposals": 1} + + +class TestEventProposalForm(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.category = EventCategoryFactory.create().name + + def setUp(self): + frappe.clear_messages() + # The rollback restores the settings row but not its cached copy. + self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") + self.enterContext(self.change_settings("Buzz Settings", GUEST_PROPOSAL_SETTINGS)) + + def test_form_data_carries_the_banner_and_success_copy(self): + with self.change_settings( + "Buzz Settings", event_proposal_banner_title="Pitch us", event_proposal_success_title="Got it" + ): + data = get_event_proposal_form_data() + + self.assertEqual((data.banner_title, data.success_title), ("Pitch us", "Got it")) + self.assertTrue(data.form_fields) + self.assertNotIn("status", {f["fieldname"] for f in data.form_fields}) + + def test_disabled_proposals_are_not_found(self): + with self.change_settings("Buzz Settings", accept_event_proposals=0): + with self.assertRaises(ProposalsNotAccepted): + get_event_proposal_form_data() + + self.assertIn("not being accepted", frappe.local.message_log[-1]["message"]) + + def test_guest_blocked_when_guest_proposals_are_off(self): + with self.change_settings("Buzz Settings", allow_guest_event_proposals=0): + with self.set_user("Guest"), self.assertRaises(LoginRequired): + get_event_proposal_form_data() + + def test_submit_drops_fields_outside_the_form(self): + payload = self.proposal_payload(status="Approved") + + submit_event_proposal(data=payload) + + created = self.submitted_proposal(payload["title"]) + self.assertNotEqual(created.status, "Approved") + self.assertEqual(created.about, payload["about"]) + + def test_guest_can_submit(self): + payload = self.proposal_payload() + + with self.set_user("Guest"): + submit_event_proposal(data=payload) + + # Event Proposal has no submitted_by field, so the owner is the proposer. + self.assertEqual(self.submitted_proposal(payload["title"]).owner, "Guest") + + def test_submit_blocked_when_proposals_are_closed(self): + with self.change_settings("Buzz Settings", accept_event_proposals=0): + with self.assertRaises(ProposalsNotAccepted): + submit_event_proposal(data=self.proposal_payload()) + + def proposal_payload(self, **overrides): + return { + "title": f"Proposal {frappe.generate_hash(length=6)}", + "category": self.category, + "start_date": add_days(today(), 30), + "start_time": "10:00:00", + "end_time": "18:00:00", + "about": "A proposal raised by the forms test suite.", + **overrides, + } + + def submitted_proposal(self, title: str): + return frappe.get_last_doc("Event Proposal", filters={"title": title}) diff --git a/buzz/api/forms/test_forms.py b/buzz/api/forms/test_forms.py index 0245dcd6..9b8e505e 100644 --- a/buzz/api/forms/test_forms.py +++ b/buzz/api/forms/test_forms.py @@ -1,22 +1,8 @@ import frappe -from frappe.tests import IntegrationTestCase - -from buzz.api.forms import ( - get_custom_form_data, - get_dial_codes, - get_event_proposal_form_data, - submit_custom_form, - submit_event_proposal, -) -from buzz.api.forms.exceptions import ( - EventNotFound, - FormNotAvailable, - LoginRequired, - MandatoryFieldsHidden, - ProposalsNotAccepted, - SubmissionsClosed, - UnknownExcludedFields, -) +from frappe.tests import IntegrationTestCase, UnitTestCase + +from buzz.api.forms import get_dial_codes +from buzz.api.forms.exceptions import MandatoryFieldsHidden, UnknownExcludedFields from buzz.api.forms.fields import ( STANDARD_EXCLUDE_FIELDS, get_form_fields, @@ -24,79 +10,18 @@ parse_excluded_fields, validate_excluded_fields, ) -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team +from buzz.tests.factories import EventCategoryFactory, EventHostFactory, SponsorshipTierFactory -# Renderable Talk Proposal fields (after STANDARD_EXCLUDE_FIELDS + auto-set event/submitted_by): -# title (reqd, Data), description (Text Editor), speakers (reqd, Table), phone (Phone) +# Leaves title and speakers (both mandatory), description and phone renderable. TALK_PROPOSAL_EXCLUDE = STANDARD_EXCLUDE_FIELDS | {"event", "submitted_by"} +LAYOUT_BREAKS = ("Section Break", "Column Break") -def ensure_prompt_named_record(doctype, name): - # Event Category uses autoname "prompt" -> name set explicitly. - if frappe.db.exists(doctype, name): - return name - doc = frappe.new_doc(doctype) - doc.name = name - doc.insert(ignore_permissions=True) - return doc.name - - -def ensure_event_host(host_name): - # Event Host autonames to a hash, so `host_name` is both the label and the lookup key. - existing = frappe.db.get_value("Event Host", {"host_name": host_name}, "name") - if existing: - return existing - doc = frappe.new_doc("Event Host") - doc.host_name = host_name - doc.insert(ignore_permissions=True) - return doc.name - - -class FormsTestCase(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Test Forms Category") - cls.host = ensure_event_host("Test Forms Host") - cls.team = create_owned_team(f"Forms Tests {frappe.generate_hash(length=6)}", "Administrator") - - def setUp(self): - frappe.set_user("Administrator") - frappe.clear_messages() - - def build_event(self, is_published=1, **form_row): - """An event carrying one custom form. Returns the inserted event and its form route.""" - event = frappe.new_doc("Buzz Event") - event.update( - { - "title": f"Test Event {frappe.generate_hash(length=6)}", - "start_date": "2030-01-01", - "end_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "category": self.category, - "host": self.host, - "team": self.team, - "is_published": is_published, - } - ) - form_row.setdefault("form_doctype", "Talk Proposal") - form_row.setdefault("route", f"propose-{frappe.generate_hash(length=6)}") - form_row.setdefault("publish", 1) - event.set("custom_forms", []) - event.append("custom_forms", form_row) - event.insert(ignore_permissions=True) - event.reload() - return event, form_row["route"] - - -class TestParseExcludedFields(IntegrationTestCase): +class TestParseExcludedFields(UnitTestCase): def test_blank_returns_none(self): - self.assertIsNone(parse_excluded_fields(None)) - self.assertIsNone(parse_excluded_fields("")) - self.assertIsNone(parse_excluded_fields(" ")) - self.assertIsNone(parse_excluded_fields(", ,,")) + for blank in (None, "", " ", ", ,,"): + with self.subTest(blank=blank): + self.assertIsNone(parse_excluded_fields(blank)) def test_trims_and_drops_empties(self): self.assertEqual(parse_excluded_fields("a, b ,,c"), {"a", "b", "c"}) @@ -107,327 +32,75 @@ def test_single_field(self): class TestGetFormFields(IntegrationTestCase): def test_excluding_a_field_drops_it(self): - exclude_fields = TALK_PROPOSAL_EXCLUDE | {"phone"} - fields = get_form_fields("Talk Proposal", exclude_fields) - returned = {f["fieldname"] for f in fields} + returned = self.fieldnames(get_form_fields("Talk Proposal", TALK_PROPOSAL_EXCLUDE | {"phone"})) + self.assertNotIn("phone", returned) self.assertTrue({"title", "description", "speakers"} <= returned) def test_no_extra_exclude_returns_all_renderable(self): - fields = get_form_fields("Talk Proposal", TALK_PROPOSAL_EXCLUDE) - returned = {f["fieldname"] for f in fields} + returned = self.fieldnames(get_form_fields("Talk Proposal", TALK_PROPOSAL_EXCLUDE)) + self.assertTrue({"title", "description", "speakers", "phone"} <= returned) def test_layout_breaks_pass_through(self): - # With layout breaks on, section/column breaks are emitted even when other fields are excluded. exclude_fields = TALK_PROPOSAL_EXCLUDE | {"description", "speakers", "phone"} fields = get_form_fields("Talk Proposal", exclude_fields, with_layout_breaks=True) - real_fields = { - f["fieldname"] for f in fields if f["fieldtype"] not in ("Section Break", "Column Break") - } - breaks = [f for f in fields if f["fieldtype"] in ("Section Break", "Column Break")] + + real_fields = self.fieldnames(f for f in fields if f["fieldtype"] not in LAYOUT_BREAKS) self.assertIn("title", real_fields) self.assertFalse({"description", "speakers", "phone"} & real_fields) - self.assertTrue(breaks, "expected layout breaks to pass through") + self.assertTrue([f for f in fields if f["fieldtype"] in LAYOUT_BREAKS]) def test_table_field_carries_child_fields(self): - speakers = next( - f for f in get_form_fields("Talk Proposal", TALK_PROPOSAL_EXCLUDE) if f["fieldname"] == "speakers" - ) - child_fieldnames = {f["fieldname"] for f in speakers["child_fields"]} - self.assertTrue({"first_name", "email"} <= child_fieldnames) + fields = get_form_fields("Talk Proposal", TALK_PROPOSAL_EXCLUDE) + speakers = next(f for f in fields if f["fieldname"] == "speakers") + + self.assertTrue({"first_name", "email"} <= self.fieldnames(speakers["child_fields"])) + + def fieldnames(self, fields) -> set[str]: + return {f["fieldname"] for f in fields} class TestGetLinkFieldOptions(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Test Forms Category") - cls.host = ensure_event_host("Test Forms Host") - cls.team = create_owned_team(f"Link Field Tests {frappe.generate_hash(length=6)}", "Administrator") - - def make_tier(self, title="Gold Tier"): - event = frappe.new_doc("Buzz Event") - event.update( - { - "title": f"Test Event {frappe.generate_hash(length=6)}", - "start_date": "2030-01-01", - "end_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "category": self.category, - "host": self.host, - "team": self.team, - } - ) - event.insert(ignore_permissions=True) - tier = frappe.new_doc("Sponsorship Tier") - tier.update({"event": event.name, "title": title, "prices": [{"currency": "INR", "price": 1000}]}) - tier.insert(ignore_permissions=True) - return tier + cls.category = EventCategoryFactory.create().name + EventHostFactory.create() def test_options_have_value_label_shape(self): options = get_link_field_options("Event Host") + self.assertTrue(options) self.assertTrue(all(set(option) == {"value", "label"} for option in options)) def test_no_title_field_label_falls_back_to_name(self): - # Event Category has no title field -> label mirrors the name. - match = next(o for o in get_link_field_options("Event Category") if o["value"] == self.category) - self.assertEqual(match["label"], self.category) + # Event Category has no title field. + self.assertEqual(self.label_of("Event Category", self.category), self.category) def test_title_field_used_as_label(self): # Sponsorship Tier names are hashes; its title field is the readable label. - tier = self.make_tier(title="Gold Tier") - match = next(o for o in get_link_field_options("Sponsorship Tier") if o["value"] == tier.name) - self.assertEqual(match["label"], "Gold Tier") - self.assertNotEqual(match["value"], match["label"]) + tier = SponsorshipTierFactory.create(title="Gold Tier").name + + self.assertEqual(self.label_of("Sponsorship Tier", tier), "Gold Tier") + self.assertNotEqual(tier, "Gold Tier") def test_null_title_falls_back_to_name(self): - tier = self.make_tier() - # Blank the title directly (bypasses the reqd validation) to exercise the fallback. - frappe.db.set_value("Sponsorship Tier", tier.name, "title", "") - match = next(o for o in get_link_field_options("Sponsorship Tier") if o["value"] == tier.name) - self.assertEqual(match["label"], tier.name) - - -class TestCustomFormResponse(FormsTestCase): - def test_event_name_travels_as_a_string(self): - event, form_route = self.build_event() - data = get_custom_form_data(event.route, form_route) - self.assertEqual(data.event.name, str(event.name)) - self.assertIsInstance(data.__json__()["event"]["name"], str) - - def test_open_form_carries_success_copy(self): - event, form_route = self.build_event(success_title="Nice one", success_message="See you there") - data = get_custom_form_data(event.route, form_route) - self.assertFalse(data.closed) - self.assertEqual(data.success_title, "Nice one") - self.assertEqual(data.success_message, "See you there") - self.assertEqual(data.form_title, "Talk Proposal") - - -class TestCustomFormExcludedFields(FormsTestCase): - def test_get_custom_form_data_hides_excluded_fields(self): - event, form_route = self.build_event(excluded_fields="phone") - data = get_custom_form_data(event.route, form_route) - returned = {f["fieldname"] for f in data.form_fields} - self.assertNotIn("phone", returned) - self.assertTrue({"title", "description", "speakers"} <= returned) + tier = SponsorshipTierFactory.create().name + # set_value skips the mandatory check on title. + frappe.db.set_value("Sponsorship Tier", tier, "title", "") - def test_get_custom_form_data_empty_excluded_fields_returns_all(self): - event, form_route = self.build_event(excluded_fields="") - data = get_custom_form_data(event.route, form_route) - returned = {f["fieldname"] for f in data.form_fields} - self.assertTrue({"title", "description", "speakers", "phone"} <= returned) + self.assertEqual(self.label_of("Sponsorship Tier", tier), tier) - def test_submit_drops_excluded_field_value(self): - # phone is excluded -> a posted phone value must be dropped. - event, form_route = self.build_event(excluded_fields="phone") - submit_custom_form( - event.route, - form_route, - data={ - "title": "Hidden Phone Test", - "description": "

desc

", - "speakers": [{"first_name": "Jane", "email": "jane@example.com"}], - "phone": "+919999999999", - }, - ) - created = frappe.get_last_doc("Talk Proposal", filters={"title": "Hidden Phone Test"}) - self.assertEqual(str(created.event), str(event.name)) - self.assertFalse(created.phone, f"phone should be dropped but was {created.phone!r}") - # Non-excluded fields (and child rows) must still be written. - self.assertEqual(created.title, "Hidden Phone Test") - self.assertEqual(created.description, "

desc

") - self.assertEqual(len(created.speakers), 1) - self.assertEqual(created.speakers[0].first_name, "Jane") - self.assertEqual(created.speakers[0].email, "jane@example.com") - - -class TestCustomFormAccess(FormsTestCase): - def test_status_codes(self): - self.assertEqual(EventNotFound.http_status_code, 404) - self.assertEqual(FormNotAvailable.http_status_code, 404) - self.assertEqual(LoginRequired.http_status_code, 401) - self.assertEqual(SubmissionsClosed.http_status_code, 409) - - def test_unknown_event_route(self): - with self.assertRaises(EventNotFound): - get_custom_form_data("no-such-event-route", "feedback") - - self.assertEqual(frappe.local.message_log[-1]["title"], "Not Found") - self.assertIn("Event not found", frappe.local.message_log[-1]["message"]) - - def test_unpublished_event_is_not_found(self): - # A route is only assigned on publish, so unpublish after the fact to keep one. - event, form_route = self.build_event() - frappe.db.set_value("Buzz Event", event.name, "is_published", 0) - frappe.clear_document_cache("Buzz Event", event.name) - - with self.assertRaises(EventNotFound): - get_custom_form_data(event.route, form_route) - - def test_unknown_form_route(self): - event, _ = self.build_event() - with self.assertRaises(FormNotAvailable): - get_custom_form_data(event.route, "no-such-form-route") - - self.assertIn("not available for this event", frappe.local.message_log[-1]["message"]) - - def test_unpublished_form_row_is_not_available(self): - event, form_route = self.build_event(publish=0) - with self.assertRaises(FormNotAvailable): - get_custom_form_data(event.route, form_route) - - def test_guest_needs_login_when_form_requires_it(self): - event, form_route = self.build_event(login_required=1) - frappe.set_user("Guest") - with self.assertRaises(LoginRequired): - get_custom_form_data(event.route, form_route) - - self.assertEqual(frappe.local.message_log[-1]["title"], "Login Required") - - def test_guest_cannot_submit_a_login_only_form(self): - event, form_route = self.build_event(login_required=1) - frappe.set_user("Guest") - with self.assertRaises(LoginRequired): - submit_custom_form(event.route, form_route, data={"title": "Nope"}) - - def test_logged_in_user_passes_the_login_gate(self): - event, form_route = self.build_event(login_required=1) - self.assertFalse(get_custom_form_data(event.route, form_route).closed) - - -class TestCustomFormClosing(FormsTestCase): - def test_closed_form_returns_the_closed_copy_and_no_fields(self): - event, form_route = self.build_event( - auto_close_at="2020-01-01 00:00:00", - closed_title="All done", - closed_message="Come back next year", - ) - data = get_custom_form_data(event.route, form_route) - self.assertTrue(data.closed) - self.assertEqual(data.form_fields, []) - self.assertEqual(data.custom_fields, []) - self.assertEqual(data.closed_title, "All done") - self.assertEqual(data.closed_message, "Come back next year") - self.assertEqual(data.success_title, "") - self.assertEqual(data.success_message, "") - - def test_future_close_time_leaves_the_form_open(self): - event, form_route = self.build_event(auto_close_at="2099-01-01 00:00:00") - self.assertFalse(get_custom_form_data(event.route, form_route).closed) - - def test_submitting_a_closed_form_conflicts(self): - event, form_route = self.build_event(auto_close_at="2020-01-01 00:00:00") - with self.assertRaises(SubmissionsClosed): - submit_custom_form(event.route, form_route, data={"title": "Too late"}) - - self.assertEqual(frappe.local.message_log[-1]["title"], "Submissions Closed") - - -class TestCustomFormCustomFields(FormsTestCase): - def build_event_with_custom_field(self, **field): - event, form_route = self.build_event() - field.setdefault("label", "Dietary Preference") - field.setdefault("fieldtype", "Data") - frappe.get_doc( - { - "doctype": "Buzz Custom Field", - "event": event.name, - "applied_to": "Custom Form", - "custom_form_doctype": "Talk Proposal", - "enabled": 1, - **field, - } - ).insert(ignore_permissions=True) - frappe.clear_document_cache("Buzz Event", event.name) - return event, form_route - - def test_definitions_are_returned_with_the_form(self): - event, form_route = self.build_event_with_custom_field(mandatory=1, order=2, placeholder="Veg?") - custom_fields = get_custom_form_data(event.route, form_route).custom_fields - self.assertEqual(len(custom_fields), 1) - self.assertEqual(custom_fields[0].label, "Dietary Preference") - # Check fields travel as 0/1, not booleans. - self.assertEqual(custom_fields[0].mandatory, 1) - self.assertEqual(custom_fields[0].order, 2) - - def test_submitted_values_land_in_additional_fields(self): - event, form_route = self.build_event_with_custom_field() - submit_custom_form( - event.route, - form_route, - data={ - "title": "Custom Field Test", - "speakers": [{"first_name": "Jane", "email": "jane@example.com"}], - }, - custom_fields_data={"dietary_preference": "Vegetarian", "not_a_custom_field": "dropped"}, - ) - created = frappe.get_last_doc("Talk Proposal", filters={"title": "Custom Field Test"}) - self.assertEqual(len(created.additional_fields), 1) - self.assertEqual(created.additional_fields[0].fieldname, "dietary_preference") - self.assertEqual(created.additional_fields[0].value, "Vegetarian") - - def test_blank_values_are_not_appended(self): - event, form_route = self.build_event_with_custom_field() - submit_custom_form( - event.route, - form_route, - data={ - "title": "Blank Custom Field Test", - "speakers": [{"first_name": "Jane", "email": "jane@example.com"}], - }, - custom_fields_data={"dietary_preference": ""}, - ) - created = frappe.get_last_doc("Talk Proposal", filters={"title": "Blank Custom Field Test"}) - self.assertEqual(created.additional_fields, []) - - -class TestCustomFormLinkEventFilter(FormsTestCase): - def make_sponsorship_event(self): - event, _ = self.build_event() - form = frappe.get_doc("Sponsor Enquiry Form", {"event": event.name}) - form.publish = 1 - form.save() - return event, form.route - - def make_tier(self, event_name, title): - tier = frappe.new_doc("Sponsorship Tier") - tier.update({"event": event_name, "title": title, "prices": [{"currency": "INR", "price": 100}]}) - tier.insert(ignore_permissions=True) - return tier.name - - def test_tier_options_filtered_to_form_event(self): - event_a, route_a = self.make_sponsorship_event() - event_b, _ = self.make_sponsorship_event() - tier_a = self.make_tier(event_a.name, "Gold A") - tier_b = self.make_tier(event_b.name, "Gold B") - - data = get_custom_form_data(event_a.route, route_a) - tier_field = next(f for f in data.form_fields if f["fieldname"] == "tier") - - option_values = [option["value"] for option in tier_field["link_options"]] - self.assertIn(tier_a, option_values) - self.assertNotIn(tier_b, option_values, "tiers from other events must not leak") - - def test_link_field_without_event_is_not_filtered(self): - # Country has no `event` field -> it must keep returning the full list. - event_a, route_a = self.make_sponsorship_event() - data = get_custom_form_data(event_a.route, route_a) - country_field = next(f for f in data.form_fields if f["fieldname"] == "country") - self.assertTrue(len(country_field["link_options"]) > 1) + def label_of(self, doctype: str, name: str) -> str: + return next(o["label"] for o in get_link_field_options(doctype) if o["value"] == name) class TestValidateExcludedFields(IntegrationTestCase): - def test_status_codes(self): - self.assertEqual(UnknownExcludedFields.http_status_code, 400) - self.assertEqual(MandatoryFieldsHidden.http_status_code, 400) + def setUp(self): + frappe.clear_messages() def test_hiding_mandatory_field_throws(self): - # speakers is mandatory; it cannot be hidden. with self.assertRaises(MandatoryFieldsHidden): validate_excluded_fields("Talk Proposal", "speakers") @@ -440,11 +113,10 @@ def test_unknown_field_throws(self): self.assertIn("not_a_field", frappe.local.message_log[-1]["message"]) def test_hiding_optional_field_passes(self): - # phone is optional -> safe to hide. validate_excluded_fields("Talk Proposal", "phone") def test_system_field_is_noop(self): - # Auto-set/system fields are never rendered; listing them is a harmless no-op. + # System fields are never rendered, so listing them does nothing. validate_excluded_fields("Talk Proposal", "event, submitted_by") def test_blank_is_noop(self): @@ -452,91 +124,6 @@ def test_blank_is_noop(self): validate_excluded_fields("Talk Proposal", None) -class TestBuzzEventSaveValidatesExcludedFields(FormsTestCase): - def test_save_hiding_mandatory_throws(self): - with self.assertRaises(MandatoryFieldsHidden): - self.build_event(excluded_fields="speakers") - - def test_save_with_unknown_field_throws(self): - with self.assertRaises(UnknownExcludedFields): - self.build_event(excluded_fields="phone, bogus_field") - - def test_save_hiding_optional_field_succeeds(self): - event, _ = self.build_event(excluded_fields="phone") - self.assertTrue(frappe.db.exists("Buzz Event", event.name)) - - -class TestEventProposalForm(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Test Forms Category") - - def setUp(self): - frappe.set_user("Administrator") - frappe.clear_messages() - self.set_settings(accept_event_proposals=1, allow_guest_event_proposals=1) - - def proposal_payload(self, **overrides): - return { - "title": f"Proposal {frappe.generate_hash(length=6)}", - "category": self.category, - "start_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "about": "A proposal raised by the forms test suite.", - **overrides, - } - - def set_settings(self, **values): - for fieldname, value in values.items(): - frappe.db.set_single_value("Buzz Settings", fieldname, value) - frappe.clear_document_cache("Buzz Settings", "Buzz Settings") - - def test_form_data_shape(self): - self.set_settings(event_proposal_banner_title="Pitch us", event_proposal_success_title="Got it") - data = get_event_proposal_form_data() - self.assertEqual(data.banner_title, "Pitch us") - self.assertEqual(data.success_title, "Got it") - self.assertTrue(data.form_fields) - self.assertNotIn("status", {f["fieldname"] for f in data.form_fields}) - - def test_disabled_proposals_are_not_found(self): - self.set_settings(accept_event_proposals=0) - with self.assertRaises(ProposalsNotAccepted): - get_event_proposal_form_data() - - self.assertIn("not being accepted", frappe.local.message_log[-1]["message"]) - - def test_guest_blocked_when_guest_proposals_are_off(self): - self.set_settings(allow_guest_event_proposals=0) - frappe.set_user("Guest") - with self.assertRaises(LoginRequired): - get_event_proposal_form_data() - - def test_submit_drops_fields_outside_the_form(self): - payload = self.proposal_payload(status="Approved") - submit_event_proposal(data=payload) - created = frappe.get_last_doc("Event Proposal", filters={"title": payload["title"]}) - # status is excluded, so a posted value must not stick. - self.assertNotEqual(created.status, "Approved") - self.assertEqual(created.about, payload["about"]) - - def test_guest_can_submit(self): - payload = self.proposal_payload() - frappe.set_user("Guest") - submit_event_proposal(data=payload) - frappe.set_user("Administrator") - # Event Proposal has no submitted_by field, so the proposer is only the doc owner. - created = frappe.get_last_doc("Event Proposal", filters={"title": payload["title"]}) - self.assertEqual(created.owner, "Guest") - - def test_submit_blocked_when_proposals_are_closed(self): - self.set_settings(accept_event_proposals=0) - with self.assertRaises(ProposalsNotAccepted): - submit_event_proposal(data=self.proposal_payload()) - - class TestDialCodes(IntegrationTestCase): def test_shape_and_uniqueness(self): codes = get_dial_codes() diff --git a/buzz/api/maps/test_maps.py b/buzz/api/maps/test_maps.py index 6bb23e70..d093f6f8 100644 --- a/buzz/api/maps/test_maps.py +++ b/buzz/api/maps/test_maps.py @@ -2,13 +2,16 @@ import frappe import requests -from frappe.tests import IntegrationTestCase +from frappe.tests import IntegrationTestCase, UnitTestCase from buzz.api.maps import add_place_as_venue, locate_map_link, search_places from buzz.api.maps.exceptions import CannotAddVenues, PlaceSearchFailed, PlaceSearchNotEnabled from buzz.api.maps.services import place_search_enabled -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.events.doctype.event_venue.test_event_venue import clear_map_link_cache +from buzz.tests.factories import BuzzTeamFactory, UserFactory +from buzz.tests.utils import clear_map_link_cache + +GOOGLE_MAPS_SETTINGS = {"google_maps_enabled": 1, "google_places_api_key": "secret-places-key"} +USER_WITHOUT_VENUE_ACCESS = "no-venues@example.com" SUGGESTIONS = { "suggestions": [ @@ -27,24 +30,18 @@ } -def configure_google_maps(enabled: int = 1, places_key: str | None = "secret-places-key") -> None: - settings = frappe.get_doc("Buzz Settings") - settings.google_maps_enabled = enabled - settings.google_places_api_key = places_key - settings.save() - - def google_answers(payload: dict) -> Mock: return Mock(json=Mock(return_value=payload), raise_for_status=Mock()) -class TestSearchPlaces(IntegrationTestCase): +class GoogleMapsTestCase(IntegrationTestCase): def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") + # The rollback restores the settings row but not its cached copy. self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") - configure_google_maps() + self.enterContext(self.change_settings("Buzz Settings", GOOGLE_MAPS_SETTINGS)) + +class TestSearchPlaces(GoogleMapsTestCase): @patch("buzz.api.maps.services.requests.request") def test_maps_suggestions_to_predictions_with_the_secret_key(self, post): post.return_value = google_answers(SUGGESTIONS) @@ -66,18 +63,16 @@ def test_blank_query_does_not_call_google(self, post): post.assert_not_called() def test_disabled_switch_turns_search_off(self): - configure_google_maps(enabled=0) - - self.assertFalse(place_search_enabled()) - with self.assertRaises(PlaceSearchNotEnabled): - search_places("nehru", "token-1") + with self.change_settings("Buzz Settings", google_maps_enabled=0): + self.assertFalse(place_search_enabled()) + with self.assertRaises(PlaceSearchNotEnabled): + search_places("nehru", "token-1") def test_missing_key_turns_search_off(self): - configure_google_maps(places_key=None) - - self.assertFalse(place_search_enabled()) - with self.assertRaises(PlaceSearchNotEnabled): - search_places("nehru", "token-1") + with self.change_settings("Buzz Settings", google_places_api_key=None): + self.assertFalse(place_search_enabled()) + with self.assertRaises(PlaceSearchNotEnabled): + search_places("nehru", "token-1") @patch("buzz.api.maps.services.requests.request", side_effect=requests.ConnectionError) def test_google_failure_is_a_named_error(self, post): @@ -85,25 +80,25 @@ def test_google_failure_is_a_named_error(self, post): search_places("nehru", "token-1") def test_someone_who_cannot_add_venues_is_refused(self): - frappe.set_user(create_user("no-venues@example.com", "Attendee")) - - with self.assertRaises(CannotAddVenues): + with ( + self.set_user(UserFactory.create_once(USER_WITHOUT_VENUE_ACCESS).name), + self.assertRaises(CannotAddVenues), + ): search_places("nehru", "token-1") -class TestAddPlaceAsVenue(IntegrationTestCase): +class TestAddPlaceAsVenue(GoogleMapsTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.owner = create_user("place-venue-owner@example.com", "Owner") - cls.team = create_owned_team("Place Venue Team", cls.owner) + cls.owner = UserFactory.create_once("place-venue-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner).name + cls.outsider = UserFactory.create_once("place-venue-outsider@example.com").name + BuzzTeamFactory.create_owned_by(cls.outsider) def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") - configure_google_maps() - frappe.set_user(self.owner) + super().setUp() + self.enterContext(self.set_user(self.owner)) @patch("buzz.api.maps.services.requests.request") def test_saves_the_place_with_its_address_and_place_id(self, request): @@ -145,20 +140,15 @@ def test_place_id_cannot_redirect_the_google_call(self, request): @patch("buzz.api.maps.services.requests.request") def test_another_teams_member_cannot_add_to_the_team(self, request): request.return_value = google_answers({"formattedAddress": "Somewhere"}) - outsider = create_user("place-venue-outsider@example.com", "Outsider") - create_owned_team("Place Venue Other Team", outsider) - frappe.set_user(outsider) - with self.assertRaises(frappe.PermissionError): + with self.set_user(self.outsider), self.assertRaises(frappe.PermissionError): add_place_as_venue(self.team, "place-3", "Not Mine", "token-1") request.assert_not_called() -class TestLocateMapLink(IntegrationTestCase): +class TestLocateMapLink(UnitTestCase): def setUp(self): clear_map_link_cache() - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") def test_google_link_gives_coordinates_and_the_place_name(self): link = "https://www.google.com/maps/place/Nehru+Centre/@18.99,72.81,17z/data=!3d18.9903!4d72.8174" @@ -185,8 +175,10 @@ def test_unreadable_link_gives_nothing(self): {"latitude": None, "longitude": None, "name": None, "address": None, "embed_url": None}, ) + +class TestLocateMapLinkPermission(IntegrationTestCase): def test_someone_who_cannot_add_venues_is_refused(self): - frappe.set_user(create_user("no-map-links@example.com", "Attendee")) + user = UserFactory.create_once("no-map-links@example.com").name - with self.assertRaises(CannotAddVenues): + with self.set_user(user), self.assertRaises(CannotAddVenues): locate_map_link("https://www.openstreetmap.org/#map=17/12.9/77.5") diff --git a/buzz/api/payments/test_payments.py b/buzz/api/payments/test_payments.py index 490e0859..194167aa 100644 --- a/buzz/api/payments/test_payments.py +++ b/buzz/api/payments/test_payments.py @@ -1,93 +1,65 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.events.test_events import create_event from buzz.api.payments import get_event_payment_gateways from buzz.payments import resolve_payment_gateway +from buzz.tests.factories import BuzzEventFactory, PaymentGatewayFactory class TestGetEventPaymentGateways(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.event = str(frappe.get_doc("Buzz Event", {"route": "test-route"}).name) - def setUp(self): - frappe.set_user("Administrator") - gateway = {"doctype": "Payment Gateway", "gateway": f"Test Gateway {frappe.generate_hash(6)}"} - self.gateway = frappe.get_doc(gateway).insert().name - self.set_gateways([]) - self.set_default(None) - - def tearDown(self): - frappe.db.rollback() - frappe.clear_document_cache("Buzz Event", self.event) - - def set_gateways(self, gateways: list[str]): - event = frappe.get_doc("Buzz Event", self.event) - event.payment_gateways = [] - for gateway in gateways: - event.append("payment_gateways", {"payment_gateway": gateway}) - event.save(ignore_permissions=True) - frappe.clear_document_cache("Buzz Event", self.event) - - def set_default(self, gateway: str | None): - frappe.db.set_single_value("Buzz Settings", "default_payment_gateway", gateway) - self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") + self.enterContext(self.change_settings("Buzz Settings", default_payment_gateway=None)) + self.gateway = PaymentGatewayFactory.create().name + self.other_gateway = PaymentGatewayFactory.create().name def test_configured_gateways_override_default(self): - other = frappe.get_doc({"doctype": "Payment Gateway", "gateway": f"Other {frappe.generate_hash(6)}"}) - self.set_default(other.insert().name) - self.set_gateways([self.gateway]) + event = self.create_event(self.gateway) - self.assertEqual(get_event_payment_gateways(self.event), [self.gateway]) + with self.change_settings("Buzz Settings", default_payment_gateway=self.other_gateway): + self.assertEqual(get_event_payment_gateways(event), [self.gateway]) def test_no_gateways_configured(self): - self.assertEqual(get_event_payment_gateways(self.event), []) + self.assertEqual(get_event_payment_gateways(self.create_event()), []) def test_falls_back_to_default_gateway(self): - self.set_default(self.gateway) + event = self.create_event() - self.assertEqual(get_event_payment_gateways(self.event), [self.gateway]) + with self.change_settings("Buzz Settings", default_payment_gateway=self.gateway): + self.assertEqual(get_event_payment_gateways(event), [self.gateway]) def test_new_event_starts_with_default_gateway(self): - self.set_default(self.gateway) - team = frappe.db.get_value("Buzz Event", self.event, "team") - - event = frappe.get_doc("Buzz Event", create_event("Default Gateway Event", team)) + with self.change_settings("Buzz Settings", default_payment_gateway=self.gateway): + event = self.create_event() - self.assertEqual([row.payment_gateway for row in event.payment_gateways], [self.gateway]) + self.assertEqual(self.gateways_on(event), [self.gateway]) def test_new_event_keeps_gateways_it_was_created_with(self): - self.set_default(self.gateway) - other = frappe.get_doc({"doctype": "Payment Gateway", "gateway": f"Other {frappe.generate_hash(6)}"}) - other.insert() - team = frappe.db.get_value("Buzz Event", self.event, "team") - - event = frappe.get_doc( - "Buzz Event", - create_event("Own Gateway Event", team, payment_gateways=[{"payment_gateway": other.name}]), - ) + with self.change_settings("Buzz Settings", default_payment_gateway=self.gateway): + event = self.create_event(self.other_gateway) - self.assertEqual([row.payment_gateway for row in event.payment_gateways], [other.name]) + self.assertEqual(self.gateways_on(event), [self.other_gateway]) def test_resolve_picks_the_first_gateway_when_none_is_given(self): - self.set_gateways([self.gateway]) - - self.assertEqual(resolve_payment_gateway(self.event, None), self.gateway) + self.assertEqual(resolve_payment_gateway(self.create_event(self.gateway), None), self.gateway) def test_resolve_accepts_a_gateway_the_event_uses(self): - self.set_gateways([self.gateway]) + event = self.create_event(self.gateway) - self.assertEqual(resolve_payment_gateway(self.event, self.gateway), self.gateway) + self.assertEqual(resolve_payment_gateway(event, self.gateway), self.gateway) def test_resolve_rejects_a_gateway_the_event_does_not_use(self): - self.set_gateways([self.gateway]) - other = frappe.get_doc({"doctype": "Payment Gateway", "gateway": f"Other {frappe.generate_hash(6)}"}) + event = self.create_event(self.gateway) with self.assertRaises(frappe.ValidationError): - resolve_payment_gateway(self.event, other.insert().name) + resolve_payment_gateway(event, self.other_gateway) def test_resolve_throws_when_the_event_has_no_gateway(self): with self.assertRaises(frappe.ValidationError): - resolve_payment_gateway(self.event, None) + resolve_payment_gateway(self.create_event(), None) + + def create_event(self, *gateways: str) -> str: + rows = [{"payment_gateway": gateway} for gateway in gateways] + return str(BuzzEventFactory.create(payment_gateways=rows).name) + + def gateways_on(self, event: str) -> list[str]: + return [row.payment_gateway for row in frappe.get_doc("Buzz Event", event).payment_gateways] diff --git a/buzz/api/proposals/test_proposal_pipeline.py b/buzz/api/proposals/test_proposal_pipeline.py new file mode 100644 index 00000000..064ad803 --- /dev/null +++ b/buzz/api/proposals/test_proposal_pipeline.py @@ -0,0 +1,180 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils.data import getdate + +from buzz.api.events.exceptions import CannotManageEvent, EventNotFound +from buzz.api.proposals import get_event_proposal_trend, get_event_proposals, set_proposal_state +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + TalkProposalFactory, + UserFactory, +) + +ACCEPTED = '[["status", "in", ["Accepted"]]]' + + +class TestGetEventProposals(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.manager = UserFactory.create_once("proposals-manager@example.com").name + cls.outsider = UserFactory.create_once("proposals-outsider@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.manager).name + cls.event = str(BuzzEventFactory.create(team=cls.team).name) + cls.other_event = str(BuzzEventFactory.create(team=cls.team).name) + cls.pending = TalkProposalFactory.create_as_guest( + cls.event, "one@example.com", title="Pending Kubernetes" + ).name + cls.accepted = TalkProposalFactory.create_as_guest( + cls.event, "two@example.com", title="Accepted Rust" + ).name + frappe.db.set_value("Talk Proposal", cls.accepted, "status", "Accepted") + cls.elsewhere = TalkProposalFactory.create_as_guest( + cls.other_event, "three@example.com", title="Elsewhere Go" + ).name + + def test_lists_only_this_events_proposals(self): + names = self.list_proposal_names() + self.assertIn(self.pending, names) + self.assertIn(self.accepted, names) + self.assertNotIn(self.elsewhere, names) + + def test_lists_proposals_the_manager_is_not_a_speaker_on(self): + self.assertEqual(self.get_proposals_as_manager().total, 2) + + def test_outsider_cannot_read_the_pipeline(self): + with self.set_user(self.outsider), self.assertRaises(CannotManageEvent): + get_event_proposals(self.event) + + def test_unknown_event_is_not_found(self): + with self.set_user(self.manager), self.assertRaises(EventNotFound): + get_event_proposals("does-not-exist") + + def test_status_filter_narrows_the_page(self): + self.assertEqual(self.list_proposal_names(filters=ACCEPTED), [self.accepted]) + + def test_search_matches_the_title(self): + self.assertEqual(self.list_proposal_names(search="Kubernetes"), [self.pending]) + + def test_search_matches_a_speaker_email(self): + self.assertEqual(self.list_proposal_names(search="two@example.com"), [self.accepted]) + + def test_search_that_matches_nothing_returns_nothing(self): + self.assertEqual(self.list_proposal_names(search="Fortran"), []) + + def test_matched_counts_the_filter_while_total_counts_the_event(self): + response = self.get_proposals_as_manager(filters=ACCEPTED) + self.assertEqual(response.matched, 1) + self.assertEqual(response.total, 2) + + def test_page_reports_whether_more_are_waiting(self): + self.assertTrue(self.get_proposals_as_manager(limit=1).has_next_page) + self.assertFalse(self.get_proposals_as_manager(start=1, limit=1).has_next_page) + + def test_order_reverses_the_page(self): + self.assertEqual(list(reversed(self.list_proposal_names())), self.list_proposal_names(order="asc")) + + def test_rows_carry_their_speakers(self): + row = next(p for p in self.get_proposals_as_manager().proposals if p.name == self.pending) + self.assertEqual([speaker.email for speaker in row.speakers], ["one@example.com"]) + + def test_a_writer_is_told_they_may_change_a_status(self): + self.assertTrue(self.get_proposals_as_manager().can_write) + + def test_a_read_only_member_reads_the_pipeline_without_the_write_flag(self): + # Viewer and Frontdesk pass the read check the list uses and fail the write one. + viewer = UserFactory.create_once("proposals-viewer@example.com").name + BuzzTeamMembershipFactory.create(team=self.team, user=viewer, team_role="Viewer") + + with self.set_user(viewer): + response = get_event_proposals(self.event) + + self.assertEqual(response.total, 2) + self.assertFalse(response.can_write) + + def get_proposals_as_manager(self, **kwargs): + with self.set_user(self.manager): + return get_event_proposals(self.event, **kwargs) + + def list_proposal_names(self, **kwargs) -> list[str]: + return [proposal.name for proposal in self.get_proposals_as_manager(**kwargs).proposals] + + +class TestGetEventProposalTrend(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.manager = UserFactory.create_once("trend-manager@example.com").name + cls.outsider = UserFactory.create_once("trend-outsider@example.com").name + team = BuzzTeamFactory.create_owned_by(cls.manager).name + cls.event = str(BuzzEventFactory.create(team=team).name) + cls.proposal = TalkProposalFactory.create_as_guest(cls.event, "trend@example.com").name + + def setUp(self): + self.enterContext(self.set_user(self.manager)) + + def test_window_is_zero_filled_and_ends_today(self): + trend = get_event_proposal_trend(self.event, days=7) + self.assertEqual(len(trend.per_day), 7) + self.assertEqual(trend.per_day[-1].date, getdate()) + self.assertEqual(trend.per_day[-1].count, 1) + + def test_total_and_status_split_agree(self): + trend = get_event_proposal_trend(self.event) + self.assertEqual(trend.total, 1) + self.assertEqual(sum(row.count for row in trend.by_status), trend.total) + + def test_days_are_clamped_to_a_sane_window(self): + self.assertEqual(len(get_event_proposal_trend(self.event, days=500).per_day), 90) + self.assertEqual(len(get_event_proposal_trend(self.event, days=0).per_day), 2) + + def test_outsider_cannot_read_the_trend(self): + with self.set_user(self.outsider), self.assertRaises(CannotManageEvent): + get_event_proposal_trend(self.event) + + +class TestSetProposalState(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.manager = UserFactory.create_once("proposal-state-manager@example.com").name + cls.outsider = UserFactory.create_once("proposal-state-outsider@example.com").name + team = BuzzTeamFactory.create_owned_by(cls.manager).name + cls.event = str(BuzzEventFactory.create(team=team).name) + + def setUp(self): + self.enterContext(self.set_user(self.manager)) + + def test_closing_then_opening_round_trips(self): + self.assertTrue(set_proposal_state(self.event, closed=True).proposals_closed) + self.assertTrue(get_event_proposals(self.event).proposals_closed) + + self.assertFalse(set_proposal_state(self.event, closed=False).proposals_closed) + self.assertFalse(get_event_proposals(self.event).proposals_closed) + + def test_opening_publishes_a_form_that_was_never_published(self): + self.unpublish_proposal_form() + + self.assertFalse(set_proposal_state(self.event, closed=False).proposals_closed) + + def test_an_unpublished_form_reads_as_closed(self): + self.unpublish_proposal_form() + + self.assertTrue(get_event_proposals(self.event).proposals_closed) + + def test_the_link_points_at_the_events_own_form_page(self): + route = frappe.db.get_value("Buzz Event", self.event, "route") + self.assertEqual(get_event_proposals(self.event).proposal_link, f"/b/{route}/propose-talk") + + def test_an_outsider_cannot_change_the_state(self): + with self.set_user(self.outsider), self.assertRaises(CannotManageEvent): + set_proposal_state(self.event, closed=True) + + def unpublish_proposal_form(self): + event = frappe.get_doc("Buzz Event", self.event) + for row in event.custom_forms: + if row.form_doctype == "Talk Proposal": + row.publish = 0 + event.save(ignore_permissions=True) diff --git a/buzz/api/proposals/test_proposals.py b/buzz/api/proposals/test_proposals.py index fc0635e4..2677d4cf 100644 --- a/buzz/api/proposals/test_proposals.py +++ b/buzz/api/proposals/test_proposals.py @@ -1,128 +1,67 @@ import frappe from frappe.tests import IntegrationTestCase -from frappe.utils.data import cstr, getdate +from frappe.utils import add_days, today +from frappe.utils.data import cstr from frappe.utils.response import json_handler -from buzz.api.events.exceptions import CannotManageEvent, EventNotFound -from buzz.api.forms.test_forms import ensure_event_host, ensure_prompt_named_record -from buzz.api.proposals import ( - accept_proposal, - get_event_proposal_trend, - get_event_proposals, - get_my_proposals, - set_proposal_state, -) -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team -from buzz.proposals.doctype.talk_proposal.test_talk_proposal import ( - make_guest_proposal, - make_test_event, - make_test_user, -) - -ACCEPTED = '[["status", "in", ["Accepted"]]]' - - -def serialized_proposals() -> list[dict]: - return [proposal.__json__() for proposal in get_my_proposals()] +from buzz.api.proposals import accept_proposal, get_my_proposals +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, TalkProposalFactory, UserFactory class TestGetMyProposals(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "My Proposals Category") - cls.host = ensure_event_host("My Proposals Host") - cls.event = make_test_event(cls.category, cls.host) - cls.speaker_user = make_test_user("speaker-api@example.com") - cls.other_user = make_test_user("other-api@example.com") - cls.guest_proposal = make_guest_proposal(cls.event, cls.speaker_user) - - def tearDown(self): - frappe.set_user("Administrator") + cls.start_date = add_days(today(), 30) + cls.event = str(BuzzEventFactory.create(start_date=cls.start_date, end_date=cls.start_date).name) + cls.speaker_user = UserFactory.create_once("speaker-api@example.com").name + cls.other_user = UserFactory.create_once("other-api@example.com").name + cls.guest_proposal = TalkProposalFactory.create_as_guest(cls.event, cls.speaker_user).name def test_returns_guest_submitted_proposal_for_speaker(self): - frappe.set_user(self.speaker_user) - names = [row["name"] for row in serialized_proposals()] - self.assertIn(self.guest_proposal, names) + self.assertIn(self.guest_proposal, self.listed_names(self.speaker_user)) def test_returns_proposal_submitted_by_user_without_speaker_row(self): - proposal = frappe.get_doc( - { - "doctype": "Talk Proposal", - "title": f"Submitter API Talk {frappe.generate_hash(length=6)}", - "event": self.event, - "submitted_by": self.speaker_user, - "speakers": [{"first_name": "Someone", "email": "someone-else@example.com"}], - } - ).insert(ignore_permissions=True) + proposal = TalkProposalFactory.create( + event=self.event, + submitted_by=self.speaker_user, + speakers=[{"first_name": "Someone", "email": "someone-else@example.com"}], + ) - frappe.set_user(self.speaker_user) - names = [row["name"] for row in serialized_proposals()] - self.assertIn(proposal.name, names) + self.assertIn(proposal.name, self.listed_names(self.speaker_user)) def test_excludes_unrelated_proposals(self): - frappe.set_user(self.other_user) - names = [row["name"] for row in serialized_proposals()] - self.assertNotIn(self.guest_proposal, names) + self.assertNotIn(self.guest_proposal, self.listed_names(self.other_user)) + + def test_rows_carry_their_speakers(self): + row = self.listed_row(self.guest_proposal) - def test_rows_have_expected_shape(self): - frappe.set_user(self.speaker_user) - row = next(r for r in serialized_proposals() if r["name"] == self.guest_proposal) self.assertEqual(row["event"], cstr(self.event)) self.assertEqual(row["status"], "Review Pending") - self.assertEqual( - set(row), - { - "name", - "title", - "event", - "event_title", - "start_date", - "start_time", - "end_date", - "venue", - "banner_image", - "allow_editing_talks_after_acceptance", - "status", - "creation", - "modified", - "speakers", - }, - ) - - def test_rows_carry_their_speakers(self): - frappe.set_user(self.speaker_user) - row = next(r for r in serialized_proposals() if r["name"] == self.guest_proposal) self.assertEqual( row["speakers"], [{"first_name": "Speaker", "last_name": None, "email": self.speaker_user}], ) def test_speakers_keep_their_row_order(self): - proposal = frappe.get_doc( - { - "doctype": "Talk Proposal", - "title": f"Duo Talk {frappe.generate_hash(length=6)}", - "event": self.event, - "submitted_by": self.speaker_user, - "speakers": [ - {"first_name": "First", "email": "first-speaker@example.com"}, - {"first_name": "Second", "email": "second-speaker@example.com"}, - ], - } - ).insert(ignore_permissions=True) + proposal = TalkProposalFactory.create( + event=self.event, + submitted_by=self.speaker_user, + speakers=[ + {"first_name": "First", "email": "first-speaker@example.com"}, + {"first_name": "Second", "email": "second-speaker@example.com"}, + ], + ) - frappe.set_user(self.speaker_user) - row = next(r for r in serialized_proposals() if r["name"] == proposal.name) + row = self.listed_row(proposal.name) self.assertEqual([speaker["first_name"] for speaker in row["speakers"]], ["First", "Second"]) def test_rows_carry_the_joined_event_columns(self): frappe.db.set_value("Buzz Event", self.event, "banner_image", "/files/banner-probe.png") self.addCleanup(frappe.clear_document_cache, "Buzz Event", self.event) - frappe.set_user(self.speaker_user) - row = next(r for r in serialized_proposals() if r["name"] == self.guest_proposal) - self.assertEqual(cstr(row["start_date"]), "2030-01-01") + row = self.listed_row(self.guest_proposal) + self.assertEqual(cstr(row["start_date"]), self.start_date) self.assertEqual(row["banner_image"], "/files/banner-probe.png") def test_modified_tracks_the_latest_edit(self): @@ -131,257 +70,65 @@ def test_modified_tracks_the_latest_edit(self): proposal.save(ignore_permissions=True) self.addCleanup(frappe.clear_document_cache, "Talk Proposal", self.guest_proposal) - frappe.set_user(self.speaker_user) - row = next(r for r in serialized_proposals() if r["name"] == self.guest_proposal) + row = self.listed_row(self.guest_proposal) self.assertGreater(row["modified"], row["creation"]) def test_creation_serializes_in_frappe_datetime_format(self): - frappe.set_user(self.speaker_user) - row = next(r for r in serialized_proposals() if r["name"] == self.guest_proposal) + row = self.listed_row(self.guest_proposal) self.assertRegex(json_handler(row["creation"]), r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}") + def listed_names(self, user: str) -> list[str]: + with self.set_user(user): + return [proposal.name for proposal in get_my_proposals()] -class TestGetEventProposals(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Event Proposals Category") - cls.host = ensure_event_host("Event Proposals Host") - cls.manager = make_test_user("proposals-manager@example.com") - cls.outsider = make_test_user("proposals-outsider@example.com") - cls.team = create_owned_team(f"Proposals Team {frappe.generate_hash(length=6)}", cls.manager) - cls.event = cstr(make_test_event(cls.category, cls.host, team=cls.team)) - cls.other_event = cstr(make_test_event(cls.category, cls.host, team=cls.team)) - cls.pending = make_guest_proposal(cls.event, "one@example.com", title="Pending Kubernetes") - cls.accepted = make_guest_proposal(cls.event, "two@example.com", title="Accepted Rust") - frappe.db.set_value("Talk Proposal", cls.accepted, "status", "Accepted") - cls.elsewhere = make_guest_proposal(cls.other_event, "three@example.com") - - def tearDown(self): - frappe.set_user("Administrator") - - def listed(self, **kwargs) -> list[str]: - frappe.set_user(self.manager) - response = get_event_proposals(self.event, **kwargs) - return [proposal.name for proposal in response.proposals] - - def test_lists_only_this_events_proposals(self): - names = self.listed() - self.assertIn(self.pending, names) - self.assertIn(self.accepted, names) - self.assertNotIn(self.elsewhere, names) - - def test_lists_proposals_the_manager_is_not_a_speaker_on(self): - """The team reads its whole pipeline, not just the talks it happens to be on.""" - frappe.set_user(self.manager) - self.assertEqual(get_event_proposals(self.event).total, 2) - - def test_outsider_cannot_read_the_pipeline(self): - frappe.set_user(self.outsider) - with self.assertRaises(CannotManageEvent): - get_event_proposals(self.event) - - def test_unknown_event_is_not_found(self): - frappe.set_user(self.manager) - with self.assertRaises(EventNotFound): - get_event_proposals("does-not-exist") - - def test_status_filter_narrows_the_page(self): - self.assertEqual(self.listed(filters=ACCEPTED), [self.accepted]) - - def test_search_matches_the_title(self): - self.assertEqual(self.listed(search="Kubernetes"), [self.pending]) - - def test_search_matches_a_speaker_email(self): - self.assertEqual(self.listed(search="two@example.com"), [self.accepted]) - - def test_search_that_matches_nothing_returns_nothing(self): - self.assertEqual(self.listed(search="Fortran"), []) - - def test_matched_counts_the_filter_while_total_counts_the_event(self): - frappe.set_user(self.manager) - response = get_event_proposals(self.event, filters=ACCEPTED) - self.assertEqual(response.matched, 1) - self.assertEqual(response.total, 2) - - def test_page_reports_whether_more_are_waiting(self): - frappe.set_user(self.manager) - first = get_event_proposals(self.event, limit=1) - self.assertTrue(first.has_next_page) - self.assertFalse(get_event_proposals(self.event, start=1, limit=1).has_next_page) - - def test_order_reverses_the_page(self): - self.assertEqual(list(reversed(self.listed())), self.listed(order="asc")) - - def test_rows_carry_their_speakers(self): - frappe.set_user(self.manager) - row = next(p for p in get_event_proposals(self.event).proposals if p.name == self.pending) - self.assertEqual([speaker.email for speaker in row.speakers], ["one@example.com"]) - - def test_a_writer_is_told_they_may_change_a_status(self): - frappe.set_user(self.manager) - self.assertTrue(get_event_proposals(self.event).can_write) - - def test_a_read_only_member_reads_the_pipeline_without_the_write_flag(self): - """Viewer and Frontdesk pass the read check the list uses and fail the write one.""" - viewer = make_test_user("proposals-viewer@example.com") - frappe.get_doc( - { - "doctype": "Buzz Team Membership", - "team": self.team, - "user": viewer, - "team_role": "Viewer", - "enabled": 1, - } - ).insert(ignore_permissions=True) - - frappe.set_user(viewer) - response = get_event_proposals(self.event) - self.assertEqual(response.total, 2) - self.assertFalse(response.can_write) - - -class TestGetEventProposalTrend(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Proposal Trend Category") - cls.host = ensure_event_host("Proposal Trend Host") - cls.manager = make_test_user("trend-manager@example.com") - cls.outsider = make_test_user("trend-outsider@example.com") - cls.team = create_owned_team(f"Trend Team {frappe.generate_hash(length=6)}", cls.manager) - cls.event = cstr(make_test_event(cls.category, cls.host, team=cls.team)) - cls.proposal = make_guest_proposal(cls.event, "trend@example.com") - - def tearDown(self): - frappe.set_user("Administrator") - - def test_window_is_zero_filled_and_ends_today(self): - frappe.set_user(self.manager) - trend = get_event_proposal_trend(self.event, days=7) - self.assertEqual(len(trend.per_day), 7) - self.assertEqual(trend.per_day[-1].date, getdate()) - self.assertEqual(trend.per_day[-1].count, 1) - - def test_total_and_status_split_agree(self): - frappe.set_user(self.manager) - trend = get_event_proposal_trend(self.event) - self.assertEqual(trend.total, 1) - self.assertEqual(sum(row.count for row in trend.by_status), trend.total) - - def test_days_are_clamped_to_a_sane_window(self): - frappe.set_user(self.manager) - self.assertEqual(len(get_event_proposal_trend(self.event, days=500).per_day), 90) - self.assertEqual(len(get_event_proposal_trend(self.event, days=0).per_day), 2) - - def test_outsider_cannot_read_the_trend(self): - frappe.set_user(self.outsider) - with self.assertRaises(CannotManageEvent): - get_event_proposal_trend(self.event) + def listed_row(self, proposal: str) -> dict: + with self.set_user(self.speaker_user): + rows = [row.__json__() for row in get_my_proposals()] + return next(row for row in rows if row["name"] == proposal) class TestAcceptProposal(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Accept Proposal Category") - cls.host = ensure_event_host("Accept Proposal Host") - cls.manager = make_test_user("accept-manager@example.com") - cls.outsider = make_test_user("accept-outsider@example.com") - cls.team = create_owned_team(f"Accept Team {frappe.generate_hash(length=6)}", cls.manager) - cls.event = cstr(make_test_event(cls.category, cls.host, team=cls.team)) + cls.manager = UserFactory.create_once("accept-manager@example.com").name + cls.outsider = UserFactory.create_once("accept-outsider@example.com").name + cls.speaker = UserFactory.create_once("accept-speaker@example.com").name + team = BuzzTeamFactory.create_owned_by(cls.manager).name + cls.event = str(BuzzEventFactory.create(team=team).name) def setUp(self): - self.proposal = make_guest_proposal(self.event, "accept-speaker@example.com") - - def tearDown(self): - frappe.set_user("Administrator") + self.proposal = TalkProposalFactory.create_as_guest(self.event, self.speaker).name def test_accepting_creates_the_talk_and_sets_the_status(self): - frappe.set_user(self.manager) - accepted = accept_proposal(self.proposal) + with self.set_user(self.manager): + accepted = accept_proposal(self.proposal) self.assertEqual(accepted.status, "Accepted") self.assertEqual(frappe.db.get_value("Talk Proposal", self.proposal, "status"), "Accepted") self.assertEqual(frappe.db.get_value("Event Talk", accepted.talk, "proposal"), self.proposal) def test_the_talk_carries_the_proposal_speakers(self): - frappe.set_user(self.manager) - talk = frappe.get_doc("Event Talk", accept_proposal(self.proposal).talk) + with self.set_user(self.manager): + talk = frappe.get_doc("Event Talk", accept_proposal(self.proposal).talk) + self.assertEqual(len(talk.speakers), 1) def test_accepting_twice_reuses_the_talk_rather_than_duplicating_it(self): - """A reviewer can move a proposal off Accepted and back; the programme has one entry.""" - frappe.set_user(self.manager) - first = accept_proposal(self.proposal) - frappe.db.set_value("Talk Proposal", self.proposal, "status", "Shortlisted") + with self.set_user(self.manager): + first = accept_proposal(self.proposal) + frappe.db.set_value("Talk Proposal", self.proposal, "status", "Shortlisted") + second = accept_proposal(self.proposal) - second = accept_proposal(self.proposal) self.assertEqual(second.talk, first.talk) self.assertEqual(second.status, "Accepted") self.assertEqual(frappe.db.count("Event Talk", {"proposal": self.proposal}), 1) def test_a_speaker_cannot_accept_their_own_proposal(self): - """A listed speaker holds write on the document; accepting is the team's call.""" - speaker = make_test_user("accept-speaker@example.com") - frappe.set_user(speaker) - with self.assertRaises(frappe.PermissionError): + # A listed speaker holds write on the document; accepting is the team's call. + with self.set_user(self.speaker), self.assertRaises(frappe.PermissionError): accept_proposal(self.proposal) def test_an_outsider_cannot_accept(self): - frappe.set_user(self.outsider) - with self.assertRaises(frappe.PermissionError): + with self.set_user(self.outsider), self.assertRaises(frappe.PermissionError): accept_proposal(self.proposal) - - -class TestSetProposalState(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Proposal State Category") - cls.host = ensure_event_host("Proposal State Host") - cls.manager = make_test_user("proposal-state-manager@example.com") - cls.outsider = make_test_user("proposal-state-outsider@example.com") - cls.team = create_owned_team(f"Proposal State Team {frappe.generate_hash(length=6)}", cls.manager) - cls.event = cstr(make_test_event(cls.category, cls.host, team=cls.team)) - frappe.db.set_value("Buzz Event", cls.event, "route", f"proposal-state-{frappe.generate_hash(6)}") - - def setUp(self): - frappe.set_user(self.manager) - - def tearDown(self): - frappe.set_user("Administrator") - - def test_closing_then_opening_round_trips(self): - self.assertTrue(set_proposal_state(self.event, closed=True).proposals_closed) - self.assertTrue(get_event_proposals(self.event).proposals_closed) - - self.assertFalse(set_proposal_state(self.event, closed=False).proposals_closed) - self.assertFalse(get_event_proposals(self.event).proposals_closed) - - def test_opening_publishes_a_form_that_was_never_published(self): - event = frappe.get_doc("Buzz Event", self.event) - for row in event.custom_forms: - if row.form_doctype == "Talk Proposal": - row.publish = 0 - event.save(ignore_permissions=True) - - self.assertFalse(set_proposal_state(self.event, closed=False).proposals_closed) - - def test_an_unpublished_form_reads_as_closed(self): - event = frappe.get_doc("Buzz Event", self.event) - for row in event.custom_forms: - if row.form_doctype == "Talk Proposal": - row.publish = 0 - event.save(ignore_permissions=True) - - self.assertTrue(get_event_proposals(self.event).proposals_closed) - - def test_the_link_points_at_the_events_own_form_page(self): - route = frappe.db.get_value("Buzz Event", self.event, "route") - self.assertEqual(get_event_proposals(self.event).proposal_link, f"/b/{route}/propose-talk") - - def test_an_outsider_cannot_change_the_state(self): - frappe.set_user(self.outsider) - with self.assertRaises(CannotManageEvent): - set_proposal_state(self.event, closed=True) diff --git a/buzz/api/sponsorships/test_forms.py b/buzz/api/sponsorships/test_forms.py index b8189b11..6fb7d1b4 100644 --- a/buzz/api/sponsorships/test_forms.py +++ b/buzz/api/sponsorships/test_forms.py @@ -10,61 +10,17 @@ submit_enquiry_form, ) from buzz.api.sponsorships.exceptions import EnquiryNotAccessible -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team +from buzz.tests.factories import BuzzEventFactory, SponsorshipTierFactory, UserFactory class SponsorFormTestCase(IntegrationTestCase): - @staticmethod - def ensure_category(): - name = "Sponsor Form Test Category" - if not frappe.db.exists("Event Category", name): - frappe.get_doc({"doctype": "Event Category", "name": name}).insert(ignore_permissions=True) - return name - - @staticmethod - def ensure_host(): - name = "Sponsor Form Test Host" - host = frappe.db.get_value("Event Host", {"host_name": name}) - if not host: - host = ( - frappe.get_doc({"doctype": "Event Host", "host_name": name}) - .insert(ignore_permissions=True) - .name - ) - return host - def setUp(self): - frappe.set_user("Administrator") - frappe.clear_messages() - self.category = self.ensure_category() - self.host = self.ensure_host() - self.team = create_owned_team(f"Sponsor Forms {frappe.generate_hash(length=6)}", "Administrator") - self.event = self.make_event() + self.enterContext(self.set_user("Administrator")) + self.event = BuzzEventFactory.create() self.form = frappe.get_doc("Sponsor Enquiry Form", {"event": self.event.name}) self.form.publish = 1 self.form.save(ignore_permissions=True) - def tearDown(self): - frappe.set_user("Administrator") - frappe.db.rollback() - - def make_event(self): - return frappe.get_doc( - { - "doctype": "Buzz Event", - "title": f"Sponsor Form {frappe.generate_hash(length=6)}", - "start_date": "2030-01-01", - "end_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "category": self.category, - "host": self.host, - "team": self.team, - "is_published": 1, - } - ).insert(ignore_permissions=True) - def form_values(self, **extra): return {"company_name": "Acme", "company_logo": "/files/acme.png", **extra} @@ -99,10 +55,11 @@ def test_unpublished_form_is_not_available(self): get_enquiry_form(self.event.route) def test_guest_can_open_the_form_with_a_required_contact_email(self): - frappe.set_user("Guest") - contact = self.contact_field() + with self.set_user("Guest"): + contact = self.contact_field() + closed = get_enquiry_form(self.event.route).closed - self.assertFalse(get_enquiry_form(self.event.route).closed) + self.assertFalse(closed) self.assertTrue(contact["reqd"]) self.assertFalse(contact["default"]) @@ -117,10 +74,6 @@ def test_hiding_contact_email_is_refused(self): with self.assertRaises(frappe.ValidationError): self.form.save(ignore_permissions=True) - def contact_field(self): - fields = get_enquiry_form(self.event.route).form_fields - return next(field for field in fields if field["fieldname"] == "contact_email") - def test_archiving_unpublishes_the_form_without_reopening_it_on_republish(self): self.event.archive_event() self.form.reload() @@ -131,19 +84,12 @@ def test_archiving_unpublishes_the_form_without_reopening_it_on_republish(self): self.form.reload() self.assertFalse(self.form.publish) + def contact_field(self): + fields = get_enquiry_form(self.event.route).form_fields + return next(field for field in fields if field["fieldname"] == "contact_email") + class TestSponsorFormSubmission(SponsorFormTestCase): - def make_user(self, email): - user = frappe.get_doc( - { - "doctype": "User", - "email": email, - "first_name": "Sponsor", - "roles": [{"role": "Buzz User"}], - } - ) - return user.insert(ignore_permissions=True).name - def test_submission_records_form_and_drops_forged_values(self): name = submit_enquiry_form( self.event.route, @@ -187,22 +133,14 @@ def test_standard_field_validation_is_preserved(self): submit_enquiry_form(self.event.route, self.form_values(phone="not-a-phone")) def test_cross_event_tier_is_rejected(self): - other_event = self.make_event() - tier = frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": other_event.name, - "title": "Other tier", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert(ignore_permissions=True) + tier = SponsorshipTierFactory.create() with self.assertRaises(frappe.ValidationError): submit_enquiry_form(self.event.route, self.form_values(tier=tier.name)) def test_disabled_tier_is_excluded_from_link_options(self): - enabled = self.make_tier("Available") - disabled = self.make_tier("Retired", enabled=0) + enabled = SponsorshipTierFactory.create(event=self.event.name) + disabled = SponsorshipTierFactory.create(event=self.event.name, enabled=0) tier_field = next( field for field in get_enquiry_form(self.event.route).form_fields if field["fieldname"] == "tier" @@ -213,24 +151,13 @@ def test_disabled_tier_is_excluded_from_link_options(self): self.assertNotIn(disabled.name, values) def test_disabled_tier_submission_is_rejected(self): - tier = self.make_tier("Retired", enabled=0) + tier = SponsorshipTierFactory.create(event=self.event.name, enabled=0) with self.assertRaises(frappe.ValidationError): submit_enquiry_form(self.event.route, self.form_values(tier=tier.name)) - def make_tier(self, title, enabled=1): - return frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": self.event.name, - "title": title, - "prices": [{"currency": "INR", "price": 100}], - "enabled": enabled, - } - ).insert(ignore_permissions=True) - def test_guest_submission_requires_a_valid_contact_email(self): - frappe.set_user("Guest") + self.enterContext(self.set_user("Guest")) with self.assertRaises(frappe.MandatoryError): submit_enquiry_form(self.event.route, self.form_values()) @@ -244,18 +171,19 @@ def test_guest_submission_requires_a_valid_contact_email(self): self.assertEqual(enquiry.contact_email, "applicant@example.com") def test_contact_email_addresses_mail_but_grants_no_access(self): - frappe.set_user("Guest") - name = submit_enquiry_form(self.event.route, self.form_values(contact_email="applicant@example.com")) + with self.set_user("Guest"): + name = submit_enquiry_form( + self.event.route, self.form_values(contact_email="applicant@example.com") + ) enquiry = frappe.get_doc("Sponsorship Enquiry", name) self.assertEqual(enquiry.contact_recipient, "applicant@example.com") # The address is never verified, so holding that mailbox proves nothing. - applicant = self.make_user("applicant@example.com") - frappe.set_user(applicant) - with self.assertRaises(EnquiryNotAccessible): - get_sponsorship_details(name) - self.assertNotIn(name, {row.name for row in get_user_sponsorship_inquiries()}) + with self.set_user(UserFactory.create_once("applicant@example.com").name): + with self.assertRaises(EnquiryNotAccessible): + get_sponsorship_details(name) + self.assertNotIn(name, {row.name for row in get_user_sponsorship_inquiries()}) def test_custom_answers_validate_required_zero_and_options(self): self.add_question(mandatory=1) diff --git a/buzz/api/sponsorships/test_manage.py b/buzz/api/sponsorships/test_manage.py index 047aab0e..5f7debdf 100644 --- a/buzz/api/sponsorships/test_manage.py +++ b/buzz/api/sponsorships/test_manage.py @@ -8,8 +8,8 @@ update_enquiry_status, ) from buzz.api.sponsorships.exceptions import EnquiryNotFound, EnquiryStatusLocked, EnquiryTierMissing -from buzz.api.sponsorships.test_sponsorships import SponsorshipTestCase -from buzz.tests.factories import SponsorshipEnquiryFactory +from buzz.tests.base_test_cases import SponsorshipTestCase +from buzz.tests.factories import BuzzTeamMembershipFactory, SponsorshipEnquiryFactory, UserFactory TIER_FIELDS = {"name", "title", "prices", "slots", "enabled", "perks", "sponsor_count"} SPONSOR_FIELDS = { @@ -38,92 +38,64 @@ class ManageTestCase(SponsorshipTestCase): - def make_member(self, team_role: str) -> str: - email = f"sponsorships-{team_role.lower()}-{frappe.generate_hash(length=6)}@example.com" - user = frappe.new_doc("User") - user.email = email - user.first_name = "Team" - user.append("roles", {"role": "Buzz User"}) - user.insert(ignore_permissions=True) - frappe.get_doc( - { - "doctype": "Buzz Team Membership", - "team": self.team(), - "user": email, - "team_role": team_role, - "enabled": 1, - } - ).insert(ignore_permissions=True) - return email - - def team(self) -> str: - return frappe.db.get_value("Buzz Event", self.event, "team") + @classmethod + def setUpClass(cls): + super().setUpClass() + team = frappe.db.get_value("Buzz Event", cls.event, "team") + cls.manager = UserFactory.create_once("sponsorships-manager@example.com").name + cls.viewer = UserFactory.create_once("sponsorships-viewer@example.com").name + BuzzTeamMembershipFactory.create(team=team, user=cls.manager, team_role="Manager") + BuzzTeamMembershipFactory.create(team=team, user=cls.viewer, team_role="Viewer") class TestGetEventSponsorships(ManageTestCase): def test_team_member_reads_tiers_and_sponsors(self): self.make_sponsor() - frappe.set_user(self.make_member("Manager")) - response = get_event_sponsorships(self.event) + with self.set_user(self.manager): + response = get_event_sponsorships(self.event) self.assertEqual(set(response.tiers[0].__json__()), TIER_FIELDS) self.assertEqual(set(response.sponsors[0].__json__()), SPONSOR_FIELDS) def test_tier_counts_its_sponsors(self): self.make_sponsor() - frappe.set_user(self.make_member("Manager")) - response = get_event_sponsorships(self.event) + with self.set_user(self.manager): + response = get_event_sponsorships(self.event) tier = next(tier for tier in response.tiers if tier.name == self.tier.name) self.assertEqual(tier.sponsor_count, 1) def test_non_member_is_refused(self): - frappe.set_user(self.make_stranger()) - - with self.assertRaises(CannotManageEvent): + with self.set_user(self.make_stranger()), self.assertRaises(CannotManageEvent): get_event_sponsorships(self.event) def test_viewer_gets_can_write_false(self): - frappe.set_user(self.make_member("Viewer")) - - response = get_event_sponsorships(self.event) + with self.set_user(self.viewer): + response = get_event_sponsorships(self.event) self.assertFalse(response.can_write) class TestGetEventSponsorshipEnquiries(ManageTestCase): - def make_enquiry(self, company_name: str, status: str = "Approval Pending"): - return frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": self.event, - "tier": self.tier.name, - "company_name": company_name, - "company_logo": "/files/acme.png", - "status": status, - } - ).insert() - def test_rows_carry_tier_title_and_price(self): - frappe.set_user(self.make_member("Viewer")) - - response = get_event_sponsorship_enquiries(self.event, search="Acme Corp") + with self.set_user(self.viewer): + response = get_event_sponsorship_enquiries(self.event, search="Acme Corp") row = next(row for row in response.enquiries if row.name == self.enquiry.name) self.assertEqual(set(row.__json__()), ENQUIRY_FIELDS) - self.assertEqual((row.tier_title, row.tier_price), (self.tier.title, 5000)) + self.assertEqual((row.tier_title, row.tier_price), ("Gold", 5000)) def test_search_and_status_narrow_the_page(self): suffix = frappe.generate_hash(length=6) - paid = self.make_enquiry(f"Zeta {suffix}", status="Paid") - self.make_enquiry(f"Zeta {suffix} Two") - frappe.set_user(self.make_member("Manager")) + paid = self.create_enquiry(f"Zeta {suffix}", status="Paid") + self.create_enquiry(f"Zeta {suffix} Two") - response = get_event_sponsorship_enquiries( - self.event, search=f"Zeta {suffix}", filters='[["status", "in", ["Paid"]]]' - ) + with self.set_user(self.manager): + response = get_event_sponsorship_enquiries( + self.event, search=f"Zeta {suffix}", filters='[["status", "in", ["Paid"]]]' + ) self.assertEqual([row.name for row in response.enquiries], [paid.name]) self.assertEqual(response.matched, 1) @@ -138,9 +110,9 @@ def test_form_questions_are_offered_and_filter_the_page(self): event=self.event, additional_fields=[{"fieldname": "booth", "label": "Needs a booth", "value": "1"}], ) - frappe.set_user(self.make_member("Manager")) - response = get_event_sponsorship_enquiries(self.event, filters='[["booth", "in", ["1"]]]') + with self.set_user(self.manager): + response = get_event_sponsorship_enquiries(self.event, filters='[["booth", "in", ["1"]]]') self.assertIn("booth", [field.key for field in response.filter_fields]) self.assertEqual([row.name for row in response.enquiries], [booth.name]) @@ -148,13 +120,12 @@ def test_form_questions_are_offered_and_filter_the_page(self): def test_pages_follow_start_and_limit(self): suffix = frappe.generate_hash(length=6) for index in range(3): - self.make_enquiry(f"Paged {suffix} {index}") - frappe.set_user(self.make_member("Manager")) + self.create_enquiry(f"Paged {suffix} {index}") - first = get_event_sponsorship_enquiries(self.event, search=f"Paged {suffix}", order="asc", limit=2) - rest = get_event_sponsorship_enquiries( - self.event, search=f"Paged {suffix}", order="asc", start=2, limit=2 - ) + with self.set_user(self.manager): + search = {"search": f"Paged {suffix}", "order": "asc", "limit": 2} + first = get_event_sponsorship_enquiries(self.event, **search) + rest = get_event_sponsorship_enquiries(self.event, start=2, **search) self.assertTrue(first.has_next_page) self.assertFalse(rest.has_next_page) @@ -162,28 +133,29 @@ def test_pages_follow_start_and_limit(self): self.assertEqual(names, [f"Paged {suffix} {index}" for index in range(3)]) def test_stranger_is_refused(self): - frappe.set_user(self.make_stranger()) - - with self.assertRaises(CannotManageEvent): + with self.set_user(self.make_stranger()), self.assertRaises(CannotManageEvent): get_event_sponsorship_enquiries(self.event) + def create_enquiry(self, company_name: str, status: str = "Approval Pending"): + return SponsorshipEnquiryFactory.create( + event=self.event, tier=self.tier.name, company_name=company_name, status=status + ) + class TestGetEventSponsorshipEnquiry(ManageTestCase): def test_team_member_reads_answers_and_sponsor(self): self.enquiry.append("additional_fields", {"label": "Budget", "fieldname": "budget", "value": "5000"}) self.enquiry.save() sponsor = self.make_sponsor() - frappe.set_user(self.make_member("Viewer")) - detail = get_event_sponsorship_enquiry(self.enquiry.name) + with self.set_user(self.viewer): + detail = get_event_sponsorship_enquiry(self.enquiry.name) self.assertEqual(detail.sponsor, sponsor.name) self.assertEqual([(answer.label, answer.value) for answer in detail.answers], [("Budget", "5000")]) def test_stranger_is_refused(self): - frappe.set_user(self.make_stranger()) - - with self.assertRaises(CannotManageEvent): + with self.set_user(self.make_stranger()), self.assertRaises(CannotManageEvent): get_event_sponsorship_enquiry(self.enquiry.name) def test_unknown_enquiry_is_not_found(self): @@ -192,25 +164,21 @@ def test_unknown_enquiry_is_not_found(self): class TestUpdateEnquiryStatus(ManageTestCase): - def status(self) -> str: - return frappe.db.get_value("Sponsorship Enquiry", self.enquiry.name, "status") + def setUp(self): + super().setUp() + self.enterContext(self.set_user(self.manager)) def test_manager_moves_the_enquiry_along(self): - frappe.set_user(self.make_member("Manager")) - self.assertEqual(update_enquiry_status(self.enquiry.name, "Payment Pending"), "Payment Pending") - self.assertEqual(self.status(), "Payment Pending") + self.assertEqual(self.enquiry_status(), "Payment Pending") def test_marking_paid_lists_the_sponsor_once(self): - frappe.set_user(self.make_member("Manager")) - update_enquiry_status(self.enquiry.name, "Paid") update_enquiry_status(self.enquiry.name, "Paid") self.assertEqual(frappe.db.count("Event Sponsor", {"enquiry": self.enquiry.name}), 1) def test_paid_enquiry_keeps_its_status(self): - frappe.set_user(self.make_member("Manager")) update_enquiry_status(self.enquiry.name, "Paid") with self.assertRaises(EnquiryStatusLocked): @@ -218,22 +186,20 @@ def test_paid_enquiry_keeps_its_status(self): def test_approval_needs_a_tier(self): frappe.db.set_value("Sponsorship Enquiry", self.enquiry.name, "tier", None) - frappe.set_user(self.make_member("Manager")) with self.assertRaises(EnquiryTierMissing): update_enquiry_status(self.enquiry.name, "Payment Pending") def test_unknown_status_is_rejected(self): - frappe.set_user(self.make_member("Manager")) - with self.assertRaises(frappe.ValidationError): update_enquiry_status(self.enquiry.name, "Rejected") def test_viewer_and_stranger_are_refused(self): - for user in (self.make_member("Viewer"), self.make_stranger()): - with self.subTest(user): - frappe.set_user(user) - with self.assertRaises(CannotManageEvent): - update_enquiry_status(self.enquiry.name, "Withdrawn") - frappe.set_user("Administrator") - self.assertEqual(self.status(), "Approval Pending") + for user in (self.viewer, self.make_stranger()): + with self.subTest(user), self.set_user(user), self.assertRaises(CannotManageEvent): + update_enquiry_status(self.enquiry.name, "Withdrawn") + + self.assertEqual(self.enquiry_status(), "Approval Pending") + + def enquiry_status(self) -> str: + return frappe.db.get_value("Sponsorship Enquiry", self.enquiry.name, "status") diff --git a/buzz/api/sponsorships/test_sponsorships.py b/buzz/api/sponsorships/test_sponsorships.py index 5855e052..384418f4 100644 --- a/buzz/api/sponsorships/test_sponsorships.py +++ b/buzz/api/sponsorships/test_sponsorships.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock, patch import frappe -from frappe.tests import IntegrationTestCase from buzz.api.sponsorships import ( create_sponsorship_payment_link, @@ -16,103 +15,16 @@ PaymentNotPermitted, WithdrawalNotPermitted, ) -from buzz.ticketing.doctype.event_booking.test_event_booking_refund import make_payment_gateway - -ENQUIRY_FIELDS = { - "name", - "company_name", - "company_logo", - "event", - "tier", - "tier_title", - "status", - "creation", - "owner", -} -EVENT_FIELDS = {"title", "short_description", "about", "start_date", "end_date", "venue", "route"} -SPONSOR_FIELDS = {"name", "company_name", "company_logo", "creation", "event", "tier", "tier_title"} -LIST_FIELDS = { - "name", - "company_name", - "event", - "tier", - "status", - "creation", - "event_title", - "tier_title", - "has_sponsor", -} - - -class SponsorshipTestCase(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.event = str(frappe.get_doc("Buzz Event", {"route": "test-route"}).name) - - def setUp(self): - frappe.set_user("Administrator") - frappe.clear_messages() - - self.tier = frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": self.event, - "title": f"Sponsorship Test {frappe.generate_hash(length=6)}", - "prices": [{"currency": "INR", "price": 5000}], - } - ).insert() - - self.enquiry = frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": self.event, - "tier": self.tier.name, - "company_name": "Acme Corp", - "company_logo": "/files/acme.png", - "status": "Approval Pending", - } - ).insert() - - def tearDown(self): - frappe.set_user("Administrator") - frappe.db.rollback() - - def make_stranger(self) -> str: - email = f"stranger-{frappe.generate_hash(length=6)}@example.com" - user = frappe.new_doc("User") - user.email = email - user.first_name = "Stranger" - user.append("roles", {"role": "Buzz User"}) - user.insert(ignore_permissions=True) - return email - - def make_sponsor(self): - return frappe.get_doc( - { - "doctype": "Event Sponsor", - "event": self.event, - "tier": self.tier.name, - "company_name": "Acme Corp", - "company_logo": "/files/acme.png", - "enquiry": self.enquiry.name, - } - ).insert() +from buzz.tests.base_test_cases import SponsorshipTestCase +from buzz.tests.factories import PaymentGatewayFactory class TestGetSponsorshipDetails(SponsorshipTestCase): - def test_response_shape(self): - response = get_sponsorship_details(self.enquiry.name).__json__() - - self.assertEqual(set(response), {"enquiry", "event_details", "sponsor_details", "has_sponsor"}) - self.assertEqual(set(response["enquiry"]), ENQUIRY_FIELDS) - self.assertEqual(set(response["event_details"]), EVENT_FIELDS) - def test_enquiry_carries_the_tier_title(self): enquiry = get_sponsorship_details(self.enquiry.name).__json__()["enquiry"] self.assertEqual(enquiry["name"], self.enquiry.name) - self.assertEqual(enquiry["tier_title"], self.tier.title) + self.assertEqual(enquiry["tier_title"], "Gold") self.assertEqual(enquiry["owner"], "Administrator") def test_no_sponsor_yet(self): @@ -126,14 +38,15 @@ def test_sponsor_details_once_sponsored(self): response = get_sponsorship_details(self.enquiry.name).__json__() self.assertTrue(response["has_sponsor"]) - self.assertEqual(set(response["sponsor_details"]), SPONSOR_FIELDS) + self.assertEqual( + set(response["sponsor_details"]), + {"name", "company_name", "company_logo", "creation", "event", "tier", "tier_title"}, + ) self.assertEqual(response["sponsor_details"]["name"], sponsor.name) - self.assertEqual(response["sponsor_details"]["tier_title"], self.tier.title) + self.assertEqual(response["sponsor_details"]["tier_title"], "Gold") def test_stranger_is_refused(self): - frappe.set_user(self.make_stranger()) - - with self.assertRaises(EnquiryNotAccessible): + with self.set_user(self.make_stranger()), self.assertRaises(EnquiryNotAccessible): get_sponsorship_details(self.enquiry.name) self.assertEqual(frappe.local.message_log[-1]["title"], "Not Permitted") @@ -142,38 +55,26 @@ def test_unknown_enquiry_raises_does_not_exist(self): with self.assertRaises(frappe.DoesNotExistError): get_sponsorship_details("no-such-enquiry") - def test_status_codes(self): - self.assertEqual(EnquiryNotAccessible.http_status_code, 403) - self.assertEqual(PaymentNotPermitted.http_status_code, 403) - self.assertEqual(WithdrawalNotPermitted.http_status_code, 403) - self.assertEqual(EnquiryAlreadyPaid.http_status_code, 409) - self.assertEqual(EnquiryAlreadyWithdrawn.http_status_code, 409) - class TestGetUserSponsorshipInquiries(SponsorshipTestCase): - def test_response_shape(self): - rows = [row.__json__() for row in get_user_sponsorship_inquiries()] - mine = [row for row in rows if row["name"] == self.enquiry.name] - - self.assertEqual(len(mine), 1) - self.assertEqual(set(mine[0]), LIST_FIELDS) - self.assertEqual(mine[0]["tier_title"], self.tier.title) - self.assertEqual(mine[0]["event_title"], frappe.db.get_value("Buzz Event", self.event, "title")) - self.assertFalse(mine[0]["has_sponsor"]) - def test_has_sponsor_flips_once_sponsored(self): + event_title = frappe.db.get_value("Buzz Event", self.event, "title") + row = self.listed_row() + self.assertEqual((row.tier_title, row.event_title, row.has_sponsor), ("Gold", event_title, False)) + self.make_sponsor() - rows = {row.name: row for row in get_user_sponsorship_inquiries()} - self.assertTrue(rows[self.enquiry.name].has_sponsor) + self.assertTrue(self.listed_row().has_sponsor) def test_only_own_enquiries_are_listed(self): - frappe.set_user(self.make_stranger()) - - names = [row.name for row in get_user_sponsorship_inquiries()] + with self.set_user(self.make_stranger()): + names = [row.name for row in get_user_sponsorship_inquiries()] self.assertNotIn(self.enquiry.name, names) + def listed_row(self): + return next(row for row in get_user_sponsorship_inquiries() if row.name == self.enquiry.name) + class TestWithdrawSponsorshipEnquiry(SponsorshipTestCase): def test_withdraw_sets_the_status(self): @@ -198,17 +99,13 @@ def test_paid_enquiry_cannot_be_withdrawn(self): withdraw_sponsorship_enquiry(self.enquiry.name) def test_stranger_cannot_withdraw(self): - frappe.set_user(self.make_stranger()) - - with self.assertRaises(WithdrawalNotPermitted): + with self.set_user(self.make_stranger()), self.assertRaises(WithdrawalNotPermitted): withdraw_sponsorship_enquiry(self.enquiry.name) class TestCreateSponsorshipPaymentLink(SponsorshipTestCase): def test_stranger_cannot_create_a_payment_link(self): - frappe.set_user(self.make_stranger()) - - with self.assertRaises(PaymentNotPermitted): + with self.set_user(self.make_stranger()), self.assertRaises(PaymentNotPermitted): create_sponsorship_payment_link(self.enquiry.name, self.tier.name) def test_disabled_tier_cannot_create_a_payment_link(self): @@ -218,27 +115,27 @@ def test_disabled_tier_cannot_create_a_payment_link(self): with self.assertRaises(frappe.ValidationError): create_sponsorship_payment_link(self.enquiry.name, self.tier.name) - def use_razorpay(self): - make_payment_gateway("Razorpay") - event = frappe.get_doc("Buzz Event", self.event) - event.payment_gateways = [] - event.append("payment_gateways", {"payment_gateway": "Razorpay"}) - event.save(ignore_permissions=True) - self.addCleanup(frappe.clear_document_cache, "Buzz Event", self.event) - def test_link_charges_the_chosen_currency(self): self.tier.append("prices", {"currency": "USD", "price": 60}) self.tier.save() - self.use_razorpay() + gateway = self.add_gateway_to_event() with patch("buzz.payments.get_controller", return_value=MagicMock()): - create_sponsorship_payment_link(self.enquiry.name, self.tier.name, "Razorpay", currency="USD") + create_sponsorship_payment_link(self.enquiry.name, self.tier.name, gateway, currency="USD") payment = frappe.get_last_doc("Event Payment", {"reference_docname": self.enquiry.name}) self.assertEqual((payment.currency, payment.amount), ("USD", 60)) def test_link_refuses_a_currency_the_tier_has_no_price_in(self): - self.use_razorpay() + gateway = self.add_gateway_to_event() with self.assertRaises(frappe.ValidationError): - create_sponsorship_payment_link(self.enquiry.name, self.tier.name, "Razorpay", currency="EUR") + create_sponsorship_payment_link(self.enquiry.name, self.tier.name, gateway, currency="EUR") + + def add_gateway_to_event(self) -> str: + gateway = PaymentGatewayFactory.create().name + event = frappe.get_doc("Buzz Event", self.event) + event.set("payment_gateways", [{"payment_gateway": gateway}]) + event.save(ignore_permissions=True) + self.addCleanup(frappe.clear_document_cache, "Buzz Event", self.event) + return gateway diff --git a/buzz/api/teams/test_invitations.py b/buzz/api/teams/test_invitations.py index 79104cfb..53ce6583 100644 --- a/buzz/api/teams/test_invitations.py +++ b/buzz/api/teams/test_invitations.py @@ -11,35 +11,35 @@ NoPendingInvite, UnknownTeamRole, ) -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership +from buzz.tests.factories import ( + BuzzTeamFactory, + BuzzTeamMembershipFactory, + UserFactory, + UserInvitationFactory, +) -class TestInviteMembers(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its users and its team names. +class InvitationTestCase(IntegrationTestCase): def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - # Every invitation mails itself out on insert, which needs an outgoing email account. + # Mailing needs an outgoing email account. self.enterContext(patch("frappe.sendmail")) - def team_owned_by(self, name: str, owner_email: str) -> tuple[str, str]: - owner = create_user(owner_email, "Owner") - return create_owned_team(name, owner), owner + def create_user(self, email: str) -> str: + return UserFactory.create_once(email).name + + def create_team_and_owner(self, owner_email: str) -> tuple[str, str]: + owner = self.create_user(owner_email) + return BuzzTeamFactory.create_owned_by(owner).name, owner + + def add_member(self, team: str, user: str, team_role: str, **overrides): + BuzzTeamMembershipFactory.create(team=team, user=user, team_role=team_role, **overrides) - def invitation_for(self, email: str) -> dict: - return frappe.db.get_value( - "User Invitation", - {"email": email}, - ["buzz_team", "buzz_team_role", "status"], - as_dict=True, - ) +class TestInviteMembers(InvitationTestCase): def test_invites_an_email_that_belongs_to_no_user_yet(self): - team, owner = self.team_owned_by("Invite Newcomer", "invite-owner@example.com") + team, owner = self.create_team_and_owner("invite-owner@example.com") - frappe.set_user(owner) - outcomes = invite_members(team, [{"email": "newcomer@example.com", "team_role": "Manager"}]) + outcomes = self.invite_as(owner, team, "newcomer@example.com", "Manager") self.assertEqual([outcome.status for outcome in outcomes], ["invited"]) invitation = self.invitation_for("newcomer@example.com") @@ -48,11 +48,10 @@ def test_invites_an_email_that_belongs_to_no_user_yet(self): self.assertEqual(invitation.status, "Pending") def test_adds_an_existing_user_to_the_team_without_an_invitation(self): - team, owner = self.team_owned_by("Invite Existing", "invite-owner2@example.com") - colleague = create_user("invite-colleague@example.com", "Colleague") + team, owner = self.create_team_and_owner("invite-owner2@example.com") + colleague = self.create_user("invite-colleague@example.com") - frappe.set_user(owner) - outcomes = invite_members(team, [{"email": colleague, "team_role": "Frontdesk"}]) + outcomes = self.invite_as(owner, team, colleague, "Frontdesk") self.assertEqual([outcome.status for outcome in outcomes], ["added"]) self.assertIsNone(self.invitation_for(colleague)) @@ -63,56 +62,50 @@ def test_adds_an_existing_user_to_the_team_without_an_invitation(self): self.assertTrue(membership.enabled) def test_a_manager_cannot_invite_anyone(self): - team, _ = self.team_owned_by("Invite Manager Guard", "invite-owner3@example.com") - manager = create_user("invite-manager@example.com", "Manager") - upsert_membership(team, manager, "Manager") + team, _ = self.create_team_and_owner("invite-owner3@example.com") + manager = self.create_user("invite-manager@example.com") + self.add_member(team, manager, "Manager") - frappe.set_user(manager) with self.assertRaises(CannotManageMembers): - invite_members(team, [{"email": "manager-invitee@example.com", "team_role": "Viewer"}]) + self.invite_as(manager, team, "manager-invitee@example.com", "Viewer") self.assertIsNone(self.invitation_for("manager-invitee@example.com")) def test_a_viewer_cannot_invite_anyone(self): - team, _ = self.team_owned_by("Invite Viewer Guard", "invite-owner4@example.com") - viewer = create_user("invite-viewer@example.com", "Viewer") - upsert_membership(team, viewer, "Viewer") + team, _ = self.create_team_and_owner("invite-owner4@example.com") + viewer = self.create_user("invite-viewer@example.com") + self.add_member(team, viewer, "Viewer") - frappe.set_user(viewer) with self.assertRaises(CannotManageMembers): - invite_members(team, [{"email": "viewer-invitee@example.com", "team_role": "Viewer"}]) + self.invite_as(viewer, team, "viewer-invitee@example.com", "Viewer") def test_an_outsider_cannot_invite_anyone(self): - team, _ = self.team_owned_by("Invite Outsider Guard", "invite-owner5@example.com") - outsider = create_user("invite-outsider@example.com", "Outsider") + team, _ = self.create_team_and_owner("invite-owner5@example.com") + outsider = self.create_user("invite-outsider@example.com") - frappe.set_user(outsider) with self.assertRaises(CannotManageMembers): - invite_members(team, [{"email": "outsider-invitee@example.com", "team_role": "Viewer"}]) + self.invite_as(outsider, team, "outsider-invitee@example.com", "Viewer") def test_ownership_cannot_be_granted(self): - team, owner = self.team_owned_by("Invite Ownership", "invite-owner6@example.com") + team, owner = self.create_team_and_owner("invite-owner6@example.com") - frappe.set_user(owner) with self.assertRaises(CannotGrantOwnership): - invite_members(team, [{"email": "would-be-owner@example.com", "team_role": "Owner"}]) + self.invite_as(owner, team, "would-be-owner@example.com", "Owner") self.assertIsNone(self.invitation_for("would-be-owner@example.com")) def test_an_unknown_role_is_rejected(self): - team, owner = self.team_owned_by("Invite Unknown Role", "invite-owner7@example.com") + team, owner = self.create_team_and_owner("invite-owner7@example.com") - frappe.set_user(owner) with self.assertRaises(UnknownTeamRole): - invite_members(team, [{"email": "bad-role@example.com", "team_role": "Overlord"}]) + self.invite_as(owner, team, "bad-role@example.com", "Overlord") def test_an_existing_member_is_left_alone(self): - team, owner = self.team_owned_by("Invite Existing Member", "invite-owner8@example.com") - member = create_user("invite-settled@example.com", "Settled") - upsert_membership(team, member, "Viewer") + team, owner = self.create_team_and_owner("invite-owner8@example.com") + member = self.create_user("invite-settled@example.com") + self.add_member(team, member, "Viewer") - frappe.set_user(owner) - outcomes = invite_members(team, [{"email": member, "team_role": "Admin"}]) + outcomes = self.invite_as(owner, team, member, "Admin") self.assertEqual([outcome.status for outcome in outcomes], ["already_a_member"]) self.assertEqual( @@ -121,119 +114,94 @@ def test_an_existing_member_is_left_alone(self): ) def test_a_removed_member_is_re_enabled_rather_than_invited(self): - team, owner = self.team_owned_by("Invite Returning", "invite-owner9@example.com") - member = create_user("invite-returning@example.com", "Returning") - upsert_membership(team, member, "Manager") - frappe.db.set_value("Buzz Team Membership", {"team": team, "user": member}, "enabled", 0) + team, owner = self.create_team_and_owner("invite-owner9@example.com") + member = self.create_user("invite-returning@example.com") + self.add_member(team, member, "Manager", enabled=0) - frappe.set_user(owner) - outcomes = invite_members(team, [{"email": member, "team_role": "Frontdesk"}]) + outcomes = self.invite_as(owner, team, member, "Frontdesk") self.assertEqual([outcome.status for outcome in outcomes], ["added"]) self.assertIsNone(self.invitation_for(member)) def test_an_existing_user_is_matched_regardless_of_case(self): - team, owner = self.team_owned_by("Invite Mixed Case", "invite-owner10@example.com") - colleague = create_user("invite-mixedcase@example.com", "Mixed") + team, owner = self.create_team_and_owner("invite-owner10@example.com") + colleague = self.create_user("invite-mixedcase@example.com") - frappe.set_user(owner) - outcomes = invite_members(team, [{"email": "Invite-MixedCase@Example.com", "team_role": "Viewer"}]) + outcomes = self.invite_as(owner, team, "Invite-MixedCase@Example.com", "Viewer") self.assertEqual([outcome.status for outcome in outcomes], ["added"]) self.assertTrue(frappe.db.exists("Buzz Team Membership", {"team": team, "user": colleague})) def test_names_the_person_who_was_added(self): - team, owner = self.team_owned_by("Invite Named", "invite-owner11@example.com") - colleague = create_user("invite-named@example.com", "Rhea") + team, owner = self.create_team_and_owner("invite-owner11@example.com") + colleague = UserFactory.create(first_name="Rhea", last_name="").name - frappe.set_user(owner) - outcomes = invite_members(team, [{"email": colleague, "team_role": "Manager"}]) + outcomes = self.invite_as(owner, team, colleague, "Manager") self.assertEqual(outcomes[0].full_name, "Rhea") def test_an_invited_stranger_has_no_name_to_show_yet(self): - team, owner = self.team_owned_by("Invite Nameless", "invite-owner12@example.com") + team, owner = self.create_team_and_owner("invite-owner12@example.com") - frappe.set_user(owner) - outcomes = invite_members(team, [{"email": "nameless@example.com", "team_role": "Viewer"}]) + outcomes = self.invite_as(owner, team, "nameless@example.com", "Viewer") self.assertIsNone(outcomes[0].full_name) + def invite_as(self, user: str, team: str, email: str, team_role: str) -> list: + with self.set_user(user): + return invite_members(team, [{"email": email, "team_role": team_role}]) + + def invitation_for(self, email: str) -> dict: + return frappe.db.get_value( + "User Invitation", + {"email": email}, + ["buzz_team", "buzz_team_role", "status"], + as_dict=True, + ) -class TestTeamOverviewInvitations(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its users and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - # Every invitation mails itself out on insert, which needs an outgoing email account. - self.enterContext(patch("frappe.sendmail")) +class TestTeamOverviewInvitations(InvitationTestCase): def test_lists_the_teams_pending_invitations(self): - owner = create_user("overview-invites-owner@example.com", "Owner") - team = create_owned_team("Overview Invites", owner) + team, owner = self.create_team_and_owner("overview-invites-owner@example.com") - frappe.set_user(owner) - invite_members(team, [{"email": "awaited@example.com", "team_role": "Frontdesk"}]) - invites = get_team_overview(team).invites + with self.set_user(owner): + invite_members(team, [{"email": "awaited@example.com", "team_role": "Frontdesk"}]) + invites = get_team_overview(team).invites self.assertEqual(len(invites), 1) self.assertEqual(invites[0].email, "awaited@example.com") self.assertEqual(invites[0].team_role, "Frontdesk") def test_leaves_out_another_teams_invitations(self): - owner = create_user("overview-invites-mine@example.com", "Owner") - mine = create_owned_team("Overview Invites Mine", owner) - theirs = create_owned_team( - "Overview Invites Theirs", create_user("overview-invites-other@example.com", "Other") - ) + mine, owner = self.create_team_and_owner("overview-invites-mine@example.com") + theirs, _ = self.create_team_and_owner("overview-invites-other@example.com") + self.create_invitation(mine, "mine@example.com") + self.create_invitation(theirs, "theirs@example.com") - frappe.set_user(owner) - invite_members(mine, [{"email": "mine@example.com", "team_role": "Viewer"}]) - frappe.set_user("Administrator") - invite_members(theirs, [{"email": "theirs@example.com", "team_role": "Viewer"}]) + with self.set_user(owner): + invites = get_team_overview(mine).invites - frappe.set_user(owner) - self.assertEqual([invite.email for invite in get_team_overview(mine).invites], ["mine@example.com"]) + self.assertEqual([invite.email for invite in invites], ["mine@example.com"]) def test_drops_an_invitation_once_it_is_no_longer_pending(self): - owner = create_user("overview-invites-settled@example.com", "Owner") - team = create_owned_team("Overview Invites Settled", owner) - - frappe.set_user(owner) - invite_members(team, [{"email": "cancelled@example.com", "team_role": "Viewer"}]) - frappe.set_user("Administrator") - frappe.db.set_value("User Invitation", {"email": "cancelled@example.com"}, "status", "Cancelled") - - frappe.set_user(owner) - self.assertEqual(get_team_overview(team).invites, []) + team, owner = self.create_team_and_owner("overview-invites-settled@example.com") + invitation = self.create_invitation(team, "cancelled@example.com") + frappe.db.set_value("User Invitation", invitation, "status", "Cancelled") + with self.set_user(owner): + self.assertEqual(get_team_overview(team).invites, []) -class TestInviteActions(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its users and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - # Every invitation mails itself out on insert, which needs an outgoing email account. - self.enterContext(patch("frappe.sendmail")) - - def invited_team(self, name: str, owner_email: str, invitee: str) -> tuple[str, str]: - """A team with one pending invitation, and the owner who sent it.""" - owner = create_user(owner_email, "Owner") - team = create_owned_team(name, owner) - frappe.set_user(owner) - invite_members(team, [{"email": invitee, "team_role": "Viewer"}]) - return team, owner + def create_invitation(self, team: str, email: str) -> str: + return UserInvitationFactory.create(email=email, buzz_team=team, buzz_team_role="Viewer").name - def invitation_for(self, email: str) -> dict: - return frappe.db.get_value( - "User Invitation", {"email": email}, ["name", "key", "status"], as_dict=True - ) +class TestInviteActions(InvitationTestCase): def test_resending_rotates_the_key_and_leaves_the_invitation_pending(self): - team, _ = self.invited_team("Resend Rotates", "resend-owner@example.com", "resend-me@example.com") + team, owner = self.create_team_with_invite("resend-owner@example.com", "resend-me@example.com") before = self.invitation_for("resend-me@example.com") - resend_invite(team, "resend-me@example.com") + with self.set_user(owner): + resend_invite(team, "resend-me@example.com") after = self.invitation_for("resend-me@example.com") self.assertEqual(after.status, "Pending") @@ -241,69 +209,77 @@ def test_resending_rotates_the_key_and_leaves_the_invitation_pending(self): self.assertNotEqual(after.key, before.key) def test_retracting_cancels_the_invitation_and_drops_it_from_the_overview(self): - team, _ = self.invited_team("Retract Cancels", "retract-owner@example.com", "retract-me@example.com") + team, owner = self.create_team_with_invite("retract-owner@example.com", "retract-me@example.com") - retract_invite(team, "retract-me@example.com") + with self.set_user(owner): + retract_invite(team, "retract-me@example.com") + invites = get_team_overview(team).invites self.assertEqual(self.invitation_for("retract-me@example.com").status, "Cancelled") - self.assertEqual(get_team_overview(team).invites, []) + self.assertEqual(invites, []) def test_an_address_is_matched_regardless_of_case_or_padding(self): - team, _ = self.invited_team("Retract Untidy", "untidy-owner@example.com", "untidy@example.com") + team, owner = self.create_team_with_invite("untidy-owner@example.com", "untidy@example.com") - retract_invite(team, " Untidy@Example.com ") + with self.set_user(owner): + retract_invite(team, " Untidy@Example.com ") self.assertEqual(self.invitation_for("untidy@example.com").status, "Cancelled") def test_a_manager_can_do_neither(self): - team, _ = self.invited_team( - "Actions Manager Guard", "actions-owner2@example.com", "guarded@example.com" - ) - manager = create_user("actions-manager@example.com", "Manager") - upsert_membership(team, manager, "Manager") + team, _ = self.create_team_with_invite("actions-owner2@example.com", "guarded@example.com") + manager = self.create_user("actions-manager@example.com") + self.add_member(team, manager, "Manager") - frappe.set_user(manager) - with self.assertRaises(CannotManageMembers): - resend_invite(team, "guarded@example.com") - with self.assertRaises(CannotManageMembers): - retract_invite(team, "guarded@example.com") + with self.set_user(manager): + with self.assertRaises(CannotManageMembers): + resend_invite(team, "guarded@example.com") + with self.assertRaises(CannotManageMembers): + retract_invite(team, "guarded@example.com") self.assertEqual(self.invitation_for("guarded@example.com").status, "Pending") def test_another_teams_owner_can_do_neither(self): - team, _ = self.invited_team("Actions Mine", "actions-owner3@example.com", "not-yours@example.com") - frappe.set_user("Administrator") - outsider = create_user("actions-other-owner@example.com", "Other") - create_owned_team("Actions Theirs", outsider) + team, _ = self.create_team_with_invite("actions-owner3@example.com", "not-yours@example.com") + _, outsider = self.create_team_and_owner("actions-other-owner@example.com") # Core's own guards are app-wide: an Event Manager passes them for any buzz invitation. - frappe.set_user(outsider) - with self.assertRaises(CannotManageMembers): - resend_invite(team, "not-yours@example.com") - with self.assertRaises(CannotManageMembers): - retract_invite(team, "not-yours@example.com") + with self.set_user(outsider): + with self.assertRaises(CannotManageMembers): + resend_invite(team, "not-yours@example.com") + with self.assertRaises(CannotManageMembers): + retract_invite(team, "not-yours@example.com") self.assertEqual(self.invitation_for("not-yours@example.com").status, "Pending") def test_an_unknown_address_has_nothing_to_act_on(self): - team, _ = self.invited_team("Actions Unknown", "actions-owner4@example.com", "known@example.com") + team, owner = self.create_team_with_invite("actions-owner4@example.com", "known@example.com") - with self.assertRaises(NoPendingInvite): + with self.set_user(owner), self.assertRaises(NoPendingInvite): resend_invite(team, "stranger@example.com") def test_a_retracted_invitation_cannot_be_retracted_again(self): - team, _ = self.invited_team("Actions Twice", "actions-owner5@example.com", "twice@example.com") - retract_invite(team, "twice@example.com") + team, owner = self.create_team_with_invite("actions-owner5@example.com", "twice@example.com") - with self.assertRaises(NoPendingInvite): + with self.set_user(owner): retract_invite(team, "twice@example.com") - with self.assertRaises(NoPendingInvite): - resend_invite(team, "twice@example.com") + + with self.assertRaises(NoPendingInvite): + retract_invite(team, "twice@example.com") + with self.assertRaises(NoPendingInvite): + resend_invite(team, "twice@example.com") + + def create_team_with_invite(self, owner_email: str, invitee: str) -> tuple[str, str]: + team, owner = self.create_team_and_owner(owner_email) + with self.set_user(owner): + invite_members(team, [{"email": invitee, "team_role": "Viewer"}]) + return team, owner + + def invitation_for(self, email: str) -> dict: + return frappe.db.get_value("User Invitation", {"email": email}, ["key", "status"], as_dict=True) class TestDefaultPath(IntegrationTestCase): def test_an_invitee_setting_their_password_lands_on_the_dashboard(self): - """Core sends a System User to `get_default_path()` after a password reset, ignoring - the invitation's own redirect. Buzz roles have no desk access, so that path has to - be the dashboard.""" + # After a password reset core redirects to `get_default_path()`, not the invitation's path. self.assertEqual(get_default_path(), "/b") diff --git a/buzz/api/teams/test_member_roles.py b/buzz/api/teams/test_member_roles.py new file mode 100644 index 00000000..14dcdee7 --- /dev/null +++ b/buzz/api/teams/test_member_roles.py @@ -0,0 +1,227 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.api.teams import change_roles, remove_members +from buzz.api.teams.exceptions import ( + CannotGrantOwnership, + CannotManageMembers, + NotATeamMember, + UnknownTeamRole, +) +from buzz.tests.factories import BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory + + +class MemberRoleTestCase(IntegrationTestCase): + # Roles span teams and rollback is per class, so every test owns its users. + def create_user(self, email: str) -> str: + return UserFactory.create_once(email).name + + def create_team(self, owner: str, *members: tuple[str, str]) -> str: + team = BuzzTeamFactory.create_owned_by(owner).name + for user, team_role in members: + BuzzTeamMembershipFactory.create(team=team, user=user, team_role=team_role) + return team + + +class TestRemoveMember(MemberRoleTestCase): + def test_an_admin_disables_the_membership_instead_of_deleting_it(self): + admin = self.create_user("remove-admin@example.com") + member = self.create_user("remove-member@example.com") + team = self.create_team( + self.create_user("remove-owner@example.com"), (admin, "Admin"), (member, "Manager") + ) + + with self.set_user(admin): + remove_members(team, [member]) + + self.assertEqual(self.membership_enabled(team, member), 0) + + def test_a_manager_cannot_remove_anyone(self): + manager = self.create_user("remove-manager@example.com") + member = self.create_user("remove-managers-target@example.com") + team = self.create_team( + self.create_user("remove-owner2@example.com"), (manager, "Manager"), (member, "Viewer") + ) + + with self.set_user(manager), self.assertRaises(CannotManageMembers): + remove_members(team, [member]) + + def test_an_outsider_cannot_remove_anyone(self): + owner = self.create_user("remove-owner3@example.com") + team = self.create_team(owner) + + with ( + self.set_user(self.create_user("remove-outsider@example.com")), + self.assertRaises(CannotManageMembers), + ): + remove_members(team, [owner]) + + def test_the_owner_cannot_be_removed(self): + owner = self.create_user("remove-locked-owner@example.com") + admin = self.create_user("remove-owners-admin@example.com") + team = self.create_team(owner, (admin, "Admin")) + + with self.set_user(admin), self.assertRaises(frappe.ValidationError): + remove_members(team, [owner]) + + def test_refuses_a_user_who_is_not_on_the_team(self): + owner = self.create_user("remove-stranger-owner@example.com") + stranger = self.create_user("remove-stranger@example.com") + team = self.create_team(owner) + + with self.set_user(owner), self.assertRaises(NotATeamMember): + remove_members(team, [stranger]) + + def test_removing_the_same_member_twice_is_refused(self): + owner = self.create_user("remove-twice-owner@example.com") + member = self.create_user("remove-twice-member@example.com") + team = self.create_team(owner, (member, "Manager")) + + with self.set_user(owner): + remove_members(team, [member]) + + with self.assertRaises(NotATeamMember): + remove_members(team, [member]) + + def test_the_desk_role_survives_while_another_team_still_earns_it(self): + owner = self.create_user("remove-roles-owner@example.com") + member = self.create_user("remove-roles-member@example.com") + first = self.create_team(owner, (member, "Manager")) + second = self.create_team(owner, (member, "Manager")) + + with self.set_user(owner): + remove_members(first, [member]) + self.assertIn("Event Manager", frappe.get_roles(member)) + + remove_members(second, [member]) + self.assertNotIn("Event Manager", frappe.get_roles(member)) + + def test_an_admin_removes_several_members_at_once(self): + owner = self.create_user("remove-batch-owner@example.com") + first = self.create_user("remove-batch-first@example.com") + second = self.create_user("remove-batch-second@example.com") + team = self.create_team(owner, (first, "Manager"), (second, "Viewer")) + + with self.set_user(owner): + remove_members(team, [first, second]) + + self.assertEqual(self.membership_enabled(team, first), 0) + self.assertEqual(self.membership_enabled(team, second), 0) + + def membership_enabled(self, team: str, user: str) -> int: + return frappe.db.get_value("Buzz Team Membership", {"team": team, "user": user}, "enabled") + + +class TestChangeRole(MemberRoleTestCase): + def test_an_admin_promotes_a_manager(self): + admin = self.create_user("role-admin@example.com") + member = self.create_user("role-member@example.com") + team = self.create_team( + self.create_user("role-owner@example.com"), (admin, "Admin"), (member, "Manager") + ) + + with self.set_user(admin): + change_roles(team, [member], "Admin") + + self.assertEqual(self.role(team, member), "Admin") + + def test_a_demoted_member_loses_the_desk_role_the_old_one_earned(self): + owner = self.create_user("role-demote-owner@example.com") + member = self.create_user("role-demote-member@example.com") + team = self.create_team(owner, (member, "Manager")) + self.assertIn("Event Manager", frappe.get_roles(member)) + + with self.set_user(owner): + change_roles(team, [member], "Viewer") + + self.assertNotIn("Event Manager", frappe.get_roles(member)) + + def test_an_admin_may_demote_another_admin(self): + admin = self.create_user("role-peer-admin@example.com") + peer = self.create_user("role-peer@example.com") + team = self.create_team( + self.create_user("role-peer-owner@example.com"), (admin, "Admin"), (peer, "Admin") + ) + + with self.set_user(admin): + change_roles(team, [peer], "Viewer") + + self.assertEqual(self.role(team, peer), "Viewer") + + def test_a_manager_cannot_change_anyone(self): + manager = self.create_user("role-manager@example.com") + member = self.create_user("role-managers-target@example.com") + team = self.create_team( + self.create_user("role-manager-owner@example.com"), (manager, "Manager"), (member, "Viewer") + ) + + with self.set_user(manager), self.assertRaises(CannotManageMembers): + change_roles(team, [member], "Manager") + + def test_an_outsider_cannot_change_anyone(self): + member = self.create_user("role-outsiders-target@example.com") + team = self.create_team(self.create_user("role-outsider-owner@example.com"), (member, "Viewer")) + + with ( + self.set_user(self.create_user("role-outsider@example.com")), + self.assertRaises(CannotManageMembers), + ): + change_roles(team, [member], "Manager") + + def test_the_owner_cannot_be_given_another_role(self): + owner = self.create_user("role-locked-owner@example.com") + admin = self.create_user("role-owners-admin@example.com") + team = self.create_team(owner, (admin, "Admin")) + + with self.set_user(admin), self.assertRaises(frappe.ValidationError): + change_roles(team, [owner], "Viewer") + + def test_ownership_cannot_be_granted(self): + owner = self.create_user("role-grant-owner@example.com") + member = self.create_user("role-grant-member@example.com") + team = self.create_team(owner, (member, "Manager")) + + with self.set_user(owner), self.assertRaises(CannotGrantOwnership): + change_roles(team, [member], "Owner") + + def test_an_unknown_role_is_refused(self): + owner = self.create_user("role-unknown-owner@example.com") + member = self.create_user("role-unknown-member@example.com") + team = self.create_team(owner, (member, "Manager")) + + with self.set_user(owner), self.assertRaises(UnknownTeamRole): + change_roles(team, [member], "Overlord") + + def test_refuses_a_user_who_is_not_on_the_team(self): + owner = self.create_user("role-stranger-owner@example.com") + stranger = self.create_user("role-stranger@example.com") + team = self.create_team(owner) + + with self.set_user(owner), self.assertRaises(NotATeamMember): + change_roles(team, [stranger], "Manager") + + def test_refuses_a_member_whose_membership_is_disabled(self): + owner = self.create_user("role-disabled-owner@example.com") + member = self.create_user("role-disabled-member@example.com") + team = self.create_team(owner, (member, "Manager")) + + with self.set_user(owner): + remove_members(team, [member]) + + with self.assertRaises(NotATeamMember): + change_roles(team, [member], "Viewer") + + def test_an_admin_re_roles_several_members_at_once(self): + owner = self.create_user("role-batch-owner@example.com") + first = self.create_user("role-batch-first@example.com") + second = self.create_user("role-batch-second@example.com") + team = self.create_team(owner, (first, "Manager"), (second, "Frontdesk")) + + with self.set_user(owner): + change_roles(team, [first, second], "Viewer") + + self.assertEqual(self.role(team, first), "Viewer") + self.assertEqual(self.role(team, second), "Viewer") + + def role(self, team: str, user: str) -> str: + return frappe.db.get_value("Buzz Team Membership", {"team": team, "user": user}, "team_role") diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index 0047cc1f..6db30003 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -1,36 +1,30 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.teams import change_roles, get_my_teams, get_team_overview, remove_members, update_team -from buzz.api.teams.exceptions import ( - CannotEditTeam, - CannotGrantOwnership, - CannotManageMembers, - NotATeamMember, - UnknownTeamRole, -) -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership +from buzz.api.teams import get_my_teams, get_team_overview, update_team +from buzz.api.teams.exceptions import CannotEditTeam, NotATeamMember from buzz.events.doctype.buzz_team_settings.buzz_team_settings import feature_flags -from buzz.tests.factories import BuzzTeamFactory, UserFactory +from buzz.tests.factories import BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory -class TestGetMyTeams(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its user and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") +class TeamTestCase(IntegrationTestCase): + def create_user(self, email: str) -> str: + return UserFactory.create_once(email).name + + def create_team(self, owner: str, **overrides) -> str: + return BuzzTeamFactory.create_owned_by(owner, **overrides).name + + def add_member(self, team: str, user: str, team_role: str, **overrides): + BuzzTeamMembershipFactory.create(team=team, user=user, team_role=team_role, **overrides) - def team_names_for(self, user: str) -> list[str]: - frappe.set_user(user) - return [team.name for team in get_my_teams()] +class TestGetMyTeams(TeamTestCase): def test_returns_owned_team_with_role_and_title(self): - user = create_user("switcher-owner@example.com", "Owner") - team = create_owned_team("Switcher Owned", user) + user = self.create_user("switcher-owner@example.com") + team = self.create_team(user, team_name="Switcher Owned") - frappe.set_user(user) - options = get_my_teams() + with self.set_user(user): + options = get_my_teams() self.assertEqual(len(options), 1) self.assertEqual(options[0].name, team) @@ -38,68 +32,60 @@ def test_returns_owned_team_with_role_and_title(self): self.assertEqual(options[0].team_role, "Owner") def test_returns_every_team_the_user_belongs_to(self): - user = create_user("switcher-multi@example.com", "Multi") - owned = create_owned_team("Switcher Multi Owned", user) - joined = create_owned_team("Switcher Multi Joined", create_user("switcher-host@example.com", "Host")) - upsert_membership(joined, user, "Viewer") + user = self.create_user("switcher-multi@example.com") + owned = self.create_team(user) + joined = self.create_team(self.create_user("switcher-host@example.com")) + self.add_member(joined, user, "Viewer") self.assertEqual(sorted(self.team_names_for(user)), sorted([owned, joined])) def test_a_viewer_sees_the_team_despite_no_read_permission(self): - user = create_user("switcher-viewer@example.com", "Viewer") - team = create_owned_team("Switcher Viewer", create_user("switcher-admin@example.com", "Admin")) - upsert_membership(team, user, "Viewer") + user = self.create_user("switcher-viewer@example.com") + team = self.create_team(self.create_user("switcher-admin@example.com")) + self.add_member(team, user, "Viewer") - frappe.set_user(user) - self.assertFalse(frappe.has_permission("Buzz Team", doc=team)) - self.assertEqual([option.name for option in get_my_teams()], [team]) + with self.set_user(user): + self.assertFalse(frappe.has_permission("Buzz Team", doc=team)) + self.assertEqual([option.name for option in get_my_teams()], [team]) def test_skips_disabled_memberships(self): - user = create_user("switcher-disabled@example.com", "Lapsed") - team = create_owned_team("Switcher Disabled", create_user("switcher-owner2@example.com", "Owner")) - upsert_membership(team, user, "Manager") - frappe.db.set_value("Buzz Team Membership", {"team": team, "user": user}, "enabled", 0) + user = self.create_user("switcher-disabled@example.com") + team = self.create_team(self.create_user("switcher-owner2@example.com")) + self.add_member(team, user, "Manager", enabled=0) self.assertEqual(self.team_names_for(user), []) def test_lists_each_teams_enabled_members(self): - owner = create_user("switcher-members-owner@example.com", "Owner") - viewer = create_user("switcher-members-viewer@example.com", "Viewer") - team = create_owned_team("Switcher Members", owner) - upsert_membership(team, viewer, "Viewer") + owner = self.create_user("switcher-members-owner@example.com") + viewer = self.create_user("switcher-members-viewer@example.com") + team = self.create_team(owner) + self.add_member(team, viewer, "Viewer") - frappe.set_user(viewer) - members = get_my_teams()[0].members + with self.set_user(viewer): + members = get_my_teams()[0].members self.assertEqual([member.user for member in members], [owner, viewer]) def test_a_viewer_gets_the_teams_feature_flags(self): - user = UserFactory.create_once("switcher-flags-viewer@example.com").name + user = self.create_user("switcher-flags-viewer@example.com") team = BuzzTeamFactory.create_owned_by().name - upsert_membership(team, user, "Viewer") + self.add_member(team, user, "Viewer") - frappe.set_user(user) - self.assertEqual(get_my_teams()[0].feature_flags, feature_flags(team)) + with self.set_user(user): + self.assertEqual(get_my_teams()[0].feature_flags, feature_flags(team)) def test_returns_nothing_for_a_user_on_no_team(self): - user = create_user("switcher-teamless@example.com", "Teamless") - - self.assertEqual(self.team_names_for(user), []) + self.assertEqual(self.team_names_for(self.create_user("switcher-teamless@example.com")), []) + def team_names_for(self, user: str) -> list[str]: + with self.set_user(user): + return [team.name for team in get_my_teams()] -class TestGetTeamOverview(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its user and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def overview_for(self, user: str, team: str): - frappe.set_user(user) - return get_team_overview(team) +class TestGetTeamOverview(TeamTestCase): def test_returns_the_team_and_the_callers_role(self): - user = create_user("overview-owner@example.com", "Owner") - team = create_owned_team("Overview Owned", user) + user = self.create_user("overview-owner@example.com") + team = self.create_team(user, team_name="Overview Owned") overview = self.overview_for(user, team) @@ -109,13 +95,11 @@ def test_returns_the_team_and_the_callers_role(self): self.assertEqual(overview.my_role, "Owner") def test_lists_enabled_members_only(self): - owner = create_user("overview-host@example.com", "Host") - viewer = create_user("overview-viewer@example.com", "Viewer") - lapsed = create_user("overview-lapsed@example.com", "Lapsed") - team = create_owned_team("Overview Members", owner) - upsert_membership(team, viewer, "Viewer") - upsert_membership(team, lapsed, "Manager") - frappe.db.set_value("Buzz Team Membership", {"team": team, "user": lapsed}, "enabled", 0) + owner = self.create_user("overview-host@example.com") + viewer = self.create_user("overview-viewer@example.com") + team = self.create_team(owner) + self.add_member(team, viewer, "Viewer") + self.add_member(team, self.create_user("overview-lapsed@example.com"), "Manager", enabled=0) members = self.overview_for(viewer, team).members @@ -124,306 +108,62 @@ def test_lists_enabled_members_only(self): def test_lists_the_owner_first(self): # The owner's name sorts last alphabetically, so only the role ordering can put them first. - owner = create_user("overview-order-owner@example.com", "Zara") - manager = create_user("overview-order-member@example.com", "Aditi") - team = create_owned_team("Overview Order", owner) - upsert_membership(team, manager, "Manager") + owner = UserFactory.create(first_name="Zara").name + manager = UserFactory.create(first_name="Aditi").name + team = self.create_team(owner) + self.add_member(team, manager, "Manager") members = self.overview_for(owner, team).members self.assertEqual([member.user for member in members], [owner, manager]) def test_a_viewer_reads_the_team_despite_no_read_permission(self): - user = create_user("overview-no-perm@example.com", "Viewer") - team = create_owned_team("Overview No Perm", create_user("overview-admin@example.com", "Admin")) - upsert_membership(team, user, "Viewer") + user = self.create_user("overview-no-perm@example.com") + team = self.create_team(self.create_user("overview-admin@example.com")) + self.add_member(team, user, "Viewer") - frappe.set_user(user) - self.assertFalse(frappe.has_permission("Buzz Team", doc=team)) - self.assertEqual(get_team_overview(team).name, team) + with self.set_user(user): + self.assertFalse(frappe.has_permission("Buzz Team", doc=team)) + self.assertEqual(get_team_overview(team).name, team) def test_refuses_a_team_the_user_is_not_on(self): - user = create_user("overview-outsider@example.com", "Outsider") - team = create_owned_team("Overview Outsider", create_user("overview-insider@example.com", "Insider")) + team = self.create_team(self.create_user("overview-insider@example.com")) - frappe.set_user(user) - with self.assertRaises(NotATeamMember): + with ( + self.set_user(self.create_user("overview-outsider@example.com")), + self.assertRaises(NotATeamMember), + ): get_team_overview(team) + def overview_for(self, user: str, team: str): + with self.set_user(user): + return get_team_overview(team) -class TestRemoveMember(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its users and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def membership(self, team: str, user: str) -> dict: - return frappe.db.get_value( - "Buzz Team Membership", {"team": team, "user": user}, ["name", "enabled"], as_dict=True - ) - - def test_an_admin_disables_the_membership_instead_of_deleting_it(self): - admin = create_user("remove-admin@example.com", "Admin") - member = create_user("remove-member@example.com", "Member") - team = create_owned_team("Remove Admin", create_user("remove-owner@example.com", "Owner")) - upsert_membership(team, admin, "Admin") - upsert_membership(team, member, "Manager") - - frappe.set_user(admin) - remove_members(team, [member]) - - self.assertEqual(self.membership(team, member).enabled, 0) - - def test_a_manager_cannot_remove_anyone(self): - manager = create_user("remove-manager@example.com", "Manager") - member = create_user("remove-managers-target@example.com", "Target") - team = create_owned_team("Remove Manager", create_user("remove-owner2@example.com", "Owner")) - upsert_membership(team, manager, "Manager") - upsert_membership(team, member, "Viewer") - - frappe.set_user(manager) - with self.assertRaises(CannotManageMembers): - remove_members(team, [member]) - - def test_an_outsider_cannot_remove_anyone(self): - outsider = create_user("remove-outsider@example.com", "Outsider") - owner = create_user("remove-owner3@example.com", "Owner") - team = create_owned_team("Remove Outsider", owner) - - frappe.set_user(outsider) - with self.assertRaises(CannotManageMembers): - remove_members(team, [owner]) - - def test_the_owner_cannot_be_removed(self): - owner = create_user("remove-locked-owner@example.com", "Owner") - admin = create_user("remove-owners-admin@example.com", "Admin") - team = create_owned_team("Remove Owner", owner) - upsert_membership(team, admin, "Admin") - - frappe.set_user(admin) - with self.assertRaises(frappe.ValidationError): - remove_members(team, [owner]) - - def test_refuses_a_user_who_is_not_on_the_team(self): - owner = create_user("remove-stranger-owner@example.com", "Owner") - stranger = create_user("remove-stranger@example.com", "Stranger") - team = create_owned_team("Remove Stranger", owner) - - frappe.set_user(owner) - with self.assertRaises(NotATeamMember): - remove_members(team, [stranger]) - - def test_removing_the_same_member_twice_is_refused(self): - owner = create_user("remove-twice-owner@example.com", "Owner") - member = create_user("remove-twice-member@example.com", "Member") - team = create_owned_team("Remove Twice", owner) - upsert_membership(team, member, "Manager") - - frappe.set_user(owner) - remove_members(team, [member]) - - with self.assertRaises(NotATeamMember): - remove_members(team, [member]) - - def test_the_desk_role_survives_while_another_team_still_earns_it(self): - owner = create_user("remove-roles-owner@example.com", "Owner") - member = create_user("remove-roles-member@example.com", "Member") - first = create_owned_team("Remove Roles First", owner) - second = create_owned_team("Remove Roles Second", owner) - upsert_membership(first, member, "Manager") - upsert_membership(second, member, "Manager") - - frappe.set_user(owner) - remove_members(first, [member]) - self.assertIn("Event Manager", frappe.get_roles(member)) - - remove_members(second, [member]) - self.assertNotIn("Event Manager", frappe.get_roles(member)) - - def test_an_admin_removes_several_members_at_once(self): - owner = create_user("remove-batch-owner@example.com", "Owner") - first = create_user("remove-batch-first@example.com", "First") - second = create_user("remove-batch-second@example.com", "Second") - team = create_owned_team("Remove Batch", owner) - upsert_membership(team, first, "Manager") - upsert_membership(team, second, "Viewer") - - frappe.set_user(owner) - remove_members(team, [first, second]) - - self.assertEqual(self.membership(team, first).enabled, 0) - self.assertEqual(self.membership(team, second).enabled, 0) - - -class TestChangeRole(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its users and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def role(self, team: str, user: str) -> str: - return frappe.db.get_value("Buzz Team Membership", {"team": team, "user": user}, "team_role") - - def test_an_admin_promotes_a_manager(self): - owner = create_user("role-owner@example.com", "Owner") - admin = create_user("role-admin@example.com", "Admin") - member = create_user("role-member@example.com", "Member") - team = create_owned_team("Role Promote", owner) - upsert_membership(team, admin, "Admin") - upsert_membership(team, member, "Manager") - - frappe.set_user(admin) - change_roles(team, [member], "Admin") - - self.assertEqual(self.role(team, member), "Admin") - - def test_a_demoted_member_loses_the_desk_role_the_old_one_earned(self): - owner = create_user("role-demote-owner@example.com", "Owner") - member = create_user("role-demote-member@example.com", "Member") - team = create_owned_team("Role Demote", owner) - upsert_membership(team, member, "Manager") - self.assertIn("Event Manager", frappe.get_roles(member)) - - frappe.set_user(owner) - change_roles(team, [member], "Viewer") - - self.assertNotIn("Event Manager", frappe.get_roles(member)) - - def test_an_admin_may_demote_another_admin(self): - owner = create_user("role-peer-owner@example.com", "Owner") - admin = create_user("role-peer-admin@example.com", "Admin") - peer = create_user("role-peer@example.com", "Peer") - team = create_owned_team("Role Peer", owner) - upsert_membership(team, admin, "Admin") - upsert_membership(team, peer, "Admin") - - frappe.set_user(admin) - change_roles(team, [peer], "Viewer") - - self.assertEqual(self.role(team, peer), "Viewer") - - def test_a_manager_cannot_change_anyone(self): - owner = create_user("role-manager-owner@example.com", "Owner") - manager = create_user("role-manager@example.com", "Manager") - member = create_user("role-managers-target@example.com", "Target") - team = create_owned_team("Role Manager", owner) - upsert_membership(team, manager, "Manager") - upsert_membership(team, member, "Viewer") - - frappe.set_user(manager) - with self.assertRaises(CannotManageMembers): - change_roles(team, [member], "Manager") - - def test_an_outsider_cannot_change_anyone(self): - owner = create_user("role-outsider-owner@example.com", "Owner") - outsider = create_user("role-outsider@example.com", "Outsider") - member = create_user("role-outsiders-target@example.com", "Target") - team = create_owned_team("Role Outsider", owner) - upsert_membership(team, member, "Viewer") - - frappe.set_user(outsider) - with self.assertRaises(CannotManageMembers): - change_roles(team, [member], "Manager") - - def test_the_owner_cannot_be_given_another_role(self): - owner = create_user("role-locked-owner@example.com", "Owner") - admin = create_user("role-owners-admin@example.com", "Admin") - team = create_owned_team("Role Owner", owner) - upsert_membership(team, admin, "Admin") - - frappe.set_user(admin) - with self.assertRaises(frappe.ValidationError): - change_roles(team, [owner], "Viewer") - - def test_ownership_cannot_be_granted(self): - owner = create_user("role-grant-owner@example.com", "Owner") - member = create_user("role-grant-member@example.com", "Member") - team = create_owned_team("Role Grant", owner) - upsert_membership(team, member, "Manager") - - frappe.set_user(owner) - with self.assertRaises(CannotGrantOwnership): - change_roles(team, [member], "Owner") - - def test_an_unknown_role_is_refused(self): - owner = create_user("role-unknown-owner@example.com", "Owner") - member = create_user("role-unknown-member@example.com", "Member") - team = create_owned_team("Role Unknown", owner) - upsert_membership(team, member, "Manager") - - frappe.set_user(owner) - with self.assertRaises(UnknownTeamRole): - change_roles(team, [member], "Overlord") - - def test_refuses_a_user_who_is_not_on_the_team(self): - owner = create_user("role-stranger-owner@example.com", "Owner") - stranger = create_user("role-stranger@example.com", "Stranger") - team = create_owned_team("Role Stranger", owner) - - frappe.set_user(owner) - with self.assertRaises(NotATeamMember): - change_roles(team, [stranger], "Manager") - - def test_refuses_a_member_whose_membership_is_disabled(self): - owner = create_user("role-disabled-owner@example.com", "Owner") - member = create_user("role-disabled-member@example.com", "Member") - team = create_owned_team("Role Disabled", owner) - upsert_membership(team, member, "Manager") - - frappe.set_user(owner) - remove_members(team, [member]) - - with self.assertRaises(NotATeamMember): - change_roles(team, [member], "Viewer") - - def test_an_admin_re_roles_several_members_at_once(self): - owner = create_user("role-batch-owner@example.com", "Owner") - first = create_user("role-batch-first@example.com", "First") - second = create_user("role-batch-second@example.com", "Second") - team = create_owned_team("Role Batch", owner) - upsert_membership(team, first, "Manager") - upsert_membership(team, second, "Frontdesk") - - frappe.set_user(owner) - change_roles(team, [first, second], "Viewer") - - self.assertEqual(self.role(team, first), "Viewer") - self.assertEqual(self.role(team, second), "Viewer") - - -class TestUpdateTeam(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its users and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") +class TestUpdateTeam(TeamTestCase): def test_an_admin_renames_the_team_and_sets_its_logo(self): - owner = create_user("update-owner@example.com", "Owner") - admin = create_user("update-admin@example.com", "Admin") - team = create_owned_team("Update Before", owner) - upsert_membership(team, admin, "Admin") + admin = self.create_user("update-admin@example.com") + team = self.create_team(self.create_user("update-owner@example.com"), team_name="Update Before") + self.add_member(team, admin, "Admin") - frappe.set_user(admin) - update_team(team, " Update After ", "/files/logo.png") + with self.set_user(admin): + update_team(team, " Update After ", "/files/logo.png") details = frappe.db.get_value("Buzz Team", team, ["team_name", "logo"], as_dict=True) self.assertEqual(details.team_name, "Update After") self.assertEqual(details.logo, "/files/logo.png") def test_a_manager_cannot_edit_the_team(self): - owner = create_user("update-owner2@example.com", "Owner") - manager = create_user("update-manager@example.com", "Manager") - team = create_owned_team("Update Locked", owner) - upsert_membership(team, manager, "Manager") + manager = self.create_user("update-manager@example.com") + team = self.create_team(self.create_user("update-owner2@example.com")) + self.add_member(team, manager, "Manager") - frappe.set_user(manager) - with self.assertRaises(CannotEditTeam): + with self.set_user(manager), self.assertRaises(CannotEditTeam): update_team(team, "Renamed", None) def test_a_blank_name_is_refused(self): - owner = create_user("update-owner3@example.com", "Owner") - team = create_owned_team("Update Blank", owner) + owner = self.create_user("update-owner3@example.com") + team = self.create_team(owner) - frappe.set_user(owner) - with self.assertRaises(frappe.ValidationError): + with self.set_user(owner), self.assertRaises(frappe.ValidationError): update_team(team, " ", None) diff --git a/buzz/api/test_exceptions.py b/buzz/api/test_exceptions.py index 697e979e..6771a95c 100644 --- a/buzz/api/test_exceptions.py +++ b/buzz/api/test_exceptions.py @@ -1,23 +1,20 @@ import frappe from frappe import _lt -from frappe.tests import IntegrationTestCase +from frappe.tests import UnitTestCase -from buzz.api.exceptions import BuzzAPIError, Conflict, NotPermitted, ResourceNotFound +from buzz.api.exceptions import BuzzAPIError, Conflict -class Parameterized(BuzzAPIError): +class ParameterizedError(BuzzAPIError): http_status_code = 418 title = _lt("Teapot") message = _lt("{item} is not available.") -class TestBuzzAPIError(IntegrationTestCase): +class TestBuzzAPIError(UnitTestCase): def setUp(self): frappe.clear_messages() - def last_message(self) -> dict: - return frappe.local.message_log[-1] - def test_throw_raises_its_own_class(self): with self.assertRaises(Conflict): Conflict.throw() @@ -30,8 +27,8 @@ def test_throw_publishes_title_and_message(self): self.assertIn("conflicts", self.last_message()["message"]) def test_message_accepts_context(self): - with self.assertRaises(Parameterized): - Parameterized.throw(item="Chai") + with self.assertRaises(ParameterizedError): + ParameterizedError.throw(item="Chai") self.assertEqual(self.last_message()["message"], "Chai is not available.") @@ -42,15 +39,9 @@ def test_bare_raise_publishes_nothing(self): self.assertEqual(frappe.local.message_log, []) - def test_status_codes(self): - self.assertEqual(BuzzAPIError.http_status_code, 400) - self.assertEqual(ResourceNotFound.http_status_code, 404) - self.assertEqual(NotPermitted.http_status_code, 403) - self.assertEqual(Conflict.http_status_code, 409) - - def test_subclass_inherits_status_code(self): - self.assertEqual(Parameterized.http_status_code, 418) - def test_errors_are_validation_errors(self): # Keeps existing `except frappe.ValidationError` handlers working. self.assertTrue(issubclass(BuzzAPIError, frappe.ValidationError)) + + def last_message(self) -> dict: + return frappe.local.message_log[-1] diff --git a/buzz/api/tickets/test_ticket_windows.py b/buzz/api/tickets/test_ticket_windows.py new file mode 100644 index 00000000..41634cd0 --- /dev/null +++ b/buzz/api/tickets/test_ticket_windows.py @@ -0,0 +1,71 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz.api.tickets.windows import ADD_ON_CHANGE, CANCELLATION, TRANSFER, is_window_open +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory + +CUTOFF_FIELDS = (TRANSFER, ADD_ON_CHANGE, CANCELLATION) + + +class TestWindows(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + event = BuzzEventFactory.create() + cls.event, cls.team = event.name, event.team + + def setUp(self): + # The rollback restores these rows but not their cached copies. + self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.team) + self.addCleanup(frappe.clear_document_cache, "Buzz Event", self.event) + BuzzTeamFactory.set_settings(self.team, dict.fromkeys(CUTOFF_FIELDS, 7)) + + def test_open_while_the_event_is_beyond_the_cutoff(self): + self.set_event_start(30) + self.assertTrue(is_window_open(self.event, TRANSFER)) + + def test_closed_once_inside_the_cutoff(self): + self.set_event_start(3) + self.assertFalse(is_window_open(self.event, TRANSFER)) + + def test_exactly_on_the_cutoff_is_still_open(self): + self.set_event_start(7) + self.assertTrue(is_window_open(self.event, TRANSFER)) + + def test_a_zero_cutoff_keeps_the_window_open_until_the_day(self): + # Regression: a 0 cutoff must stay 0, not fall back to the 7-day default. + BuzzTeamFactory.set_settings(self.team, dict.fromkeys(CUTOFF_FIELDS, 0)) + self.set_event_start(1) + self.assertTrue(is_window_open(self.event, TRANSFER)) + + def test_an_event_without_a_start_date_is_closed(self): + self.set_event_start(None) + self.assertFalse(is_window_open(self.event, TRANSFER)) + + def set_event_start(self, days_from_today: int | None): + start_date = None if days_from_today is None else add_days(today(), days_from_today) + frappe.db.set_value("Buzz Event", self.event, "start_date", start_date) + frappe.clear_document_cache("Buzz Event", self.event) + + +class TestPerTeamWindows(IntegrationTestCase): + def test_each_team_enforces_its_own_window(self): + strict = self.create_team_event(cutoff_days=7, days_from_today=5) + lenient = self.create_team_event(cutoff_days=2, days_from_today=5) + + for cutoff_fieldname in CUTOFF_FIELDS: + with self.subTest(cutoff_fieldname=cutoff_fieldname): + self.assertFalse(is_window_open(strict, cutoff_fieldname)) + self.assertTrue(is_window_open(lenient, cutoff_fieldname)) + + def test_an_explicit_zero_cutoff_is_not_treated_as_unset(self): + event = self.create_team_event(cutoff_days=0, days_from_today=1) + + self.assertTrue(is_window_open(event, TRANSFER)) + + def create_team_event(self, cutoff_days: int, days_from_today: int): + team = BuzzTeamFactory.create_owned_by().name + BuzzTeamFactory.set_settings(team, dict.fromkeys(CUTOFF_FIELDS, cutoff_days)) + start_date = add_days(today(), days_from_today) + return BuzzEventFactory.create(team=team, start_date=start_date, end_date=start_date).name diff --git a/buzz/api/tickets/test_tickets.py b/buzz/api/tickets/test_tickets.py index d8e882f4..6b3546c0 100644 --- a/buzz/api/tickets/test_tickets.py +++ b/buzz/api/tickets/test_tickets.py @@ -4,7 +4,6 @@ from frappe.tests import IntegrationTestCase from frappe.utils import add_days, today -from buzz.api.forms.test_forms import ensure_event_host, ensure_prompt_named_record from buzz.api.tickets import ( change_add_on_preference, create_cancellation_request, @@ -23,249 +22,96 @@ TransferNotPermitted, TransferWindowClosed, ) -from buzz.api.tickets.windows import ADD_ON_CHANGE, CANCELLATION, TRANSFER, is_window_open -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user, payload_for -from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import set_team_settings +from buzz.api.tickets.windows import ADD_ON_CHANGE, CANCELLATION, TRANSFER +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + EventBookingFactory, + EventTicketFactory, + TicketAddOnFactory, + UserFactory, +) ATTENDEE = "ticket-attendee@example.com" OTHER_USER = "ticket-outsider@example.com" -def set_team_cutoffs(team: str, days: int): - set_team_settings(team, **dict.fromkeys((TRANSFER, ADD_ON_CHANGE, CANCELLATION), days)) - - -DETAILS_FIELDS = { - "doc", - "add_ons", - "event", - "venue", - "booking", - "ticket_type", - "can_transfer_ticket", - "can_change_add_ons", - "can_request_cancellation", - "zoom_join_url", - "zoom_reference_doctype", - "zoom_reference_name", -} - - class TicketTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - # These tests move the event's start date around, so they own an event rather than - # sharing test-route: IntegrationTestCase rolls the DB back but not the document - # cache, which would leave a rolled-back date visible to later test modules. - category = ensure_prompt_named_record("Event Category", "Test Tickets Category") - host = ensure_event_host("Test Tickets Host") - cls.event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": f"Tickets Test Event {frappe.generate_hash(length=6)}", - "start_date": "2030-01-01", - "end_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "category": category, - "host": host, - "is_published": 1, - } - ).insert(ignore_permissions=True) - - for email, first_name in ((ATTENDEE, "Ticket"), (OTHER_USER, "Outsider")): - if not frappe.db.exists("User", email): - frappe.get_doc( - {"doctype": "User", "email": email, "first_name": first_name, "send_welcome_email": 0} - ).insert(ignore_permissions=True) + cls.event = BuzzEventFactory.create() + UserFactory.create_once(ATTENDEE) + UserFactory.create_once(OTHER_USER) def setUp(self): - frappe.set_user("Administrator") frappe.clear_messages() - # The rollback restores the settings row but not its cached copy. + # The rollback restores these rows but not their cached copies. self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.event.team) - # The window checks read the event's team settings, so pin the cutoffs tests reason about. - self.set_cutoffs(7) - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.event.name, - "title": f"Tickets Test {frappe.generate_hash(length=6)}", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert(ignore_permissions=True) - - def set_cutoffs(self, days): - set_team_cutoffs(self.event.team, days) - - def set_event_start(self, days_from_today): - start_date = add_days(today(), days_from_today) - frappe.db.set_value("Buzz Event", self.event.name, {"start_date": start_date, "end_date": start_date}) - frappe.clear_document_cache("Buzz Event", self.event.name) - - def make_ticket(self, attendee_email=ATTENDEE, booking=None): - return frappe.get_doc( - { - "doctype": "Event Ticket", - "event": self.event.name, - "ticket_type": self.ticket_type.name, - "attendee_name": "Ticket Attendee", - "attendee_email": attendee_email, - "booking": booking, - } - ).insert(ignore_permissions=True) - - def make_booking(self, user=ATTENDEE): - # Event Booking.validate prices the attendee rows, so it needs at least one. - return frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.event.name, - "user": user, - "attendees": [ - {"ticket_type": self.ticket_type.name, "first_name": "Ticket", "email": ATTENDEE} - ], - } - ).insert(ignore_permissions=True) - - -class TestWindows(TicketTestCase): - def test_open_while_the_event_is_beyond_the_cutoff(self): - self.set_event_start(30) - self.assertTrue(is_window_open(self.event.name, TRANSFER)) - - def test_closed_once_inside_the_cutoff(self): - self.set_event_start(3) - self.assertFalse(is_window_open(self.event.name, TRANSFER)) - - def test_exactly_on_the_cutoff_is_still_open(self): - self.set_event_start(7) - self.assertTrue(is_window_open(self.event.name, TRANSFER)) - - def test_a_zero_cutoff_keeps_the_window_open_until_the_day(self): - # Regression: a 0 cutoff must stay 0, not fall back to the 7-day default. - self.set_cutoffs(0) - self.set_event_start(1) - self.assertTrue(is_window_open(self.event.name, TRANSFER)) - - def test_an_event_without_a_start_date_is_closed(self): + self.addCleanup(frappe.clear_document_cache, "Buzz Event", self.event.name) + # The window checks read the team's cutoffs. + BuzzTeamFactory.set_settings( + self.event.team, dict.fromkeys((TRANSFER, ADD_ON_CHANGE, CANCELLATION), 7) + ) self.set_event_start(30) - frappe.db.set_value("Buzz Event", self.event.name, "start_date", None) - frappe.clear_document_cache("Buzz Event", self.event.name) - self.assertFalse(is_window_open(self.event.name, TRANSFER)) - - -class TestPerTeamWindows(TicketTestCase): - def team_event(self, team_name: str, cutoff_days: int, days_from_today: int) -> str: - owner = create_user(f"windows-{team_name.lower().replace(' ', '-')}@example.com", "Windows") - team = create_owned_team(team_name, owner) - set_team_cutoffs(team, cutoff_days) + def set_event_start(self, days_from_today: int): start_date = add_days(today(), days_from_today) - return ( - frappe.get_doc( - { - **payload_for("Buzz Event", team_name), - "team": team, - "start_date": start_date, - "end_date": start_date, - } - ) - .insert(ignore_permissions=True) - .name - ) - - def test_each_team_enforces_its_own_window(self): - strict = self.team_event("Windows Strict", 7, 5) - lenient = self.team_event("Windows Lenient", 2, 5) - - for cutoff_fieldname in (TRANSFER, ADD_ON_CHANGE, CANCELLATION): - with self.subTest(cutoff_fieldname=cutoff_fieldname): - self.assertFalse(is_window_open(strict, cutoff_fieldname)) - self.assertTrue(is_window_open(lenient, cutoff_fieldname)) + frappe.db.set_value("Buzz Event", self.event.name, {"start_date": start_date, "end_date": start_date}) + frappe.clear_document_cache("Buzz Event", self.event.name) - def test_an_explicit_zero_cutoff_is_not_treated_as_unset(self): - event = self.team_event("Windows Zero", 0, 1) + def make_ticket(self, attendee_email: str = ATTENDEE, **overrides): + return EventTicketFactory.create(event=self.event.name, attendee_email=attendee_email, **overrides) - self.assertTrue(is_window_open(event, TRANSFER)) + def make_booking(self, user: str = ATTENDEE): + return EventBookingFactory.create(event=self.event.name, user=user) class TestGetTicketDetails(TicketTestCase): - def test_shape(self): - self.set_event_start(30) - ticket = self.make_ticket() - frappe.set_user(ATTENDEE) - - details = get_ticket_details(ticket.name) - self.assertEqual(set(details.__json__()), DETAILS_FIELDS) - self.assertEqual(details.doc.name, ticket.name) - self.assertEqual(details.add_ons, []) - self.assertIsNone(details.booking) - self.assertIsNone(details.zoom_join_url) - - def test_window_flags_are_plain_booleans(self): - self.set_event_start(30) - ticket = self.make_ticket() - frappe.set_user(ATTENDEE) - - details = get_ticket_details(ticket.name) - self.assertIs(details.can_transfer_ticket, True) - self.assertIs(details.can_change_add_ons, True) - self.assertIs(details.can_request_cancellation, True) - def test_window_flags_go_false_near_the_event(self): self.set_event_start(2) ticket = self.make_ticket() - frappe.set_user(ATTENDEE) - details = get_ticket_details(ticket.name) + with self.set_user(ATTENDEE): + details = get_ticket_details(ticket.name) + self.assertIs(details.can_transfer_ticket, False) self.assertIs(details.can_change_add_ons, False) self.assertIs(details.can_request_cancellation, False) def test_another_user_cannot_read_the_ticket(self): - self.set_event_start(30) ticket = self.make_ticket() - frappe.set_user(OTHER_USER) - with self.assertRaises(TicketNotAccessible): + with self.set_user(OTHER_USER), self.assertRaises(TicketNotAccessible): get_ticket_details(ticket.name) self.assertEqual(frappe.local.message_log[-1]["title"], "Not Permitted") def test_booker_can_read_a_ticket_held_by_someone_else(self): - self.set_event_start(30) booking = self.make_booking(user=OTHER_USER) ticket = self.make_ticket(attendee_email="guest@example.com", booking=booking.name) - frappe.set_user(OTHER_USER) - details = get_ticket_details(ticket.name) + with self.set_user(OTHER_USER): + details = get_ticket_details(ticket.name) self.assertEqual(details.doc.name, ticket.name) self.assertEqual(details.booking.name, booking.name) def test_booking_is_withheld_from_an_attendee_who_did_not_book(self): - self.set_event_start(30) booking = self.make_booking(user=OTHER_USER) ticket = self.make_ticket(booking=booking.name) - frappe.set_user(ATTENDEE) - self.assertIsNone(get_ticket_details(ticket.name).booking) + with self.set_user(ATTENDEE): + self.assertIsNone(get_ticket_details(ticket.name).booking) class TestTransferTicket(TicketTestCase): - def setUp(self): - super().setUp() - self.set_event_start(30) - @patch("buzz.api.tickets.services.send_ticket_transfer_emails") def test_attendee_transfers_their_own_ticket(self, mock_emails): ticket = self.make_ticket() - frappe.set_user(ATTENDEE) - transfer_ticket(ticket.name, "New", "Owner", "new-owner@example.com") + with self.set_user(ATTENDEE): + transfer_ticket(ticket.name, "New", "Owner", "new-owner@example.com") ticket.reload() self.assertEqual(ticket.attendee_email, "new-owner@example.com") @@ -276,9 +122,10 @@ def test_attendee_transfers_their_own_ticket(self, mock_emails): def test_booking_owner_may_transfer(self, mock_emails): booking = self.make_booking(user=OTHER_USER) ticket = self.make_ticket(booking=booking.name) - frappe.set_user(OTHER_USER) - transfer_ticket(ticket.name, "New", "Owner", "new-owner@example.com") + with self.set_user(OTHER_USER): + transfer_ticket(ticket.name, "New", "Owner", "new-owner@example.com") + self.assertEqual( frappe.db.get_value("Event Ticket", ticket.name, "attendee_email"), "new-owner@example.com" ) @@ -287,159 +134,132 @@ def test_unknown_ticket(self): with self.assertRaises(TicketNotFound): transfer_ticket("not-a-ticket", "New", "Owner", "new-owner@example.com") - self.assertEqual(TicketNotFound.http_status_code, 404) - def test_an_unrelated_user_cannot_transfer(self): ticket = self.make_ticket() - frappe.set_user(OTHER_USER) - with self.assertRaises(TransferNotPermitted): + with self.set_user(OTHER_USER), self.assertRaises(TransferNotPermitted): transfer_ticket(ticket.name, "New", "Owner", "new-owner@example.com") - self.assertEqual(TransferNotPermitted.http_status_code, 403) - def test_transfer_is_refused_once_the_window_closes(self): ticket = self.make_ticket() self.set_event_start(2) - frappe.set_user(ATTENDEE) - with self.assertRaises(TransferWindowClosed): + with self.set_user(ATTENDEE), self.assertRaises(TransferWindowClosed): transfer_ticket(ticket.name, "New", "Owner", "new-owner@example.com") - self.assertEqual(TransferWindowClosed.http_status_code, 409) self.assertEqual(frappe.local.message_log[-1]["title"], "Transfers Closed") class TestChangeAddOnPreference(TicketTestCase): def setUp(self): super().setUp() - self.set_event_start(30) - self.add_on = frappe.get_doc( - { - "doctype": "Ticket Add-on", - "event": self.event.name, - "title": f"Meal {frappe.generate_hash(length=6)}", - "user_selects_option": 1, - "options": "Veg\nNon-veg", - "price": 0, - "currency": "INR", - } - ).insert(ignore_permissions=True) - - def make_ticket_with_add_on(self, value="Veg"): - ticket = self.make_ticket() - ticket.append("add_ons", {"add_on": self.add_on.name, "value": value, "price": 0, "currency": "INR"}) - ticket.save(ignore_permissions=True) - return ticket, ticket.add_ons[0].name + self.add_on = TicketAddOnFactory.create( + event=self.event.name, user_selects_option=1, options="Veg\nNon-veg" + ) def test_changes_the_stored_value(self): - _ticket, add_on_value_id = self.make_ticket_with_add_on() - change_add_on_preference(add_on_value_id, "Non-veg") - self.assertEqual(frappe.db.get_value("Ticket Add-on Value", add_on_value_id, "value"), "Non-veg") + add_on_value = self.make_ticket_with_add_on().add_ons[0].name + + change_add_on_preference(add_on_value, "Non-veg") + + self.assertEqual(frappe.db.get_value("Ticket Add-on Value", add_on_value, "value"), "Non-veg") def test_unknown_add_on_value(self): with self.assertRaises(AddOnValueNotFound): change_add_on_preference("not-an-add-on-value", "Non-veg") - self.assertEqual(AddOnValueNotFound.http_status_code, 404) - def test_refused_once_the_window_closes(self): - _ticket, add_on_value_id = self.make_ticket_with_add_on() + add_on_value = self.make_ticket_with_add_on().add_ons[0].name self.set_event_start(2) with self.assertRaises(AddOnChangeWindowClosed): - change_add_on_preference(add_on_value_id, "Non-veg") - - self.assertEqual(AddOnChangeWindowClosed.http_status_code, 409) + change_add_on_preference(add_on_value, "Non-veg") def test_details_carry_the_selectable_options(self): - ticket, _ = self.make_ticket_with_add_on() - frappe.set_user(ATTENDEE) + ticket = self.make_ticket_with_add_on() + + with self.set_user(ATTENDEE): + add_ons = get_ticket_details(ticket.name).add_ons - add_ons = get_ticket_details(ticket.name).add_ons self.assertEqual(len(add_ons), 1) self.assertEqual(add_ons[0].options, ["Veg", "Non-veg"]) self.assertEqual(add_ons[0].value, "Veg") # Check fields travel as 0/1, not booleans. self.assertEqual(add_ons[0].user_selects_option, 1) + def make_ticket_with_add_on(self): + add_on = {"add_on": self.add_on.name, "value": "Veg", "price": 0, "currency": "INR"} + return self.make_ticket(add_ons=[add_on]) + class TestCreateCancellationRequest(TicketTestCase): def setUp(self): super().setUp() - self.set_event_start(30) - self.booking = self.make_booking() + self.booking = self.make_booking().name def test_full_booking_request(self): - self.make_ticket(booking=self.booking.name) - frappe.set_user(ATTENDEE) + self.make_ticket(booking=self.booking) - create_cancellation_request(self.booking.name) + with self.set_user(ATTENDEE): + create_cancellation_request(self.booking) - request = frappe.get_last_doc("Ticket Cancellation Request", filters={"booking": self.booking.name}) + request = self.last_cancellation_request() self.assertTrue(request.cancel_full_booking) self.assertEqual(request.tickets, []) def test_partial_request_records_the_named_tickets(self): - first = self.make_ticket(booking=self.booking.name) - self.make_ticket(booking=self.booking.name) - frappe.set_user(ATTENDEE) + first = self.make_ticket(booking=self.booking).name + self.make_ticket(booking=self.booking) - create_cancellation_request(self.booking.name, [first.name]) + with self.set_user(ATTENDEE): + create_cancellation_request(self.booking, [first]) - request = frappe.get_last_doc("Ticket Cancellation Request", filters={"booking": self.booking.name}) + request = self.last_cancellation_request() self.assertFalse(request.cancel_full_booking) - self.assertEqual([row.ticket for row in request.tickets], [first.name]) + self.assertEqual([row.ticket for row in request.tickets], [first]) def test_naming_every_ticket_is_treated_as_a_full_cancellation(self): - first = self.make_ticket(booking=self.booking.name) - second = self.make_ticket(booking=self.booking.name) - frappe.set_user(ATTENDEE) + first = self.make_ticket(booking=self.booking).name + second = self.make_ticket(booking=self.booking).name - create_cancellation_request(self.booking.name, [first.name, second.name]) + with self.set_user(ATTENDEE): + create_cancellation_request(self.booking, [first, second]) - request = frappe.get_last_doc("Ticket Cancellation Request", filters={"booking": self.booking.name}) - self.assertTrue(request.cancel_full_booking) + self.assertTrue(self.last_cancellation_request().cancel_full_booking) def test_a_ticket_from_another_booking_is_refused(self): - # Two tickets on the booking, so naming one stray id stays on the partial path — - # a count match short-circuits to a full-booking request before the check runs. - self.make_ticket(booking=self.booking.name) - self.make_ticket(booking=self.booking.name) - other_booking = self.make_booking() - stray = self.make_ticket(booking=other_booking.name) - frappe.set_user(ATTENDEE) + # Two tickets keep the request partial: a count match skips the booking check. + self.make_ticket(booking=self.booking) + self.make_ticket(booking=self.booking) + other_booking_ticket = self.make_ticket(booking=self.make_booking().name).name - with self.assertRaises(TicketNotInBooking): - create_cancellation_request(self.booking.name, [stray.name]) + with self.set_user(ATTENDEE), self.assertRaises(TicketNotInBooking): + create_cancellation_request(self.booking, [other_booking_ticket]) - self.assertIn(stray.name, frappe.local.message_log[-1]["message"]) + self.assertIn(other_booking_ticket, frappe.local.message_log[-1]["message"]) def test_another_user_cannot_request(self): - self.make_ticket(booking=self.booking.name) - frappe.set_user(OTHER_USER) + self.make_ticket(booking=self.booking) - with self.assertRaises(CancellationNotPermitted): - create_cancellation_request(self.booking.name) - - self.assertEqual(CancellationNotPermitted.http_status_code, 403) + with self.set_user(OTHER_USER), self.assertRaises(CancellationNotPermitted): + create_cancellation_request(self.booking) def test_refused_once_the_window_closes(self): - self.make_ticket(booking=self.booking.name) + self.make_ticket(booking=self.booking) self.set_event_start(2) - frappe.set_user(ATTENDEE) - - with self.assertRaises(CancellationWindowClosed): - create_cancellation_request(self.booking.name) - self.assertEqual(CancellationWindowClosed.http_status_code, 409) + with self.set_user(ATTENDEE), self.assertRaises(CancellationWindowClosed): + create_cancellation_request(self.booking) def test_a_second_open_request_is_refused(self): - self.make_ticket(booking=self.booking.name) - frappe.set_user(ATTENDEE) - create_cancellation_request(self.booking.name) + self.make_ticket(booking=self.booking) - with self.assertRaises(CancellationAlreadyRequested): - create_cancellation_request(self.booking.name) + with self.set_user(ATTENDEE): + create_cancellation_request(self.booking) + with self.assertRaises(CancellationAlreadyRequested): + create_cancellation_request(self.booking) self.assertEqual(frappe.local.message_log[-1]["title"], "Already Requested") + + def last_cancellation_request(self): + return frappe.get_last_doc("Ticket Cancellation Request", filters={"booking": self.booking}) diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index 4dc420dd..34d2e472 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -1,233 +1,111 @@ # Copyright (c) 2025, BWH Studios and Contributors # See license.txt -from datetime import datetime, timedelta, timezone from unittest.mock import patch import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today -from buzz.api.booking.services import are_registrations_closed -from buzz.api.forms.test_forms import ensure_event_host -from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, create_from_template -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import ( - create_webinar_template, - set_team_settings, -) -from buzz.events.doctype.event_template.event_template import create_template_from_event -from buzz.patches.set_time_zone_label_for_existing_events import execute as backfill_time_zone_labels -from buzz.utils import get_time_zone_label +from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES +from buzz.tests.factories import BuzzEventFactory, EventVenueFactory +START_DATE = add_days(today(), 30) +END_DATE = add_days(today(), 31) -def stub_zoom_id(webinar): - """Stand in for the Zoom API call before_insert makes; validation needs the id.""" - webinar.zoom_webinar_id = "1234567890" - -class TestBuzzEvent(FrappeTestCase): +class BuzzEventTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.create_test_fixtures() - - @classmethod - def create_test_fixtures(cls): - if not frappe.db.exists("Event Category", "Test Category"): - frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert( - ignore_permissions=True - ) + event = BuzzEventFactory.create() + cls.event_links = {"team": event.team, "host": event.host, "category": event.category} - def tearDown(self): - frappe.db.rollback() + def build_event(self, *traits, **fields): + return BuzzEventFactory.build(*traits, **self.event_links, **fields) - # ==================== Schedule Validation Tests ==================== + def create_event(self, **fields): + return BuzzEventFactory.create(**self.event_links, **fields) - def _make_event_with_schedule(self, schedule_overrides, **event_overrides): - """Helper to create a Buzz Event with a single schedule item for validation tests.""" - event_defaults = { - "doctype": "Buzz Event", - "title": "Schedule Test Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": "2026-03-05", - "end_date": "2026-03-06", - "start_time": "9:00:00", - "end_time": "18:00:00", - } - event_defaults.update(event_overrides) - event = frappe.get_doc(event_defaults) - - # Directly call validate_schedule instead of insert to avoid - # needing linked Event Track records in the test database - for row in schedule_overrides: - event.append("schedule", row) - return event +class TestEventValidation(BuzzEventTestCase): def test_refuses_a_tax_rate_above_100(self): - event = self._make_event_with_schedule([], apply_tax=1, tax_percentage=150) + event = self.build_event(apply_tax=1, tax_percentage=150) with self.assertRaises(frappe.ValidationError): event.validate_tax_settings() def test_refuses_a_negative_tax_rate(self): - event = self._make_event_with_schedule([], apply_tax=1, tax_percentage=-5) + event = self.build_event(apply_tax=1, tax_percentage=-5) with self.assertRaises(frappe.ValidationError): event.validate_tax_settings() def test_schedule_start_time_after_event_start_is_valid(self): - """Schedule at 11:00 should be valid when event starts at 9:00 (regression: string comparison bug)""" - event = self._make_event_with_schedule( - [{"date": "2026-03-05", "start_time": "11:00:00", "end_time": "12:00:00"}] - ) - # Should not raise - event.validate_schedule() + # Regression: times were compared as strings, so "11:00" sorted before "9:00". + self.event_with_schedule(START_DATE, "11:00:00", "12:00:00").validate_schedule() def test_schedule_start_time_before_event_start_is_rejected(self): - """Schedule at 08:00 should be rejected when event starts at 9:00""" - event = self._make_event_with_schedule( - [{"date": "2026-03-05", "start_time": "08:00:00", "end_time": "08:30:00"}] - ) - with self.assertRaises(frappe.exceptions.ValidationError): + event = self.event_with_schedule(START_DATE, "08:00:00", "08:30:00") + with self.assertRaises(frappe.ValidationError): event.validate_schedule() def test_schedule_end_time_after_event_end_is_rejected(self): - """Schedule ending at 19:00 should be rejected when event ends at 18:00""" - event = self._make_event_with_schedule( - [{"date": "2026-03-06", "start_time": "17:00:00", "end_time": "19:00:00"}] - ) - with self.assertRaises(frappe.exceptions.ValidationError): + event = self.event_with_schedule(END_DATE, "17:00:00", "19:00:00") + with self.assertRaises(frappe.ValidationError): event.validate_schedule() def test_schedule_end_time_before_event_end_is_valid(self): - """Schedule ending at 16:30 should be valid when event ends at 18:00""" - event = self._make_event_with_schedule( - [{"date": "2026-03-06", "start_time": "16:00:00", "end_time": "16:30:00"}] + self.event_with_schedule(END_DATE, "16:00:00", "16:30:00").validate_schedule() + + def event_with_schedule(self, date: str, start_time: str, end_time: str): + # Validated directly: an insert would need Event Track rows for the schedule. + row = {"date": date, "start_time": start_time, "end_time": end_time} + return self.build_event( + start_date=START_DATE, + end_date=END_DATE, + start_time="9:00:00", + end_time="18:00:00", + schedule=[row], ) - # Should not raise - event.validate_schedule() - - # ==================== Reserved Route Tests ==================== - def _make_event_with_route(self, route=None, **overrides): - return frappe.get_doc( - { - "doctype": "Buzz Event", - "title": f"Route Test Event {route}", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "route": route, - **overrides, - } - ) +class TestEventRoute(BuzzEventTestCase): def test_reserved_routes_are_rejected(self): - """Every reserved segment must be refused as an event route. - - An event route becomes /b/, so any route matching a top-level - dashboard segment would be shadowed by that segment's own page. - """ for route in RESERVED_EVENT_ROUTES: - with self.subTest(route=route): - with self.assertRaises(frappe.exceptions.ValidationError): - self._make_event_with_route(route).insert() - frappe.db.rollback() + with self.subTest(route=route), self.assertRaises(frappe.ValidationError): + self.create_event(route=route) def test_reserved_routes_are_rejected_case_insensitively(self): - """Mixed-case spellings of a reserved segment must be refused too. - - vue-router matches paths case-insensitively, so an event routed - "Account" is shadowed by /b/account exactly as "account" would be. - """ + # vue-router matches paths case-insensitively. for route in ("Account", "BOOKING-SUCCESS", "Register"): - with self.subTest(route=route): - with self.assertRaises(frappe.exceptions.ValidationError): - self._make_event_with_route(route).insert() - frappe.db.rollback() + with self.subTest(route=route), self.assertRaises(frappe.ValidationError): + self.create_event(route=route) def test_reserved_routes_cover_dashboard_segments(self): - """booking-success is reserved: it is a static route declared ahead of the - /:eventRoute/:formRoute catch-all, so an event using it would have every - custom form swallowed by the booking confirmation page. - """ self.assertIn("booking-success", RESERVED_EVENT_ROUTES) def test_manager_section_is_reserved(self): - """/manage owns everything below it: its children end in a catch-all that - renders the 404, so an event routed "manage" would lose every custom form. - """ self.assertIn("manage", RESERVED_EVENT_ROUTES) def test_unreserved_route_is_accepted(self): - """A route that shadows nothing saves normally.""" - event = self._make_event_with_route("my-conference-2026") - event.insert() - self.assertEqual(event.route, "my-conference-2026") + route = f"conference-{frappe.generate_hash(length=6)}" + self.assertEqual(self.create_event(route=route).route, route) def test_new_event_is_published_with_a_hashed_route(self): - """A new event is shareable on insert, on a route that is not its title.""" - event = self._make_event_with_route() - event.insert() + # None lets the DocType default apply on insert. + event = self.create_event(is_published=None) self.assertTrue(event.is_published) self.assertRegex(event.route, r"^[0-9a-f]{8}$") def test_generated_routes_are_unique(self): - """route is a unique column, so two events must not land on one hash.""" - first = self._make_event_with_route() - first.insert() - second = self._make_event_with_route() - second.insert() - self.assertNotEqual(first.route, second.route) + self.assertNotEqual(self.create_event().route, self.create_event().route) def test_explicitly_unpublished_event_stays_a_draft(self): - """The publish default must not override a caller asking for a draft.""" - event = self._make_event_with_route(is_published=0) - event.insert() - self.assertFalse(event.is_published) - - # ==================== Venue Tests ==================== + self.assertFalse(self.create_event(is_published=0).is_published) - def _make_team(self, team_name: str) -> str: - """Per test, not per class: tearDown rolls back everything setUpClass inserts.""" - owner = create_user("buzz-event-venue-owner@example.com", "Owner") - return create_owned_team(team_name, owner) - - def _make_venue(self, name: str, team: str | None) -> str: - venue = frappe.get_doc( - { - "doctype": "Event Venue", - "venue_name": name, - "address": "1 Test Street", - "team": team, - } - ).insert(ignore_permissions=True) - return str(venue.name) - - def _make_event_with_venue(self, venue: str, team: str | None): - return frappe.get_doc( - { - "doctype": "Buzz Event", - "title": f"Venue Test Event {venue}", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "team": team, - "venue": venue, - } - ) +class TestEventLocation(BuzzEventTestCase): def test_turning_an_event_online_drops_its_venue(self): - """The venue outlives the medium otherwise. - - `generate_ics_file` and the booking page both read `venue` without consulting - `medium`, so a leftover venue puts a physical address on an online event. - """ - team = self._make_team("Venue Test Team") - event = self._make_event_with_venue(self._make_venue("Venue Test Hall", team), team) + event = self.build_event("in_person") event.medium = "Online" event.meeting_link = "https://example.com/room" @@ -237,11 +115,8 @@ def test_turning_an_event_online_drops_its_venue(self): self.assertEqual(event.meeting_link, "https://example.com/room") def test_turning_an_event_in_person_drops_its_meeting_link(self): - team = self._make_team("Venue Test Team") - venue = self._make_venue("Venue Test Hall", team) - event = self._make_event_with_venue(venue, team) - event.medium = "In Person" - event.meeting_link = "https://example.com/room" + venue = EventVenueFactory.create(team=self.event_links["team"]).name + event = self.build_event("in_person", venue=venue, meeting_link="https://example.com/room") event.clear_unused_location() @@ -249,990 +124,15 @@ def test_turning_an_event_in_person_drops_its_meeting_link(self): self.assertIsNone(event.meeting_link) def test_an_online_event_keeps_a_venue_it_never_had(self): - """Clearing must not invent a change on an event that was always online.""" - team = self._make_team("Venue Test Team") - event = self._make_event_with_venue(None, team) - event.medium = "Online" + event = self.build_event(medium="Online") event.clear_unused_location() self.assertIsNone(event.venue) - # ==================== Create from Template Tests ==================== - - def test_create_from_template_copies_direct_fields(self): - """Test that direct fields are copied from template to event""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Direct Fields Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "medium": "Online", - "about": "About text", - "short_description": "Short desc", - "time_zone": "Asia/Kolkata", - "allow_guest_booking": 1, - "guest_verification_method": "Email OTP", - "send_ticket_email": 1, - "apply_tax": 1, - "tax_label": "GST", - "tax_percentage": 18, - } - ) - template.insert() - - options = { - "category": 1, - "host": 1, - "medium": 1, - "about": 1, - "short_description": 1, - "time_zone": 1, - "allow_guest_booking": 1, - "guest_verification_method": 1, - "send_ticket_email": 1, - "apply_tax": 1, - "tax_label": 1, - "tax_percentage": 1, - } - - event_name = create_from_template(template.name, frappe.as_json(options)) - event = frappe.get_doc("Buzz Event", event_name) - - self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, ensure_event_host("Test Host")) - self.assertEqual(event.medium, "Online") - self.assertEqual(event.about, "About text") - self.assertEqual(event.short_description, "Short desc") - self.assertEqual(event.time_zone, "Asia/Kolkata") - self.assertEqual(event.allow_guest_booking, 1) - self.assertEqual(event.guest_verification_method, "Email OTP") - self.assertEqual(event.send_ticket_email, 1) - self.assertEqual(event.apply_tax, 1) - self.assertEqual(event.tax_label, "GST") - self.assertEqual(event.tax_percentage, 18) - - def test_create_from_template_respects_unselected_options(self): - """Test that unselected options are not copied""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Selective Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "medium": "In Person", - "about": "Should not appear", - "apply_tax": 1, - "tax_percentage": 18, - } - ) - template.insert() - - options = {"category": 1, "host": 1, "medium": 0, "about": 0, "apply_tax": 0} - - event_name = create_from_template(template.name, frappe.as_json(options)) - event = frappe.get_doc("Buzz Event", event_name) - - self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, ensure_event_host("Test Host")) - self.assertFalse(event.about) - self.assertFalse(event.apply_tax) - - def test_create_from_template_additional_fields_override(self): - """Test that additional_fields override template values""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Override Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - } - ) - template.insert() - - # Don't copy category from template, provide via additional_fields - options = {"host": 1} - additional_fields = {"category": "Test Category"} - - event_name = create_from_template( - template.name, frappe.as_json(options), frappe.as_json(additional_fields) - ) - event = frappe.get_doc("Buzz Event", event_name) - - self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, ensure_event_host("Test Host")) - - def test_create_from_template_creates_ticket_types(self): - """Test that ticket types are created as linked documents""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Ticket Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_ticket_types": [ - { - "title": "Early Bird", - "price": 500, - "currency": "INR", - "is_published": 1, - "max_tickets_available": 100, - }, - {"title": "Regular", "price": 1000, "currency": "INR", "is_published": 1}, - ], - } - ) - template.insert() - - options = {"category": 1, "host": 1, "ticket_types": 1} - event_name = create_from_template(template.name, frappe.as_json(options)) - - ticket_types = frappe.get_all( - "Event Ticket Type", - filters={"event": event_name, "title": ["in", ["Early Bird", "Regular"]]}, - fields=["title", {"prices": ["price"]}, "max_tickets_available"], - order_by="title", - ) - self.assertEqual(len(ticket_types), 2) - self.assertEqual(ticket_types[0].title, "Early Bird") - self.assertEqual(ticket_types[0].prices[0].price, 500) - self.assertEqual(ticket_types[0].max_tickets_available, 100) - self.assertEqual(ticket_types[1].title, "Regular") - self.assertEqual(ticket_types[1].prices[0].price, 1000) - - def test_create_from_template_creates_add_ons(self): - """Test that add-ons are created as linked documents""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "AddOn Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_add_ons": [ - { - "title": "Workshop Access", - "price": 2000, - "currency": "INR", - "enabled": 1, - "user_selects_option": 1, - "options": "Morning\nAfternoon", - } - ], - } - ) - template.insert() - - options = {"category": 1, "host": 1, "add_ons": 1} - event_name = create_from_template(template.name, frappe.as_json(options)) - - add_ons = frappe.get_all( - "Ticket Add-on", - filters={"event": event_name}, - fields=["title", "price", "user_selects_option", "options"], - ) - self.assertEqual(len(add_ons), 1) - self.assertEqual(add_ons[0].title, "Workshop Access") - self.assertEqual(add_ons[0].price, 2000) - self.assertEqual(add_ons[0].user_selects_option, 1) - self.assertEqual(add_ons[0].options, "Morning\nAfternoon") - - def test_create_from_template_creates_custom_fields(self): - """Test that custom fields are created as linked documents""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "CustomField Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_custom_fields": [ - { - "label": "Company", - "fieldname": "company", - "fieldtype": "Data", - "applied_to": "Booking", - "mandatory": 1, - "enabled": 1, - "placeholder": "Enter company name", - } - ], - } - ) - template.insert() - - options = {"category": 1, "host": 1, "custom_fields": 1} - event_name = create_from_template(template.name, frappe.as_json(options)) - - custom_fields = frappe.get_all( - "Buzz Custom Field", - filters={"event": event_name}, - fields=["label", "fieldtype", "mandatory", "placeholder"], - ) - self.assertEqual(len(custom_fields), 1) - self.assertEqual(custom_fields[0].label, "Company") - self.assertEqual(custom_fields[0].fieldtype, "Data") - self.assertEqual(custom_fields[0].mandatory, 1) - self.assertEqual(custom_fields[0].placeholder, "Enter company name") - - def test_create_from_template_skips_linked_docs_when_unselected(self): - """Test that linked docs are not created when options are 0""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Skip Linked Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_ticket_types": [ - {"title": "Skipped", "price": 100, "currency": "INR", "is_published": 1} - ], - "template_add_ons": [ - {"title": "Skipped Addon", "price": 50, "currency": "INR", "enabled": 1} - ], - "template_custom_fields": [ - { - "label": "Skipped Field", - "fieldname": "skipped", - "fieldtype": "Data", - "applied_to": "Booking", - "enabled": 1, - } - ], - } - ) - template.insert() - - options = {"category": 1, "host": 1, "ticket_types": 0, "add_ons": 0, "custom_fields": 0} - event_name = create_from_template(template.name, frappe.as_json(options)) - - self.assertEqual( - len(frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "Skipped"})), 0 - ) - self.assertEqual(len(frappe.get_all("Ticket Add-on", filters={"event": event_name})), 0) - self.assertEqual(len(frappe.get_all("Buzz Custom Field", filters={"event": event_name})), 0) - - def test_create_from_template_sets_default_title_and_date(self): - """Test that event gets a default title and today's date""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Defaults Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - } - ) - template.insert() - - options = {"category": 1, "host": 1} - event_name = create_from_template(template.name, frappe.as_json(options)) - event = frappe.get_doc("Buzz Event", event_name) - - self.assertIn("Defaults Template", event.title) - self.assertEqual(str(event.start_date), frappe.utils.today()) - - def test_create_from_template_copies_sponsorship_settings(self): - """Test that sponsorship settings are copied""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Sponsor Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "auto_send_pitch_deck": 1, - "sponsor_deck_reply_to": "test@example.com", - "sponsor_deck_cc": "cc@example.com", - } - ) - template.insert() - - options = { - "category": 1, - "host": 1, - "auto_send_pitch_deck": 1, - "sponsor_deck_reply_to": 1, - "sponsor_deck_cc": 1, - } - - event_name = create_from_template(template.name, frappe.as_json(options)) - event = frappe.get_doc("Buzz Event", event_name) - - self.assertEqual(event.auto_send_pitch_deck, 1) - self.assertEqual(event.sponsor_deck_reply_to, "test@example.com") - self.assertEqual(event.sponsor_deck_cc, "cc@example.com") - - # ==================== Save as Template Tests ==================== - - def test_save_event_as_template_all_options(self): - """Test saving an event as template with all field options""" - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Full Save Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "medium": "Online", - "about": "Full event description", - "apply_tax": 1, - "tax_label": "GST", - "tax_percentage": 18, - } - ) - event.insert() - - # Create linked docs - frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": event.name, - "title": "Gold", - "prices": [{"currency": "INR", "price": 5000}], - "is_published": 1, - } - ).insert() - - frappe.get_doc( - { - "doctype": "Ticket Add-on", - "event": event.name, - "title": "Parking", - "price": 200, - "currency": "INR", - "enabled": 1, - } - ).insert() - - frappe.get_doc( - { - "doctype": "Buzz Custom Field", - "event": event.name, - "label": "Designation", - "fieldname": "designation", - "fieldtype": "Data", - "applied_to": "Booking", - "enabled": 1, - } - ).insert() - - options = { - "category": 1, - "host": 1, - "medium": 1, - "about": 1, - "apply_tax": 1, - "tax_label": 1, - "tax_percentage": 1, - "ticket_types": 1, - "add_ons": 1, - "custom_fields": 1, - } - - template_name = create_template_from_event( - str(event.name), "Full Save Template", frappe.as_json(options) - ) - template = frappe.get_doc("Event Template", template_name) - - self.assertEqual(template.category, "Test Category") - self.assertEqual(template.host, ensure_event_host("Test Host")) - self.assertEqual(template.medium, "Online") - self.assertEqual(template.about, "Full event description") - self.assertEqual(template.apply_tax, 1) - self.assertEqual(template.tax_percentage, 18) - - # Check linked docs (ticket types include default "Normal" created on event insert) - gold_tickets = [t for t in template.template_ticket_types if t.title == "Gold"] - self.assertEqual(len(gold_tickets), 1) - self.assertEqual(gold_tickets[0].price, 5000) - - self.assertEqual(len(template.template_add_ons), 1) - self.assertEqual(template.template_add_ons[0].title, "Parking") - - self.assertEqual(len(template.template_custom_fields), 1) - self.assertEqual(template.template_custom_fields[0].label, "Designation") - - def test_save_event_as_template_partial(self): - """Test saving event as template with partial options""" - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Partial Save Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "medium": "In Person", - "about": "Included", - "apply_tax": 1, - "tax_percentage": 18, - } - ) - event.insert() - - options = {"category": 1, "about": 1, "medium": 0, "apply_tax": 0} - - template_name = create_template_from_event( - str(event.name), "Partial Save Template", frappe.as_json(options) - ) - template = frappe.get_doc("Event Template", template_name) - - self.assertEqual(template.category, "Test Category") - self.assertEqual(template.about, "Included") - self.assertFalse(template.host) - self.assertFalse(template.apply_tax) - - # ==================== Round Trip Test ==================== - - def test_round_trip_preserves_data(self): - """Test Event -> Template -> Event preserves all data""" - original = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Round Trip Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "medium": "Online", - "about": "Round trip description", - "apply_tax": 1, - "tax_label": "Service Tax", - "tax_percentage": 12, - } - ) - original.insert() - - frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": original.name, - "title": "Platinum", - "prices": [{"currency": "INR", "price": 10000}], - "is_published": 1, - "max_tickets_available": 25, - } - ).insert() - - # Event -> Template - all_options = { - "category": 1, - "host": 1, - "medium": 1, - "about": 1, - "apply_tax": 1, - "tax_label": 1, - "tax_percentage": 1, - "ticket_types": 1, - } - template_name = create_template_from_event( - str(original.name), "Round Trip Template", frappe.as_json(all_options) - ) - - # Template -> New Event - new_event_name = create_from_template(template_name, frappe.as_json(all_options)) - new_event = frappe.get_doc("Buzz Event", new_event_name) - - self.assertEqual(new_event.category, original.category) - self.assertEqual(new_event.host, original.host) - self.assertEqual(new_event.medium, original.medium) - self.assertEqual(new_event.about, original.about) - self.assertEqual(new_event.tax_label, original.tax_label) - self.assertEqual(new_event.tax_percentage, original.tax_percentage) - - platinum_tickets = frappe.get_all( - "Event Ticket Type", - filters={"event": new_event_name, "title": "Platinum"}, - fields=[{"prices": ["price"]}, "max_tickets_available"], - ) - self.assertEqual(len(platinum_tickets), 1) - self.assertEqual(platinum_tickets[0].prices[0].price, 10000) - self.assertEqual(platinum_tickets[0].max_tickets_available, 25) - - # ==================== Permission Tests ==================== - - def test_create_from_template_requires_template_read_permission(self): - """Test that creating from template requires read permission on Event Template""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Perm Test Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - } - ) - template.insert() - - # Create a user without Event Template read permission - frappe.set_user("Guest") - try: - with self.assertRaises(frappe.exceptions.ValidationError): - create_from_template(template.name, frappe.as_json({"category": 1, "host": 1})) - finally: - frappe.set_user("Administrator") - def test_save_as_template_requires_create_permission(self): - """Test that saving as template requires create permission on Event Template""" - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Perm Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - } - ) - event.insert() - - frappe.set_user("Guest") - try: - with self.assertRaises(frappe.exceptions.ValidationError): - create_template_from_event(str(event.name), "Perm Template", frappe.as_json({"category": 1})) - finally: - frappe.set_user("Administrator") - - -class TestRegistrationsClosed(FrappeTestCase): - """Tests for the are_registrations_closed function with timezone handling.""" - - def _make_event( - self, - registrations_close_at=None, - time_zone=None, - start_date="2026-06-01", - start_time="09:00:00", - end_date="2026-06-01", - end_time="18:00:00", - ): - """Create a minimal event _dict for testing (no DB insert needed). - - Defaults start/end to a fixed date far from any fake "now" used in the - explicit-close_at tests, so the event-end fallback never accidentally - kicks in for those. - """ - return frappe._dict( - registrations_close_at=registrations_close_at, - time_zone=time_zone, - start_date=start_date, - start_time=start_time, - end_date=end_date, - end_time=end_time, - ) - - def test_no_close_at_and_event_in_future_returns_false(self): - """When registrations_close_at is not set and the event hasn't ended, registrations are open.""" - fake_now = datetime(2026, 6, 15, 10, 0, 0) - event = self._make_event( - time_zone="UTC", - start_date="2026-06-20", - start_time="09:00:00", - end_date="2026-06-20", - end_time="18:00:00", - ) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_now): - self.assertFalse(are_registrations_closed(event)) - - def test_no_close_at_falls_back_to_event_end(self): - """When registrations_close_at is not set, registrations close once the event itself has ended (issue #91).""" - fake_now = datetime(2026, 6, 15, 20, 0, 0) - event = self._make_event( - time_zone="UTC", - start_date="2026-06-15", - start_time="09:00:00", - end_date="2026-06-15", - end_time="18:00:00", # event ended 2 hours before fake_now - ) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_now): - self.assertTrue(are_registrations_closed(event)) - - def test_close_at_takes_priority_over_event_end(self): - """An explicit registrations_close_at overrides the event-end fallback, even when it's later than the event end.""" - fake_now = datetime(2026, 6, 15, 19, 0, 0) # after event end (18:00), before close_at (20:00) - event = self._make_event( - registrations_close_at="2026-06-15 20:00:00", - time_zone="UTC", - start_date="2026-06-15", - start_time="09:00:00", - end_date="2026-06-15", - end_time="18:00:00", - ) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_now): - self.assertFalse(are_registrations_closed(event)) - - def test_future_close_at_returns_false(self): - """When close_at is in the future, registrations are open.""" - fake_now = datetime(2026, 6, 15, 10, 0, 0) - event = self._make_event( - registrations_close_at="2026-06-15 12:00:00", # 2 hours after fake_now - time_zone="UTC", - ) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_now): - self.assertFalse(are_registrations_closed(event)) - - def test_past_close_at_returns_true(self): - """When close_at is in the past, registrations are closed.""" - fake_now = datetime(2026, 6, 15, 14, 0, 0) - event = self._make_event( - registrations_close_at="2026-06-15 12:00:00", # 2 hours before fake_now - time_zone="UTC", - ) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_now): - self.assertTrue(are_registrations_closed(event)) - - def test_timezone_ahead_of_utc_closes_earlier(self): - """An event in Asia/Kolkata (UTC+5:30) should close before the same wall-clock time in UTC. - - If it's 14:00 UTC, that's 19:30 IST. - A close_at of 18:00 (naive, in event tz) is already past in IST but not in UTC. - """ - # Simulate 19:30 IST (= 14:00 UTC) - fake_ist_now = datetime(2026, 6, 15, 19, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) - - event = self._make_event( - registrations_close_at="2026-06-15 18:00:00", # 18:00 in event tz (IST) - time_zone="Asia/Kolkata", - ) - - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_ist_now): - # 19:30 IST > 18:00 IST → closed - self.assertTrue(are_registrations_closed(event)) - - def test_timezone_behind_utc_stays_open_longer(self): - """An event in US/Pacific (UTC-7) should stay open longer than the same wall-clock in UTC. - - If it's 23:00 UTC on June 15, that's 16:00 PDT on June 15. - A close_at of 18:00 (naive, in event tz) is still in the future in PDT. - """ - # Simulate 16:00 PDT (= 23:00 UTC) - fake_pdt_now = datetime(2026, 6, 15, 16, 0, 0, tzinfo=timezone(timedelta(hours=-7))) - - event = self._make_event( - registrations_close_at="2026-06-15 18:00:00", # 18:00 in event tz (PDT) - time_zone="US/Pacific", - ) - - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_pdt_now): - # 16:00 PDT < 18:00 PDT → still open - self.assertFalse(are_registrations_closed(event)) - - def test_same_close_time_different_timezones(self): - """Same UTC instant, same close_at string — different result depending on event timezone. - - At 2026-06-15 17:30 UTC: - - Asia/Kolkata: 23:00 IST → 23:00 > 18:00 → closed - - US/Pacific: 10:30 PDT → 10:30 < 18:00 → open - """ - close_at = "2026-06-15 18:00:00" - - event_ist = self._make_event(registrations_close_at=close_at, time_zone="Asia/Kolkata") - event_pdt = self._make_event(registrations_close_at=close_at, time_zone="US/Pacific") - - # 17:30 UTC = 23:00 IST - fake_ist_now = datetime(2026, 6, 15, 23, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_ist_now): - self.assertTrue(are_registrations_closed(event_ist)) - - # 17:30 UTC = 10:30 PDT - fake_pdt_now = datetime(2026, 6, 15, 10, 30, 0, tzinfo=timezone(timedelta(hours=-7))) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_pdt_now): - self.assertFalse(are_registrations_closed(event_pdt)) - - def test_falls_back_to_system_timezone_when_event_tz_not_set(self): - """When event has no time_zone, system timezone is used.""" - fake_now = datetime(2026, 6, 15, 14, 0, 0) - event = self._make_event( - registrations_close_at="2026-06-15 13:00:00", # 1 hour before fake_now - time_zone=None, - ) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=fake_now): - self.assertTrue(are_registrations_closed(event)) - - def test_closing_moment_is_same_absolute_instant_for_viewers_anywhere(self): - """are_registrations_closed never looks at the viewer's timezone, only the event's - - so a person checking from London and a person checking from Mumbai at the exact same - real-world moment always get the same open/closed answer. - - Worked example: event in Asia/Kolkata (IST, UTC+5:30) closes at 16:30 IST. - London in June is on BST (UTC+1:00). Offset difference: 4:30. - So the closing instant is simultaneously: - 2026-06-15 11:00:00 UTC - 2026-06-15 12:00:00 BST (noon in London) - 2026-06-15 16:30:00 IST (4:30 PM in India - the configured close time) - `get_datetime_in_timezone` (mocked here, as elsewhere in this class) always returns - "now" already converted into the *event's* timezone - so regardless of where the - actual request came from, this test only needs to supply the IST-side value that - corresponds to that one shared real-world instant. - """ - event = self._make_event(registrations_close_at="2026-06-15 16:30:00", time_zone="Asia/Kolkata") - - # Exactly at the closing instant (11:00 UTC / noon BST / 16:30 IST) -> comparison is - # strictly-greater-than, so registrations are still open at the exact boundary. - at_close = datetime(2026, 6, 15, 16, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=at_close): - self.assertFalse(are_registrations_closed(event)) - - # One minute before that shared instant (10:59 UTC / 11:59 BST / 16:29 IST) -> still open. - before_close = datetime(2026, 6, 15, 16, 29, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=before_close): - self.assertFalse(are_registrations_closed(event)) - - # One minute after (11:01 UTC / 12:01 BST / 16:31 IST) -> closed. - after_close = datetime(2026, 6, 15, 16, 31, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=after_close): - self.assertTrue(are_registrations_closed(event)) - - def test_event_end_fallback_is_also_timezone_consistent(self): - """Same India/London worked example as above, but for the no-explicit-cutoff fallback - path: the event's own end_date/end_time (16:30 IST) is what closes registrations. - """ - event = self._make_event( - time_zone="Asia/Kolkata", - start_date="2026-06-15", - start_time="09:00:00", - end_date="2026-06-15", - end_time="16:30:00", - ) - - # 16:29 IST (11:59 BST / noon-minus-1 in London) -> event still ongoing, open. - before_end = datetime(2026, 6, 15, 16, 29, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=before_end): - self.assertFalse(are_registrations_closed(event)) - - # 16:31 IST (12:01 BST, just past noon in London) -> event over, closed. - after_end = datetime(2026, 6, 15, 16, 31, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) - with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=after_end): - self.assertTrue(are_registrations_closed(event)) - - -class TestTimeZoneLabel(FrappeTestCase): - """Tests for get_time_zone_label: IANA name -> short display label.""" - - def test_tzdb_abbreviation_when_alphabetic(self): - """Zones where tzdata ships a real abbreviation use it directly.""" - reference = datetime(2026, 6, 15, 12, 0) - self.assertEqual(get_time_zone_label("Asia/Kolkata", reference), "IST") - self.assertEqual(get_time_zone_label("Asia/Tokyo", reference), "JST") - self.assertEqual(get_time_zone_label("Africa/Nairobi", reference), "EAT") - self.assertEqual(get_time_zone_label("UTC", reference), "UTC") - - def test_dst_variant_follows_reference_date(self): - """DST zones get the abbreviation in effect on the reference date.""" - winter = datetime(2026, 1, 15, 12, 0) - summer = datetime(2026, 7, 15, 12, 0) - self.assertEqual(get_time_zone_label("America/New_York", winter), "EST") - self.assertEqual(get_time_zone_label("America/New_York", summer), "EDT") - self.assertEqual(get_time_zone_label("Europe/Berlin", winter), "CET") - self.assertEqual(get_time_zone_label("Europe/Berlin", summer), "CEST") - - def test_curated_abbreviation_when_tzdb_is_numeric(self): - """Zones where tzdata returns a bare offset fall back to the curated map.""" - reference = datetime(2026, 6, 15, 12, 0) - self.assertEqual(get_time_zone_label("Asia/Dubai", reference), "GST") - self.assertEqual(get_time_zone_label("Asia/Riyadh", reference), "AST") - self.assertEqual(get_time_zone_label("Asia/Bangkok", reference), "ICT") - self.assertEqual(get_time_zone_label("Asia/Kathmandu", reference), "NPT") - - def test_gmt_offset_fallback_for_unmapped_zone(self): - """Zones outside tzdata abbreviations and the curated map show a GMT offset.""" - reference = datetime(2026, 6, 15, 12, 0) - # Bhutan: tzname is "+06", not in the curated map - self.assertEqual(get_time_zone_label("Asia/Thimphu", reference), "GMT+6") - # Myanmar: half-hour offset formatting - self.assertEqual(get_time_zone_label("Asia/Yangon", reference), "GMT+6:30") - # Marquesas: negative half-hour offset - self.assertEqual(get_time_zone_label("Pacific/Marquesas", reference), "GMT-9:30") - - def test_empty_or_invalid_time_zone_returns_empty(self): - reference = datetime(2026, 6, 15, 12, 0) - self.assertEqual(get_time_zone_label(None, reference), "") - self.assertEqual(get_time_zone_label("", reference), "") - self.assertEqual(get_time_zone_label("Not/A_Zone", reference), "") - - def test_current_iana_names_for_renamed_zones(self): - """Renamed zones resolve under both the legacy and current IANA names.""" - reference = datetime(2026, 6, 15, 12, 0) - self.assertEqual(get_time_zone_label("Asia/Ho_Chi_Minh", reference), "ICT") - self.assertEqual(get_time_zone_label("America/Nuuk", reference), "WGT") - - def test_aware_reference_datetime_converted_not_reinterpreted(self): - """US DST ends 2026-11-01 06:00 UTC; 05:30 UTC is still 01:30 EDT. - - Naive .replace() would read 05:30 as New York wall clock (past the - switch, EST); a correct conversion lands on EDT. - """ - aware_reference = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc) - self.assertEqual(get_time_zone_label("America/New_York", aware_reference), "EDT") - - -class TestEventTimeZoneLabelField(FrappeTestCase): - """Saving a Buzz Event stores the display label for its time zone.""" - - def tearDown(self): - frappe.db.rollback() - - def _make_event(self, **overrides): - event_defaults = { - "doctype": "Buzz Event", - "title": "TZ Label Test Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": "2026-03-05", - "end_date": "2026-03-06", - "start_time": "9:00:00", - "end_time": "18:00:00", - } - event_defaults.update(overrides) - return frappe.get_doc(event_defaults) - - @classmethod - def setUpClass(cls): - super().setUpClass() - TestBuzzEvent.create_test_fixtures() - - def test_label_set_on_insert(self): - event = self._make_event(time_zone="Asia/Kolkata") - event.insert() - self.assertEqual(event.time_zone_label, "IST") - - def test_label_updates_when_time_zone_changes(self): - event = self._make_event(time_zone="Asia/Kolkata") - event.insert() - event.time_zone = "Asia/Dubai" - event.save() - self.assertEqual(event.time_zone_label, "GST") - - def test_label_cleared_when_time_zone_removed(self): - event = self._make_event(time_zone="Asia/Kolkata") - event.insert() - event.time_zone = "" - event.save() - self.assertEqual(event.time_zone_label, "") - - def test_label_uses_event_start_date_for_dst(self): - """July New York event shows EDT, not EST.""" - event = self._make_event( - time_zone="America/New_York", - start_date="2026-07-10", - end_date="2026-07-10", - ) - event.insert() - self.assertEqual(event.time_zone_label, "EDT") - - def test_backfill_patch_skips_events_missing_start_fields(self): - """Legacy rows can have time_zone without start fields; patch must not abort.""" - event = self._make_event(time_zone="Asia/Kolkata") - event.insert() - frappe.db.set_value( - "Buzz Event", - event.name, - {"start_time": None, "time_zone_label": ""}, - update_modified=False, - ) - - backfill_time_zone_labels() - - self.assertEqual(frappe.db.get_value("Buzz Event", event.name, "time_zone_label"), "") - - -class TestBuzzEventZoomMeeting(FrappeTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - if not frappe.db.exists("Event Category", "Test Category"): - frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert( - ignore_permissions=True - ) - - def tearDown(self): - frappe.db.rollback() - - def _make_event(self): - return frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Meeting Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": "2026-08-01", - "end_date": "2026-08-01", - "start_time": "10:00:00", - "end_time": "11:00:00", - } - ).insert(ignore_permissions=True) - - def test_create_meeting_on_zoom_links_meeting_to_event(self): - from zoom_integration.tests.zoom_fixtures import create_meeting_response - - meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" - event = self._make_event() - response = create_meeting_response() - - with patch(f"{meeting_controller}.create_zoom_session", return_value=response): - meeting = event.create_meeting_on_zoom() - - self.assertTrue(meeting.name) - event.reload() - self.assertEqual(event.zoom_meeting, meeting.name) - self.assertEqual(meeting.zoom_meeting_id, str(response["id"])) - - def test_event_stores_the_zoom_meeting_id_the_desk_link_is_built_from(self): - """buzz_event.js builds https://zoom.us/meeting/ from this field.""" - from zoom_integration.tests.zoom_fixtures import create_meeting_response - - meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" - event = self._make_event() - response = create_meeting_response() - - with patch(f"{meeting_controller}.create_zoom_session", return_value=response): - event.create_meeting_on_zoom() - - event.reload() - self.assertEqual(event.zoom_meeting, str(response["id"])) - - def test_update_event_schedule_pushes_to_zoom_meeting(self): - from zoom_integration.tests.zoom_fixtures import CREATE_MEETING_RESPONSE - - meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" - event = self._make_event() - - with patch(f"{meeting_controller}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): - event.create_meeting_on_zoom() - - # Note: do not reload() — Time fields come back as timedelta and trip event - # validation's time diff. The in-memory doc keeps string times and has - # zoom_meeting set via db_set already. - with patch(f"{meeting_controller}.update_zoom_session") as mock_update: - event.end_time = "12:00:00" - event.save(ignore_permissions=True) - - mock_update.assert_called_once() - self.assertEqual(mock_update.call_args.args[0], "meetings") - - def test_webinar_template_comes_from_the_events_team(self): - webinar_controller = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar.ZoomWebinar" - event = self._make_event() - template = create_webinar_template() - set_team_settings(event.team, default_webinar_template=template) - self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", event.team) - - with patch(f"{webinar_controller}.create_webinar_on_zoom", autospec=True, side_effect=stub_zoom_id): - webinar = event.create_webinar_on_zoom() - - self.assertEqual(webinar.template, template) - - -class TestGuestVerificationConfig(FrappeTestCase): - """The method is called directly: it is the only validation under test, and the - `frappe.in_test` early return has to be lifted for any of it to run.""" - - def _event(self, method): - event = frappe.new_doc("Buzz Event") - event.allow_guest_booking = 1 - event.guest_verification_method = method - return event +class TestGuestVerificationConfig(BuzzEventTestCase): + """`frappe.in_test` is patched off: the check returns early in tests.""" def test_email_otp_needs_an_outgoing_account(self): with ( @@ -1240,7 +140,8 @@ def test_email_otp_needs_an_outgoing_account(self): patch("buzz.api.booking.guests.email_otp_available", return_value=False), ): self.assertRaises( - frappe.ValidationError, self._event("Email OTP").validate_guest_verification_config + frappe.ValidationError, + self.event_with_verification("Email OTP").validate_guest_verification_config, ) def test_phone_otp_needs_sms_a_guest_can_be_sent(self): @@ -1249,7 +150,8 @@ def test_phone_otp_needs_sms_a_guest_can_be_sent(self): patch("buzz.api.booking.guests.phone_otp_available", return_value=False), ): self.assertRaises( - frappe.ValidationError, self._event("Phone OTP").validate_guest_verification_config + frappe.ValidationError, + self.event_with_verification("Phone OTP").validate_guest_verification_config, ) def test_a_configured_site_passes(self): @@ -1257,8 +159,11 @@ def test_a_configured_site_passes(self): patch.object(frappe, "in_test", False), patch("buzz.api.booking.guests.phone_otp_available", return_value=True), ): - self._event("Phone OTP").validate_guest_verification_config() + self.event_with_verification("Phone OTP").validate_guest_verification_config() def test_none_needs_nothing_configured(self): with patch.object(frappe, "in_test", False): - self._event("None").validate_guest_verification_config() + self.event_with_verification("None").validate_guest_verification_config() + + def event_with_verification(self, method: str): + return self.build_event(allow_guest_booking=1, guest_verification_method=method) diff --git a/buzz/events/doctype/buzz_event/test_event_zoom.py b/buzz/events/doctype/buzz_event/test_event_zoom.py new file mode 100644 index 00000000..15d2e065 --- /dev/null +++ b/buzz/events/doctype/buzz_event/test_event_zoom.py @@ -0,0 +1,75 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory + +MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" +WEBINAR_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar.ZoomWebinar" + + +class TestBuzzEventZoomMeeting(IntegrationTestCase): + def setUp(self): + self.event = BuzzEventFactory.create() + + def test_create_meeting_on_zoom_links_meeting_to_event(self): + from zoom_integration.tests.zoom_fixtures import create_meeting_response + + response = create_meeting_response() + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=response): + meeting = self.event.create_meeting_on_zoom() + + self.event.reload() + self.assertEqual(self.event.zoom_meeting, meeting.name) + self.assertEqual(meeting.zoom_meeting_id, str(response["id"])) + + def test_event_stores_the_zoom_meeting_id_the_desk_link_is_built_from(self): + from zoom_integration.tests.zoom_fixtures import create_meeting_response + + response = create_meeting_response() + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=response): + self.event.create_meeting_on_zoom() + + self.event.reload() + self.assertEqual(self.event.zoom_meeting, str(response["id"])) + + def test_update_event_schedule_pushes_to_zoom_meeting(self): + from zoom_integration.tests.zoom_fixtures import CREATE_MEETING_RESPONSE + + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + self.event.create_meeting_on_zoom() + + # No reload(): Time fields come back as timedelta and fail the time check. + with patch(f"{MEETING_CONTROLLER}.update_zoom_session") as update_zoom_session: + self.event.end_time = "12:00:00" + self.event.save() + + update_zoom_session.assert_called_once() + self.assertEqual(update_zoom_session.call_args.args[0], "meetings") + + def test_webinar_template_comes_from_the_events_team(self): + template = self.create_webinar_template() + BuzzTeamFactory.set_settings(self.event.team, {"default_webinar_template": template}) + # Rollback restores the row but not its cached copy. + self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.event.team) + + with patch( + f"{WEBINAR_CONTROLLER}.create_webinar_on_zoom", + autospec=True, + side_effect=self.set_fake_webinar_id, + ): + webinar = self.event.create_webinar_on_zoom() + + self.assertEqual(webinar.template, template) + + def create_webinar_template(self) -> str: + """Zoom is an optional app, so Buzz owns no factory for its doctypes.""" + name = f"Webinar Template {frappe.generate_hash(length=6)}" + frappe.get_doc(doctype="Zoom Webinar Template", id=name, title=name, type=1).insert() + return name + + @staticmethod + def set_fake_webinar_id(webinar): + """Replaces the Zoom API call; validation needs the id.""" + webinar.zoom_webinar_id = "1234567890" diff --git a/buzz/events/doctype/buzz_event/test_registrations_closed.py b/buzz/events/doctype/buzz_event/test_registrations_closed.py new file mode 100644 index 00000000..9ab70cc7 --- /dev/null +++ b/buzz/events/doctype/buzz_event/test_registrations_closed.py @@ -0,0 +1,93 @@ +from datetime import datetime, time, timedelta, timezone +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, getdate, today + +from buzz.api.booking.services import are_registrations_closed + +# "Now" is mocked, so the day only has to be the same for the event and the clock. +EVENT_DATE = getdate(add_days(today(), 30)) +IST = timezone(timedelta(hours=5, minutes=30)) +PDT = timezone(timedelta(hours=-7)) + + +class TestRegistrationsClosed(IntegrationTestCase): + """`get_datetime_in_timezone` is mocked: it returns "now" already in the event's time zone.""" + + def test_no_close_at_and_event_in_future_returns_false(self): + event = self.event_dict(time_zone="UTC", event_date=str(add_days(EVENT_DATE, 5))) + self.assertFalse(self.is_closed(event, self.now_at("10:00"))) + + def test_no_close_at_falls_back_to_event_end(self): + event = self.event_dict(time_zone="UTC", end_time="18:00:00") + self.assertTrue(self.is_closed(event, self.now_at("20:00"))) + + def test_close_at_takes_priority_over_event_end(self): + event = self.event_dict( + registrations_close_at=self.close_at("20:00"), time_zone="UTC", end_time="18:00:00" + ) + self.assertFalse(self.is_closed(event, self.now_at("19:00"))) + + def test_future_close_at_returns_false(self): + event = self.event_dict(registrations_close_at=self.close_at("12:00"), time_zone="UTC") + self.assertFalse(self.is_closed(event, self.now_at("10:00"))) + + def test_past_close_at_returns_true(self): + event = self.event_dict(registrations_close_at=self.close_at("12:00"), time_zone="UTC") + self.assertTrue(self.is_closed(event, self.now_at("14:00"))) + + def test_timezone_ahead_of_utc_closes_earlier(self): + event = self.event_dict(registrations_close_at=self.close_at("18:00"), time_zone="Asia/Kolkata") + self.assertTrue(self.is_closed(event, self.now_at("19:30", IST))) + + def test_timezone_behind_utc_stays_open_longer(self): + event = self.event_dict(registrations_close_at=self.close_at("18:00"), time_zone="US/Pacific") + self.assertFalse(self.is_closed(event, self.now_at("16:00", PDT))) + + def test_same_close_time_different_timezones(self): + close_time = self.close_at("18:00") + event_ist = self.event_dict(registrations_close_at=close_time, time_zone="Asia/Kolkata") + event_pdt = self.event_dict(registrations_close_at=close_time, time_zone="US/Pacific") + + self.assertTrue(self.is_closed(event_ist, self.now_at("23:00", IST))) + self.assertFalse(self.is_closed(event_pdt, self.now_at("10:30", PDT))) + + def test_falls_back_to_system_timezone_when_event_tz_not_set(self): + event = self.event_dict(registrations_close_at=self.close_at("13:00"), time_zone=None) + self.assertTrue(self.is_closed(event, self.now_at("14:00"))) + + def test_closing_moment_is_same_absolute_instant_for_viewers_anywhere(self): + # Strictly greater-than: the exact closing instant is still open. + event = self.event_dict(registrations_close_at=self.close_at("16:30"), time_zone="Asia/Kolkata") + + self.assertFalse(self.is_closed(event, self.now_at("16:29", IST))) + self.assertFalse(self.is_closed(event, self.now_at("16:30", IST))) + self.assertTrue(self.is_closed(event, self.now_at("16:31", IST))) + + def test_event_end_fallback_is_also_timezone_consistent(self): + event = self.event_dict(time_zone="Asia/Kolkata", end_time="16:30:00") + + self.assertFalse(self.is_closed(event, self.now_at("16:29", IST))) + self.assertTrue(self.is_closed(event, self.now_at("16:31", IST))) + + def event_dict(self, registrations_close_at=None, time_zone=None, event_date=None, end_time="18:00:00"): + return frappe._dict( + registrations_close_at=registrations_close_at, + time_zone=time_zone, + start_date=event_date or str(EVENT_DATE), + start_time="09:00:00", + end_date=event_date or str(EVENT_DATE), + end_time=end_time, + ) + + def now_at(self, time_of_day: str, tzinfo: timezone | None = None) -> datetime: + return datetime.combine(EVENT_DATE, time.fromisoformat(time_of_day), tzinfo) + + def close_at(self, time_of_day: str) -> str: + return f"{EVENT_DATE} {time_of_day}:00" + + def is_closed(self, event, now: datetime) -> bool: + with patch("buzz.api.booking.services.get_datetime_in_timezone", return_value=now): + return are_registrations_closed(event) diff --git a/buzz/events/doctype/buzz_event/test_time_zone_label.py b/buzz/events/doctype/buzz_event/test_time_zone_label.py new file mode 100644 index 00000000..874b805a --- /dev/null +++ b/buzz/events/doctype/buzz_event/test_time_zone_label.py @@ -0,0 +1,94 @@ +from datetime import date, datetime, timezone + +import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.utils import getdate + +from buzz.patches.set_time_zone_label_for_existing_events import execute as backfill_time_zone_labels +from buzz.tests.factories import BuzzEventFactory +from buzz.utils import get_time_zone_label + +# The label is a pure function of its arguments, so a fixed date never expires. +REFERENCE_DATETIME = datetime(2026, 6, 15, 12, 0) + + +class TestTimeZoneLabel(UnitTestCase): + def test_tzdb_abbreviation_when_alphabetic(self): + self.assert_labels( + {"Asia/Kolkata": "IST", "Asia/Tokyo": "JST", "Africa/Nairobi": "EAT", "UTC": "UTC"} + ) + + def test_dst_variant_follows_reference_date(self): + winter = datetime(2026, 1, 15, 12, 0) + summer = datetime(2026, 7, 15, 12, 0) + self.assertEqual(get_time_zone_label("America/New_York", winter), "EST") + self.assertEqual(get_time_zone_label("America/New_York", summer), "EDT") + self.assertEqual(get_time_zone_label("Europe/Berlin", winter), "CET") + self.assertEqual(get_time_zone_label("Europe/Berlin", summer), "CEST") + + def test_curated_abbreviation_when_tzdb_is_numeric(self): + self.assert_labels( + {"Asia/Dubai": "GST", "Asia/Riyadh": "AST", "Asia/Bangkok": "ICT", "Asia/Kathmandu": "NPT"} + ) + + def test_gmt_offset_fallback_for_unmapped_zone(self): + self.assert_labels( + {"Asia/Thimphu": "GMT+6", "Asia/Yangon": "GMT+6:30", "Pacific/Marquesas": "GMT-9:30"} + ) + + def test_empty_or_invalid_time_zone_returns_empty(self): + self.assert_labels({None: "", "": "", "Not/A_Zone": ""}) + + def test_current_iana_names_for_renamed_zones(self): + self.assert_labels({"Asia/Ho_Chi_Minh": "ICT", "America/Nuuk": "WGT"}) + + def test_aware_reference_datetime_converted_not_reinterpreted(self): + # US DST ends 2026-11-01 06:00 UTC, so 05:30 UTC is still 01:30 EDT. + aware_reference = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc) + self.assertEqual(get_time_zone_label("America/New_York", aware_reference), "EDT") + + def assert_labels(self, expected: dict): + for time_zone, label in expected.items(): + with self.subTest(time_zone=time_zone): + self.assertEqual(get_time_zone_label(time_zone, REFERENCE_DATETIME), label) + + +class TestEventTimeZoneLabelField(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + event = BuzzEventFactory.create() + cls.event_links = {"team": event.team, "host": event.host, "category": event.category} + + def test_label_set_on_insert(self): + self.assertEqual(self.create_event(time_zone="Asia/Kolkata").time_zone_label, "IST") + + def test_label_updates_when_time_zone_changes(self): + event = self.create_event(time_zone="Asia/Kolkata") + event.time_zone = "Asia/Dubai" + event.save() + self.assertEqual(event.time_zone_label, "GST") + + def test_label_cleared_when_time_zone_removed(self): + event = self.create_event(time_zone="Asia/Kolkata") + event.time_zone = "" + event.save() + self.assertEqual(event.time_zone_label, "") + + def test_label_uses_event_start_date_for_dst(self): + next_july = str(date(getdate().year + 1, 7, 10)) + event = self.create_event(time_zone="America/New_York", start_date=next_july, end_date=next_july) + self.assertEqual(event.time_zone_label, "EDT") + + def test_backfill_patch_skips_events_missing_start_fields(self): + event = self.create_event(time_zone="Asia/Kolkata") + frappe.db.set_value( + "Buzz Event", event.name, {"start_time": None, "time_zone_label": ""}, update_modified=False + ) + + backfill_time_zone_labels() + + self.assertEqual(frappe.db.get_value("Buzz Event", event.name, "time_zone_label"), "") + + def create_event(self, **fields): + return BuzzEventFactory.create(**self.event_links, **fields) diff --git a/buzz/events/doctype/buzz_team/test_buzz_team.py b/buzz/events/doctype/buzz_team/test_buzz_team.py index 661c8811..c29a7563 100644 --- a/buzz/events/doctype/buzz_team/test_buzz_team.py +++ b/buzz/events/doctype/buzz_team/test_buzz_team.py @@ -6,41 +6,32 @@ from buzz.events.doctype.buzz_team.buzz_team import create_default_team_for from buzz.patches.assign_default_team import TEAM_DIRECT_DOCTYPES -from buzz.tests.factories import BuzzTeamFactory -from buzz.ticketing.doctype.event_ticket_type.event_ticket_type import PAID_EVENTS_FLAG +from buzz.tests.factories import ( + BuzzCampaignFactory, + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventHostFactory, + EventTemplateFactory, + EventVenueFactory, + UserFactory, +) OWNER = "team-owner@example.com" - - -def create_user(email: str, first_name: str) -> str: - if not frappe.db.exists("User", email): - frappe.get_doc( - { - "doctype": "User", - "email": email, - "first_name": first_name, - "send_welcome_email": 0, - } - ).insert(ignore_permissions=True) - return email +TEAM_DIRECT_FACTORIES = { + "Buzz Event": BuzzEventFactory, + "Event Venue": EventVenueFactory, + "Event Host": EventHostFactory, + "Event Template": EventTemplateFactory, + "Buzz Campaign": BuzzCampaignFactory, +} class TestBuzzTeam(IntegrationTestCase): - # Rollback is per class, not per test — every test needs its own team name and user. - def setUp(self): - frappe.set_user("Administrator") - create_user(OWNER, "Team") - - def create_team(self, team_name: str) -> "frappe.Document": - return frappe.get_doc({"doctype": "Buzz Team", "team_name": team_name}).insert() - - def owner_of(self, team: str) -> str | None: - return frappe.db.get_value("Buzz Team Membership", {"team": team, "team_role": "Owner"}, "user") - - def test_name_is_a_bteam_series(self): - team = self.create_team("Named Events") - - self.assertRegex(team.name, r"^BTEAM\d{4,}$") + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once(OWNER).name def test_slug_is_generated_from_team_name(self): team = self.create_team("Slug Events") @@ -48,21 +39,15 @@ def test_slug_is_generated_from_team_name(self): self.assertEqual(team.slug, "slug-events") def test_creating_user_becomes_the_owner(self): - frappe.set_user(OWNER) - self.addCleanup(frappe.set_user, "Administrator") - - team = frappe.get_doc({"doctype": "Buzz Team", "team_name": "Session Events"}).insert( - ignore_permissions=True - ) + with self.set_user(self.owner): + team = BuzzTeamFactory.create(flags={"ignore_permissions": True}) - self.assertEqual(self.owner_of(team.name), OWNER) + self.assertEqual(self.owner_of(team.name), self.owner) def test_owner_can_be_named_explicitly_for_another_user(self): - team = frappe.get_doc({"doctype": "Buzz Team", "team_name": "Explicit Events"}) - team.flags.owner_user = OWNER - team.insert() + team = BuzzTeamFactory.create(flags={"owner_user": self.owner}) - self.assertEqual(self.owner_of(team.name), OWNER) + self.assertEqual(self.owner_of(team.name), self.owner) def test_generated_slug_is_deduplicated(self): first = self.create_team("Duplicate Events") @@ -74,9 +59,7 @@ def test_generated_slug_is_deduplicated(self): def test_explicit_duplicate_slug_is_rejected(self): self.create_team("Taken Slug Events") - clashing = frappe.get_doc( - {"doctype": "Buzz Team", "team_name": "Other Events", "slug": "taken-slug-events"} - ) + clashing = BuzzTeamFactory.build(slug="taken-slug-events", flags={"owner_user": self.owner}) self.assertRaises(frappe.UniqueValidationError, clashing.insert) @@ -89,7 +72,7 @@ def test_slug_can_be_changed_after_insert(self): self.assertEqual(frappe.db.get_value("Buzz Team", team.name, "slug"), "renamed-conf") def test_default_team_is_named_after_the_user(self): - user = create_user("default-team@example.com", "Priya") + user = UserFactory.create(first_name="Priya").name team = create_default_team_for(user) @@ -97,7 +80,7 @@ def test_default_team_is_named_after_the_user(self): self.assertEqual(self.owner_of(team.name), user) def test_default_team_is_created_only_once(self): - user = create_user("default-team-twice@example.com", "Rohan") + user = UserFactory.create().name create_default_team_for(user) create_default_team_for(user) @@ -105,18 +88,14 @@ def test_default_team_is_created_only_once(self): self.assertEqual(frappe.db.count("Buzz Team Membership", {"user": user, "team_role": "Owner"}), 1) def test_default_teams_for_users_sharing_a_first_name_do_not_collide(self): - first = create_user("default-team-sam-one@example.com", "Sam") - second = create_user("default-team-sam-two@example.com", "Sam") - - create_default_team_for(first) - create_default_team_for(second) + first = create_default_team_for(UserFactory.create(first_name="Sam").name) + second = create_default_team_for(UserFactory.create(first_name="Sam").name) - self.assertEqual(frappe.db.count("Buzz Team", {"team_name": "Sam's Team"}), 2) + self.assertNotEqual(first.name, second.name) + self.assertEqual({first.team_name, second.team_name}, {"Sam's Team"}) def test_inserting_a_team_creates_one_owner_membership(self): - team = frappe.get_doc({"doctype": "Buzz Team", "team_name": "Membership Events"}) - team.flags.owner_user = OWNER - team.insert() + team = BuzzTeamFactory.create(flags={"owner_user": self.owner}) memberships = frappe.get_all( "Buzz Team Membership", @@ -125,94 +104,70 @@ def test_inserting_a_team_creates_one_owner_membership(self): ) self.assertEqual(len(memberships), 1) - self.assertEqual(memberships[0].user, OWNER) + self.assertEqual(memberships[0].user, self.owner) self.assertEqual(memberships[0].team_role, "Owner") self.assertEqual(memberships[0].enabled, 1) + def create_team(self, team_name: str): + return BuzzTeamFactory.create_owned_by(self.owner, team_name=team_name) -def create_owned_team(team_name: str, owner: str) -> str: - team = frappe.get_doc({"doctype": "Buzz Team", "team_name": team_name}) - team.flags.owner_user = owner - team.insert(ignore_permissions=True) - BuzzTeamFactory.set_feature(team.name, PAID_EVENTS_FLAG, 1) - return team.name - - -def payload_for(doctype: str, suffix: str) -> dict: - payloads = { - "Event Venue": {"venue_name": f"Venue {suffix}", "address": "somewhere"}, - "Event Host": {"host_name": f"Host {suffix}"}, - "Event Template": {"template_name": f"Template {suffix}"}, - "Buzz Campaign": {"name": f"Campaign {suffix}", "title": suffix, "description": "why"}, - "Buzz Event": { - "title": f"Event {suffix}", - "category": "Test Category", - "start_date": "2026-03-05", - "end_date": "2026-03-06", - "start_time": "09:00:00", - "end_time": "18:00:00", - }, - } - return {"doctype": doctype, **payloads[doctype]} + def owner_of(self, team: str) -> str | None: + return frappe.db.get_value("Buzz Team Membership", {"team": team, "team_role": "Owner"}, "user") class TestSetTeamFromSoleMembership(IntegrationTestCase): - # Rollback is per class, not per test — every test owns its user and its team names. - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def insert_as(self, user: str, doctype: str, suffix: str, **overrides): - frappe.set_user(user) - return frappe.get_doc({**payload_for(doctype, suffix), **overrides}).insert(ignore_permissions=True) - def test_stamps_the_users_only_team(self): - user = create_user("one-team@example.com", "Tenant") - team = create_owned_team("One Team", user) + user = UserFactory.create_once("one-team@example.com").name + team = BuzzTeamFactory.create_owned_by(user).name for doctype in TEAM_DIRECT_DOCTYPES: with self.subTest(doctype=doctype): - doc = self.insert_as(user, doctype, f"Only {doctype}") + doc = self.insert_as(user, self.build_without_team(doctype, team)) self.assertEqual(doc.team, team) def test_leaves_team_empty_when_the_user_has_two_teams(self): - user = create_user("two-teams@example.com", "Tenant") - create_owned_team("Two Teams A", user) - create_owned_team("Two Teams B", user) + user = UserFactory.create_once("two-teams@example.com").name + first = BuzzTeamFactory.create_owned_by(user).name + BuzzTeamFactory.create_owned_by(user) - doc = self.insert_as(user, "Event Venue", "Ambiguous") + doc = self.insert_as(user, self.build_without_team("Event Venue", first)) self.assertFalse(doc.team) def test_explicit_team_wins_for_a_multi_team_user(self): - user = create_user("picks-a-team@example.com", "Tenant") - create_owned_team("Picks A", user) - chosen = create_owned_team("Picks B", user) + user = UserFactory.create_once("picks-a-team@example.com").name + BuzzTeamFactory.create_owned_by(user) + chosen = BuzzTeamFactory.create_owned_by(user).name - doc = self.insert_as(user, "Event Venue", "Chosen", team=chosen) + doc = self.insert_as(user, EventVenueFactory.build(team=chosen)) self.assertEqual(doc.team, chosen) def test_explicit_team_is_never_overwritten(self): - user = create_user("single-team-override@example.com", "Tenant") - create_owned_team("Overridden Home", user) - other = create_owned_team("Overridden Other", create_user("other-owner@example.com", "Tenant")) + user = UserFactory.create_once("single-team-override@example.com").name + BuzzTeamFactory.create_owned_by(user) + other = BuzzTeamFactory.create_owned_by(UserFactory.create_once("other-owner@example.com").name).name - doc = self.insert_as(user, "Event Venue", "Override", team=other) + doc = self.insert_as(user, EventVenueFactory.build(team=other)) self.assertEqual(doc.team, other) def test_disabled_membership_does_not_count(self): - user = create_user("disabled-member@example.com", "Tenant") - enabled = create_owned_team("Disabled Enabled", user) - spare = create_owned_team("Disabled Spare", create_user("spare-owner@example.com", "Tenant")) - membership = frappe.get_doc( - {"doctype": "Buzz Team Membership", "team": spare, "user": user, "team_role": "Manager"} - ).insert(ignore_permissions=True) - membership.enabled = 0 - membership.save(ignore_permissions=True) + user = UserFactory.create_once("disabled-member@example.com").name + enabled = BuzzTeamFactory.create_owned_by(user).name + spare = BuzzTeamFactory.create_owned_by(UserFactory.create_once("spare-owner@example.com").name).name + BuzzTeamMembershipFactory.create(team=spare, user=user, team_role="Manager", enabled=0) - doc = self.insert_as(user, "Event Venue", "Disabled") + doc = self.insert_as(user, self.build_without_team("Event Venue", spare)) self.assertEqual(doc.team, enabled) + + def insert_as(self, user: str, doc): + with self.set_user(user): + return doc.insert(ignore_permissions=True) + + def build_without_team(self, doctype: str, team: str): + doc = TEAM_DIRECT_FACTORIES[doctype].build(team=team) + doc.team = None + return doc diff --git a/buzz/events/doctype/buzz_team/test_team_members.py b/buzz/events/doctype/buzz_team/test_team_members.py index 94fdb579..eeee4d7e 100644 --- a/buzz/events/doctype/buzz_team/test_team_members.py +++ b/buzz/events/doctype/buzz_team/test_team_members.py @@ -6,160 +6,149 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user +from buzz.tests.factories import BuzzTeamFactory, UserFactory OWNER = "add-members-owner@example.com" class TestAddMembers(IntegrationTestCase): def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - # Every added member is mailed, which needs an outgoing email account. + # Mailing needs an outgoing email account. self.sendmail = self.enterContext(patch("frappe.sendmail")) - self.team = frappe.get_doc( - "Buzz Team", create_owned_team(self.unique_name(), create_user(OWNER, "Owner")) - ) - - def unique_name(self) -> str: - return f"Members Team {frappe.generate_hash(length=6)}" - - def add(self, *members: tuple[str, str]) -> int: - payload = [{"user": user, "team_role": team_role} for user, team_role in members] - return self.team.add_members(frappe.as_json(payload)) - - def membership_of(self, user: str) -> dict | None: - return frappe.db.get_value( - "Buzz Team Membership", - {"team": self.team.name, "user": user}, - ["team_role", "enabled"], - as_dict=True, - ) - - def recipients(self) -> list[str]: - return [call.kwargs["recipients"] for call in self.sendmail.call_args_list] + self.team = BuzzTeamFactory.create_owned_by(UserFactory.create_once(OWNER).name) def test_members_are_added_with_the_roles_given(self): - manager = create_user("added-manager@example.com", "Manager") - frontdesk = create_user("added-frontdesk@example.com", "Frontdesk") + manager = UserFactory.create_once("added-manager@example.com").name + frontdesk = UserFactory.create_once("added-frontdesk@example.com").name - added = self.add((manager, "Manager"), (frontdesk, "Frontdesk")) + added = self.add_members((manager, "Manager"), (frontdesk, "Frontdesk")) self.assertEqual(added, 2) self.assertEqual(self.membership_of(manager), {"team_role": "Manager", "enabled": 1}) self.assertEqual(self.membership_of(frontdesk), {"team_role": "Frontdesk", "enabled": 1}) def test_every_added_member_is_mailed(self): - first = create_user("mailed-first@example.com", "First") - second = create_user("mailed-second@example.com", "Second") + first = UserFactory.create_once("mailed-first@example.com").name + second = UserFactory.create_once("mailed-second@example.com").name - self.add((first, "Manager"), (second, "Viewer")) + self.add_members((first, "Manager"), (second, "Viewer")) self.assertEqual(self.recipients(), [[first], [second]]) def test_an_added_manager_earns_the_event_manager_role(self): - user = create_user("added-earns-role@example.com", "Earner") + user = UserFactory.create_once("added-earns-role@example.com").name - self.add((user, "Manager")) + self.add_members((user, "Manager")) self.assertIn("Event Manager", frappe.get_roles(user)) def test_an_existing_member_is_left_alone_and_not_mailed(self): - user = create_user("already-a-member@example.com", "Member") - self.add((user, "Admin")) + user = UserFactory.create_once("already-a-member@example.com").name + self.add_members((user, "Admin")) self.sendmail.reset_mock() - added = self.add((user, "Viewer")) + added = self.add_members((user, "Viewer")) self.assertEqual(added, 0) self.assertEqual(self.membership_of(user), {"team_role": "Admin", "enabled": 1}) self.assertEqual(self.recipients(), []) def test_a_lapsed_membership_is_re_enabled_and_mailed(self): - user = create_user("lapsed-member@example.com", "Lapsed") - self.add((user, "Manager")) + user = UserFactory.create_once("lapsed-member@example.com").name + self.add_members((user, "Manager")) membership = frappe.get_doc("Buzz Team Membership", {"team": self.team.name, "user": user}) membership.enabled = 0 - membership.save(ignore_permissions=True) + membership.save() self.sendmail.reset_mock() - added = self.add((user, "Viewer")) + added = self.add_members((user, "Viewer")) self.assertEqual(added, 1) self.assertEqual(self.membership_of(user), {"team_role": "Viewer", "enabled": 1}) self.assertEqual(self.recipients(), [[user]]) def test_ownership_cannot_be_granted(self): - user = create_user("cannot-own@example.com", "Pretender") + user = UserFactory.create_once("cannot-own@example.com").name - self.assertRaises(frappe.ValidationError, self.add, (user, "Owner")) + self.assertRaises(frappe.ValidationError, self.add_members, (user, "Owner")) self.assertIsNone(self.membership_of(user)) def test_a_manager_cannot_add_members(self): - manager = create_user("manager-adding@example.com", "Manager") - self.add((manager, "Manager")) - outsider = create_user("manager-adds-this@example.com", "Outsider") - frappe.set_user(manager) + manager = UserFactory.create_once("manager-adding@example.com").name + self.add_members((manager, "Manager")) + outsider = UserFactory.create_once("manager-adds-this@example.com").name - self.assertRaises(frappe.PermissionError, self.add, (outsider, "Viewer")) + with self.set_user(manager): + self.assertRaises(frappe.PermissionError, self.add_members, (outsider, "Viewer")) - frappe.set_user("Administrator") self.assertIsNone(self.membership_of(outsider)) - def found(self, txt: str) -> list[str]: - return [row["value"] for row in self.team.search_addable_users(txt)] + def test_a_team_admin_can_add_members(self): + admin = UserFactory.create_once("admin-adding@example.com").name + self.add_members((admin, "Admin")) + newcomer = UserFactory.create_once("admin-adds-this@example.com").name + + with self.set_user(admin): + added = self.add_members((newcomer, "Viewer")) + + self.assertEqual(added, 1) + self.assertEqual(self.membership_of(newcomer), {"team_role": "Viewer", "enabled": 1}) def test_users_are_found_by_email(self): - user = create_user("searched-by-email@example.com", "Searched") + user = UserFactory.create_once("searched-by-email@example.com").name - self.assertIn(user, self.found("searched-by-email")) + self.assertIn(user, self.search_addable("searched-by-email")) def test_users_are_found_by_full_name(self): - user = create_user("found-by-name@example.com", "Priyanka") - frappe.db.set_value("User", user, "last_name", "Chatterjee") - frappe.db.set_value("User", user, "full_name", "Priyanka Chatterjee") + user = UserFactory.create(first_name="Priyanka", last_name="Chatterjee").name - self.assertIn(user, self.found("Priyanka Chatterjee")) + self.assertIn(user, self.search_addable("Priyanka Chatterjee")) def test_website_users_are_found(self): - user = create_user("website-user@example.com", "Attendee") + user = UserFactory.create_once("website-user@example.com").name frappe.db.set_value("User", user, "user_type", "Website User") - self.assertIn(user, self.found("website-user")) + self.assertIn(user, self.search_addable("website-user")) def test_existing_members_are_not_offered(self): - user = create_user("already-searchable@example.com", "Existing") - self.add((user, "Manager")) + user = UserFactory.create_once("already-searchable@example.com").name + self.add_members((user, "Manager")) - self.assertNotIn(user, self.found("already-searchable")) + self.assertNotIn(user, self.search_addable("already-searchable")) def test_disabled_users_are_not_offered(self): - user = create_user("disabled-searchable@example.com", "Disabled") + user = UserFactory.create_once("disabled-searchable@example.com").name frappe.db.set_value("User", user, "enabled", 0) self.addCleanup(frappe.db.set_value, "User", user, "enabled", 1) - self.assertNotIn(user, self.found("disabled-searchable")) + self.assertNotIn(user, self.search_addable("disabled-searchable")) def test_administrator_and_guest_are_not_offered(self): - self.assertNotIn("Administrator", self.found("Administrator")) - self.assertNotIn("Guest", self.found("Guest")) + self.assertNotIn("Administrator", self.search_addable("Administrator")) + self.assertNotIn("Guest", self.search_addable("Guest")) def test_a_manager_cannot_search(self): - manager = create_user("manager-searching@example.com", "Manager") - self.add((manager, "Manager")) - frappe.set_user(manager) + manager = UserFactory.create_once("manager-searching@example.com").name + self.add_members((manager, "Manager")) - self.assertRaises(frappe.PermissionError, self.team.search_addable_users, "anything") + with self.set_user(manager): + self.assertRaises(frappe.PermissionError, self.team.search_addable_users, "anything") - def test_a_team_admin_can_add_members(self): - admin = create_user("admin-adding@example.com", "Admin") - self.add((admin, "Admin")) - newcomer = create_user("admin-adds-this@example.com", "Newcomer") - frappe.set_user(admin) + def add_members(self, *members: tuple[str, str]) -> int: + payload = [{"user": user, "team_role": team_role} for user, team_role in members] + return self.team.add_members(frappe.as_json(payload)) - added = self.add((newcomer, "Viewer")) + def membership_of(self, user: str) -> dict | None: + return frappe.db.get_value( + "Buzz Team Membership", + {"team": self.team.name, "user": user}, + ["team_role", "enabled"], + as_dict=True, + ) - frappe.set_user("Administrator") - self.assertEqual(added, 1) - self.assertEqual(self.membership_of(newcomer), {"team_role": "Viewer", "enabled": 1}) + def recipients(self) -> list[str]: + return [call.kwargs["recipients"] for call in self.sendmail.call_args_list] + + def search_addable(self, txt: str) -> list[str]: + return [row["value"] for row in self.team.search_addable_users(txt)] diff --git a/buzz/events/doctype/buzz_team_membership/test_buzz_team_membership.py b/buzz/events/doctype/buzz_team_membership/test_buzz_team_membership.py index c5f0185e..301be9e1 100644 --- a/buzz/events/doctype/buzz_team_membership/test_buzz_team_membership.py +++ b/buzz/events/doctype/buzz_team_membership/test_buzz_team_membership.py @@ -7,67 +7,51 @@ from frappe.core.api.user_invitation import invite_by_email from frappe.tests import IntegrationTestCase -from buzz.events.doctype.buzz_team.test_buzz_team import create_user +from buzz.tests.factories import ( + BuzzTeamFactory, + BuzzTeamMembershipFactory, + UserFactory, + UserInvitationFactory, +) OWNER = "membership-owner@example.com" MEMBER = "membership-member@example.com" INVITER = "invite-owner@example.com" -def create_team(team_name: str, owner_user: str = OWNER) -> "frappe.Document": - team = frappe.get_doc({"doctype": "Buzz Team", "team_name": team_name}) - team.flags.owner_user = owner_user - return team.insert() - - -def add_member(team: str, user: str, team_role: str = "Manager") -> "frappe.Document": - return frappe.get_doc( - { - "doctype": "Buzz Team Membership", - "team": team, - "user": user, - "team_role": team_role, - } - ).insert() - - class TestBuzzTeamMembership(IntegrationTestCase): - # Rollback is per class, not per test — every test needs its own team name and user. - def setUp(self): - frappe.set_user("Administrator") - create_user(OWNER, "Owner") - create_user(MEMBER, "Member") + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once(OWNER).name + cls.member = UserFactory.create_once(MEMBER).name def test_manager_membership_grants_event_manager(self): - user = create_user("role-manager@example.com", "Manager") - team = create_team("Manager Role Team") + user = UserFactory.create_once("role-manager@example.com").name - add_member(team.name, user, "Manager") + self.add_member(self.create_team(), user, "Manager") self.assertIn("Event Manager", frappe.get_roles(user)) def test_membership_keeps_the_user_out_of_desk(self): - user = create_user("role-no-desk@example.com", "Portal") - team = create_team("No Desk Team") + user = UserFactory.create_once("role-no-desk@example.com").name - add_member(team.name, user, "Manager") + self.add_member(self.create_team(), user, "Manager") self.assertEqual(frappe.db.get_value("User", user, "user_type"), "Website User") def test_frontdesk_membership_grants_frontdesk_manager_only(self): - user = create_user("role-frontdesk@example.com", "Frontdesk") - team = create_team("Frontdesk Role Team") + user = UserFactory.create_once("role-frontdesk@example.com").name - add_member(team.name, user, "Frontdesk") + self.add_member(self.create_team(), user, "Frontdesk") roles = frappe.get_roles(user) self.assertIn("Frontdesk Manager", roles) self.assertNotIn("Event Manager", roles) def test_disabling_a_membership_revokes_its_role(self): - user = create_user("role-revoked@example.com", "Revoked") - team = create_team("Revoke Role Team") - membership = add_member(team.name, user, "Frontdesk") + user = UserFactory.create_once("role-revoked@example.com").name + membership = self.add_member(self.create_team(), user, "Frontdesk") membership.enabled = 0 membership.save() @@ -75,11 +59,9 @@ def test_disabling_a_membership_revokes_its_role(self): self.assertNotIn("Frontdesk Manager", frappe.get_roles(user)) def test_role_survives_while_another_team_still_earns_it(self): - user = create_user("role-two-teams@example.com", "Two Teams") - first = create_team("First Role Team") - second = create_team("Second Role Team") - membership = add_member(first.name, user, "Manager") - add_member(second.name, user, "Manager") + user = UserFactory.create_once("role-two-teams@example.com").name + membership = self.add_member(self.create_team(), user, "Manager") + self.add_member(self.create_team(), user, "Manager") membership.enabled = 0 membership.save() @@ -87,66 +69,50 @@ def test_role_survives_while_another_team_still_earns_it(self): self.assertIn("Event Manager", frappe.get_roles(user)) def test_deleting_a_membership_revokes_its_role(self): - user = create_user("role-deleted@example.com", "Deleted") - team = create_team("Delete Role Team") - membership = add_member(team.name, user, "Manager") + user = UserFactory.create_once("role-deleted@example.com").name + membership = self.add_member(self.create_team(), user, "Manager") membership.delete() self.assertNotIn("Event Manager", frappe.get_roles(user)) - def owner_membership(self, team: str) -> "frappe.Document": - return frappe.get_last_doc("Buzz Team Membership", filters={"team": team, "team_role": "Owner"}) - def test_owner_membership_cannot_be_disabled(self): - team = create_team("Owner Disable Team") - membership = self.owner_membership(team.name) + membership = self.owner_membership(self.create_team()) membership.enabled = 0 self.assertRaises(frappe.ValidationError, membership.save) def test_owner_membership_cannot_be_created_disabled(self): - user = create_user("owner-born-disabled@example.com", "Disabled Owner") - team = create_team("Disabled Owner Team") - - membership = frappe.get_doc( - { - "doctype": "Buzz Team Membership", - "team": team.name, - "user": user, - "team_role": "Owner", - "enabled": 0, - } + user = UserFactory.create_once("owner-born-disabled@example.com").name + + membership = BuzzTeamMembershipFactory.build( + team=self.create_team(), user=user, team_role="Owner", enabled=0 ) self.assertRaises(frappe.ValidationError, membership.insert) def test_owner_membership_cannot_be_demoted(self): - team = create_team("Owner Demote Team") - membership = self.owner_membership(team.name) + membership = self.owner_membership(self.create_team()) membership.team_role = "Viewer" self.assertRaises(frappe.ValidationError, membership.save) def test_owner_membership_cannot_be_deleted(self): - team = create_team("Owner Delete Team") - membership = self.owner_membership(team.name) + membership = self.owner_membership(self.create_team()) self.assertRaises(frappe.ValidationError, membership.delete) def test_non_owner_membership_can_be_deleted(self): - team = create_team("Member Delete Team") - membership = add_member(team.name, MEMBER) + membership = self.add_member(self.create_team(), self.member, "Manager") membership.delete() self.assertFalse(frappe.db.exists("Buzz Team Membership", membership.name)) def test_non_owner_membership_can_be_disabled(self): - team = create_team("Member Disable Team") - membership = add_member(team.name, MEMBER) + membership = self.add_member(self.create_team(), self.member, "Manager") membership.enabled = 0 membership.save() @@ -154,50 +120,29 @@ def test_non_owner_membership_can_be_disabled(self): self.assertEqual(frappe.db.get_value("Buzz Team Membership", membership.name, "enabled"), 0) def test_duplicate_membership_is_rejected(self): - team = create_team("Duplicate Membership Team") - add_member(team.name, MEMBER) - - duplicate = frappe.get_doc( - { - "doctype": "Buzz Team Membership", - "team": team.name, - "user": MEMBER, - "team_role": "Viewer", - } - ) + team = self.create_team() + self.add_member(team, self.member, "Manager") + + duplicate = BuzzTeamMembershipFactory.build(team=team, user=self.member, team_role="Viewer") self.assertRaises(frappe.ValidationError, duplicate.insert) + def create_team(self) -> str: + return BuzzTeamFactory.create_owned_by(self.owner).name + + def add_member(self, team: str, user: str, team_role: str): + return BuzzTeamMembershipFactory.create(team=team, user=user, team_role=team_role) + + def owner_membership(self, team: str): + return frappe.get_last_doc("Buzz Team Membership", filters={"team": team, "team_role": "Owner"}) + class TestInvitationAccept(IntegrationTestCase): def setUp(self): - frappe.set_user("Administrator") - # Every invitation mails itself out on insert, which needs an outgoing email account. + # Mailing needs an outgoing email account. self.enterContext(patch("frappe.sendmail")) - create_user(INVITER, "Inviter") - self.team = create_team(f"Invite Team {frappe.generate_hash(length=6)}", INVITER) - - def invite(self, email: str, team_role: str = "Manager", inviter: str = INVITER, team: str | None = None): - try: - frappe.set_user(inviter) - invite_by_email( - emails=email, - roles=["Buzz User"], - redirect_to_path="/dashboard", - app_name="buzz", - buzz_team=team or self.team.name, - buzz_team_role=team_role, - ) - finally: - frappe.set_user("Administrator") - return frappe.get_last_doc("User Invitation", filters={"email": email}) - - def memberships_of(self, user: str) -> list[dict]: - return frappe.get_all( - "Buzz Team Membership", - filters={"team": self.team.name, "user": user}, - fields=["name", "team_role", "enabled"], - ) + self.inviter = UserFactory.create_once(INVITER).name + self.team = BuzzTeamFactory.create_owned_by(self.inviter).name def test_invitation_persists_the_team_and_role(self): invitation = self.invite("invitee-persisted@example.com") @@ -206,7 +151,7 @@ def test_invitation_persists_the_team_and_role(self): "User Invitation", invitation.name, ["buzz_team", "buzz_team_role"], as_dict=True ) - self.assertEqual(stored.buzz_team, self.team.name) + self.assertEqual(stored.buzz_team, self.team) self.assertEqual(stored.buzz_team_role, "Manager") def test_accept_creates_an_enabled_membership_with_the_invited_role(self): @@ -226,20 +171,15 @@ def test_accept_works_for_a_logged_out_invitee(self): email = "invitee-guest@example.com" invitation = self.invite(email) - try: - frappe.set_user("Guest") + with self.set_user("Guest"): invitation.accept(ignore_permissions=True) - finally: - frappe.set_user("Administrator") self.assertEqual(len(self.memberships_of(email)), 1) self.assertIn("Event Manager", frappe.get_roles(email)) def test_accept_re_enables_a_disabled_membership(self): - email = create_user("invitee-disabled@example.com", "Disabled Invitee") - membership = add_member(self.team.name, email, "Viewer") - membership.enabled = 0 - membership.save() + email = UserFactory.create_once("invitee-disabled@example.com").name + BuzzTeamMembershipFactory.create(team=self.team, user=email, team_role="Viewer", enabled=0) invitation = self.invite(email, "Manager") invitation.accept(ignore_permissions=True) @@ -250,8 +190,8 @@ def test_accept_re_enables_a_disabled_membership(self): self.assertEqual(memberships[0].team_role, "Manager") def test_accept_for_an_enabled_member_changes_nothing(self): - email = create_user("invitee-member@example.com", "Existing Member") - add_member(self.team.name, email, "Viewer") + email = UserFactory.create_once("invitee-member@example.com").name + BuzzTeamMembershipFactory.create(team=self.team, user=email, team_role="Viewer") invitation = self.invite(email, "Manager") invitation.accept(ignore_permissions=True) @@ -261,8 +201,8 @@ def test_accept_for_an_enabled_member_changes_nothing(self): self.assertEqual(memberships[0].team_role, "Viewer") def test_accept_is_refused_when_the_inviter_is_not_owner_or_admin(self): - manager = create_user("invite-manager@example.com", "Inviting Manager") - add_member(self.team.name, manager, "Manager") + manager = UserFactory.create_once("invite-manager@example.com").name + BuzzTeamMembershipFactory.create(team=self.team, user=manager, team_role="Manager") email = "invitee-of-manager@example.com" invitation = self.invite(email, inviter=manager) @@ -271,10 +211,10 @@ def test_accept_is_refused_when_the_inviter_is_not_owner_or_admin(self): self.assertEqual(self.memberships_of(email), []) def test_accept_is_refused_when_the_inviter_left_the_team(self): - outsider = create_user("invite-outsider@example.com", "Outsider") - frappe.get_doc("User", outsider).add_roles("Event Manager") + outsider = UserFactory.create_once("invite-outsider@example.com") + outsider.add_roles("Event Manager") email = "invitee-of-outsider@example.com" - invitation = self.invite(email, inviter=outsider) + invitation = self.invite(email, inviter=outsider.name) self.assertRaises(frappe.ValidationError, invitation.accept, True) @@ -289,15 +229,7 @@ def test_accept_is_refused_when_the_invitation_grants_ownership(self): self.assertEqual(self.memberships_of(email), []) def test_accept_is_refused_when_the_invitation_names_no_team(self): - invitation = frappe.get_doc( - { - "doctype": "User Invitation", - "email": "invitee-teamless@example.com", - "roles": [{"role": "Buzz User"}], - "app_name": "buzz", - "redirect_to_path": "/dashboard", - } - ).insert(ignore_permissions=True) + invitation = UserInvitationFactory.create() self.assertRaises(frappe.ValidationError, invitation.accept, True) @@ -305,3 +237,24 @@ def test_invitation_to_an_unknown_team_is_rejected_at_invite_time(self): self.assertRaises( frappe.LinkValidationError, self.invite, "invitee-no-team@example.com", team="No Such Team" ) + + def invite( + self, email: str, team_role: str = "Manager", inviter: str | None = None, team: str | None = None + ): + with self.set_user(inviter or self.inviter): + invite_by_email( + emails=email, + roles=["Buzz User"], + redirect_to_path="/dashboard", + app_name="buzz", + buzz_team=team or self.team, + buzz_team_role=team_role, + ) + return frappe.get_last_doc("User Invitation", filters={"email": email}) + + def memberships_of(self, user: str) -> list[dict]: + return frappe.get_all( + "Buzz Team Membership", + filters={"team": self.team, "user": user}, + fields=["team_role", "enabled"], + ) diff --git a/buzz/events/doctype/buzz_team_settings/test_buzz_team_settings.py b/buzz/events/doctype/buzz_team_settings/test_buzz_team_settings.py index 2ea944c6..79e5a556 100644 --- a/buzz/events/doctype/buzz_team_settings/test_buzz_team_settings.py +++ b/buzz/events/doctype/buzz_team_settings/test_buzz_team_settings.py @@ -8,7 +8,6 @@ from frappe.tests import IntegrationTestCase from buzz.api.tickets.windows import ADD_ON_CHANGE, CANCELLATION, TRANSFER -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user from buzz.events.doctype.buzz_team_settings.buzz_team_settings import ( FEATURE_FLAG_PREFIX, SEEDED_FIELDS, @@ -17,68 +16,29 @@ get_team_settings, is_feature_enabled, ) -from buzz.tests.factories import BuzzTeamFactory, UserFactory +from buzz.tests.factories import BuzzTeamFactory, EmailTemplateFactory, UserFactory CUTOFF_FIELDS = (TRANSFER, ADD_ON_CHANGE, CANCELLATION) - - -def set_team_settings(team: str, **values): - """Write a team's settings the way a test reads them back.""" - frappe.db.set_value("Buzz Team Settings", team, values) - - -def create_webinar_template() -> str: - name = "Settings Webinar Template" - if not frappe.db.exists("Zoom Webinar Template", name): - frappe.get_doc({"doctype": "Zoom Webinar Template", "id": name, "title": name, "type": 1}).insert( - ignore_permissions=True - ) - return name - - -def create_email_template(subject: str) -> str: - name = f"Settings {subject}" - if not frappe.db.exists("Email Template", name): - frappe.get_doc( - {"doctype": "Email Template", "name": name, "subject": subject, "response": subject} - ).insert(ignore_permissions=True) - return name +TEST_FLAG = "feature_test_flag" class TestBuzzTeamSettings(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("settings-owner@example.com").name + cls.email_template = EmailTemplateFactory.create().name + def setUp(self): - frappe.set_user("Administrator") # The rollback restores the Single but not its cached copy. self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") - def set_globals(self, **values): - for fieldname, value in values.items(): - frappe.db.set_single_value("Buzz Settings", fieldname, value) - frappe.clear_document_cache("Buzz Settings", "Buzz Settings") - - def global_values(self) -> dict: - template = create_email_template("Seeded") - return { - "support_email": "seeded-support@example.com", - "allow_transfer_ticket_before_event_start_days": 3, - "allow_add_ons_change_before_event_start_days": 4, - "allow_ticket_cancellation_request_before_event_start_days": 5, - "default_ticket_email_template": template, - "default_booking_confirmation_email_template": template, - "auto_send_pitch_deck": 1, - "default_sponsor_deck_email_template": template, - "default_sponsor_deck_reply_to": "seeded-sponsors@example.com", - "default_sponsor_deck_cc": "seeded-cc@example.com", - } - def test_creating_a_team_seeds_its_settings_from_the_globals(self): values = self.global_values() self.set_globals(**values) - owner = create_user("settings-seed@example.com", "Seed") - team = create_owned_team("Settings Seed", owner) + settings = get_team_settings(self.create_team()) - settings = get_team_settings(team) for fieldname, value in values.items(): with self.subTest(fieldname=fieldname): self.assertEqual(settings.get(fieldname), value) @@ -89,11 +49,9 @@ def test_every_seeded_field_is_covered_by_the_seed_test(self): def test_cutoffs_left_blank_fall_back_to_seven_days(self): # Int columns are NOT NULL, so the doctype default is the only place a fallback can live. self.set_globals(**dict.fromkeys(CUTOFF_FIELDS, None)) - owner = create_user("settings-blank@example.com", "Blank") - team = create_owned_team("Settings Blank", owner) + settings = get_team_settings(self.create_team()) - settings = get_team_settings(team) for fieldname in CUTOFF_FIELDS: with self.subTest(fieldname=fieldname): self.assertEqual(settings.get(fieldname), 7) @@ -104,9 +62,8 @@ def test_webinar_template_is_seeded_when_zoom_integration_is_installed(self): webinar_template = create_webinar_template() self.set_globals(default_webinar_template=webinar_template) - owner = create_user("settings-zoom@example.com", "Zoom") - team = create_owned_team("Settings Zoom", owner) + team = self.create_team() # Without this the assertion below passes vacuously on None == None. self.assertTrue(frappe.get_meta("Buzz Team Settings").has_field(ZOOM_SEEDED_FIELD)) @@ -117,34 +74,50 @@ def test_patch_seeds_teams_that_predate_the_doctype_and_is_idempotent(self): values = self.global_values() self.set_globals(**values) - owner = create_user("settings-legacy@example.com", "Legacy") - team = create_owned_team("Settings Legacy", owner) - frappe.delete_doc("Buzz Team Settings", team, force=True, ignore_permissions=True) + team = self.create_team() + frappe.delete_doc("Buzz Team Settings", team, force=True) execute() execute() - settings = get_team_settings(team) - self.assertEqual(settings.support_email, values["support_email"]) + self.assertEqual(get_team_settings(team).support_email, values["support_email"]) def test_seeding_skips_the_webinar_template_without_zoom_integration(self): - owner = create_user("settings-no-zoom@example.com", "NoZoom") - with patch("frappe.get_installed_apps", return_value=["frappe", "buzz"]): - team = create_owned_team("Settings No Zoom", owner) + team = self.create_team() self.assertIsNone(get_team_settings(team).get(ZOOM_SEEDED_FIELD)) + def create_team(self) -> str: + return BuzzTeamFactory.create_owned_by(self.owner).name + + def set_globals(self, **values): + frappe.db.set_single_value("Buzz Settings", values) + frappe.clear_document_cache("Buzz Settings", "Buzz Settings") + + def global_values(self) -> dict: + return { + "support_email": "seeded-support@example.com", + "allow_transfer_ticket_before_event_start_days": 3, + "allow_add_ons_change_before_event_start_days": 4, + "allow_ticket_cancellation_request_before_event_start_days": 5, + "default_ticket_email_template": self.email_template, + "default_booking_confirmation_email_template": self.email_template, + "auto_send_pitch_deck": 1, + "default_sponsor_deck_email_template": self.email_template, + "default_sponsor_deck_reply_to": "seeded-sponsors@example.com", + "default_sponsor_deck_cc": "seeded-cc@example.com", + } + class TestTeamTaxDetails(IntegrationTestCase): - def setUp(self): - frappe.set_user("Administrator") - owner = create_user("team-tax-owner@example.com", "Owner") - self.settings = frappe.get_doc("Buzz Team Settings", create_owned_team("Team Tax Team", owner)) + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.owner = UserFactory.create_once("team-tax-owner@example.com").name - def save_tax_details(self, **values): - self.settings.update(values) - self.settings.save() + def setUp(self): + self.settings = frappe.get_doc("Buzz Team Settings", BuzzTeamFactory.create_owned_by(self.owner).name) def test_refuses_a_tax_id_without_the_rest(self): with self.assertRaises(frappe.ValidationError): @@ -178,8 +151,9 @@ def test_changing_the_tax_id_is_allowed(self): self.save_tax_details(tax_id="29BBBBB1111B1Z5") self.assertEqual(self.settings.tax_id, "29BBBBB1111B1Z5") - -TEST_FLAG = "feature_test_flag" + def save_tax_details(self, **values): + self.settings.update(values) + self.settings.save() class TestFeatureFlags(IntegrationTestCase): @@ -190,6 +164,7 @@ def setUpClass(cls): "Buzz Team Settings", {"fieldname": TEST_FLAG, "label": "Test Flag", "fieldtype": "Check", "permlevel": 1}, ) + cls.owner = UserFactory.create_once("feature-flag-owner@example.com").name @classmethod def tearDownClass(cls): @@ -198,17 +173,9 @@ def tearDownClass(cls): frappe.db.sql_ddl(f"alter table `tabBuzz Team Settings` drop column `{TEST_FLAG}`") def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - self.owner = UserFactory.create_once("feature-flag-owner@example.com").name self.team = BuzzTeamFactory.create_owned_by(self.owner).name self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.team) - def save_flag(self, value: int): - settings = frappe.get_doc("Buzz Team Settings", self.team) - settings.set(TEST_FLAG, value) - settings.save() - def test_flag_is_off_until_saved_on(self): self.assertFalse(is_feature_enabled(self.team, TEST_FLAG)) self.save_flag(1) @@ -220,8 +187,9 @@ def test_unknown_flag_throws(self): is_feature_enabled(self.team, "feature_does_not_exist") def test_team_owner_cannot_turn_a_flag_on(self): - frappe.set_user(self.owner) - self.save_flag(1) + with self.set_user(self.owner): + self.save_flag(1) + self.assertFalse(is_feature_enabled(self.team, TEST_FLAG)) def test_every_flag_is_a_restricted_check_field(self): @@ -229,3 +197,18 @@ def test_every_flag_is_a_restricted_check_field(self): if field.fieldname.startswith(FEATURE_FLAG_PREFIX): self.assertEqual(field.fieldtype, "Check", field.fieldname) self.assertGreaterEqual(field.permlevel, 1, field.fieldname) + + def save_flag(self, value: int): + settings = frappe.get_doc("Buzz Team Settings", self.team) + settings.set(TEST_FLAG, value) + settings.save() + + +def create_webinar_template() -> str: + # Zoom is an optional app, so Buzz owns no factory for its doctypes. + name = "Settings Webinar Template" + if not frappe.db.exists("Zoom Webinar Template", name): + frappe.get_doc({"doctype": "Zoom Webinar Template", "id": name, "title": name, "type": 1}).insert( + ignore_permissions=True + ) + return name diff --git a/buzz/events/doctype/buzz_theme/test_buzz_theme.py b/buzz/events/doctype/buzz_theme/test_buzz_theme.py index 3017e626..104f213c 100644 --- a/buzz/events/doctype/buzz_theme/test_buzz_theme.py +++ b/buzz/events/doctype/buzz_theme/test_buzz_theme.py @@ -1,72 +1,59 @@ from unittest.mock import patch import frappe -from frappe.model.document import Document from frappe.tests import IntegrationTestCase from buzz.events.doctype.buzz_theme.buzz_theme import resolve_theme, theme_css - - -def copy_of_classic(theme_name: str, **overrides) -> Document: - classic = frappe.get_doc("Buzz Theme", "Classic") - theme = frappe.copy_doc(classic) - theme.update({"theme_name": theme_name, "is_standard": 0, **overrides}) - return theme - - -def set_token(theme, token: str, value: str, token_type: str | None = None): - row = next(row for row in theme.tokens if row.token == token) - row.value = value - row.type = token_type or row.type +from buzz.tests.factories import BuzzThemeFactory class TestBuzzTheme(IntegrationTestCase): def test_copy_of_standard_theme_saves(self): - copy_of_classic("Copied Theme").insert() - css = theme_css("Copied Theme") + theme = BuzzThemeFactory.create() + css = theme_css(theme.name) self.assertIn("--accent: light-dark(#171717, #ffffff);", css) self.assertIn(":root[data-mode=light] { color-scheme: light; }", css) def test_missing_token_is_rejected(self): - theme = copy_of_classic("Missing Token Theme") + theme = BuzzThemeFactory.build() theme.tokens = [row for row in theme.tokens if row.token != "accent"] self.assertRaises(frappe.ValidationError, theme.insert) def test_value_that_breaks_out_of_the_rule_is_rejected(self): - theme = copy_of_classic("Injected Value Theme") + theme = BuzzThemeFactory.build() set_token(theme, "accent", "red; } body { display: none } .x {") self.assertRaises(frappe.ValidationError, theme.insert) def test_dark_value_that_breaks_out_of_the_rule_is_rejected(self): - theme = copy_of_classic("Injected Dark Theme") - next(row for row in theme.tokens if row.token == "accent").dark_value = "red; } body { x: y" + theme = BuzzThemeFactory.build() + token_row(theme, "accent").dark_value = "red; } body { x: y" self.assertRaises(frappe.ValidationError, theme.insert) def test_required_colour_needs_a_dark_value(self): - theme = copy_of_classic("No Dark Theme") - next(row for row in theme.tokens if row.token == "accent").dark_value = "" + theme = BuzzThemeFactory.build() + token_row(theme, "accent").dark_value = "" self.assertRaises(frappe.ValidationError, theme.insert) def test_token_name_is_restricted(self): - theme = copy_of_classic("Injected Name Theme") + theme = BuzzThemeFactory.build() theme.append("tokens", {"token": "x: red; } body {", "type": "Color", "value": "#fff"}) self.assertRaises(frappe.ValidationError, theme.insert) def test_font_must_come_from_the_allowlist(self): - theme = copy_of_classic("Free Font Theme") + theme = BuzzThemeFactory.build() set_token(theme, "font-body", "Comic Sans MS, cursive") self.assertRaises(frappe.ValidationError, theme.insert) def test_required_token_keeps_its_type(self): - theme = copy_of_classic("Wrong Type Theme") + theme = BuzzThemeFactory.build() set_token(theme, "radius", "#fff", "Color") self.assertRaises(frappe.ValidationError, theme.insert) def test_extra_tokens_are_allowed(self): - theme = copy_of_classic("Extra Token Theme") + theme = BuzzThemeFactory.build() theme.append("tokens", {"token": "highlight", "type": "Color", "value": "oklch(70% 0.1 200)"}) theme.insert() - self.assertIn("--highlight: oklch(70% 0.1 200);", theme_css("Extra Token Theme")) + self.assertIn("--highlight: oklch(70% 0.1 200);", theme_css(theme.name)) def test_standard_theme_is_read_only_outside_developer_mode(self): classic = frappe.get_doc("Buzz Theme", "Classic") @@ -74,15 +61,26 @@ def test_standard_theme_is_read_only_outside_developer_mode(self): self.assertRaises(frappe.ValidationError, classic.save) def test_rows_written_past_validation_never_render(self): - theme = copy_of_classic("Tampered Theme").insert() - row = next(row for row in theme.tokens if row.token == "accent") - frappe.db.set_value("Buzz Theme Token", row.name, "value", "red; } body { display: none") + theme = BuzzThemeFactory.create() + frappe.db.set_value( + "Buzz Theme Token", token_row(theme, "accent").name, "value", "red; } body { display: none" + ) theme_css.clear_cache() - css = theme_css("Tampered Theme") + css = theme_css(theme.name) self.assertNotIn("display: none", css) self.assertNotIn("--accent:", css) def test_resolve_theme_skips_disabled(self): - copy_of_classic("Disabled Theme", enabled=0).insert() - self.assertEqual(resolve_theme("Disabled Theme", "Paper"), "Paper") + theme = BuzzThemeFactory.create(enabled=0) + self.assertEqual(resolve_theme(theme.name, "Paper"), "Paper") self.assertEqual(resolve_theme(None, "No Such Theme"), "Classic") + + +def token_row(theme, token: str): + return next(row for row in theme.tokens if row.token == token) + + +def set_token(theme, token: str, value: str, token_type: str | None = None): + row = token_row(theme, token) + row.value = value + row.type = token_type or row.type diff --git a/buzz/events/doctype/event_host/test_event_host.py b/buzz/events/doctype/event_host/test_event_host.py index 10a768b4..259a672d 100644 --- a/buzz/events/doctype/event_host/test_event_host.py +++ b/buzz/events/doctype/event_host/test_event_host.py @@ -4,86 +4,48 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.forms.test_forms import ensure_prompt_named_record -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team from buzz.patches.backfill_event_co_hosts import execute as backfill_event_co_hosts from buzz.patches.backfill_event_host_names import execute as backfill_event_host_names - -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, EventHostFactory class IntegrationTestEventHost(IntegrationTestCase): - """ - Integration tests for EventHost. - Use this class for testing interactions between multiple components. - """ - - def make_legacy_host(self) -> str: - """A host from before `host_name` existed: the docname was the label.""" - host = frappe.get_doc({"doctype": "Event Host", "host_name": "Legacy Host"}).insert( - ignore_permissions=True - ) - frappe.db.set_value("Event Host", host.name, "host_name", "", update_modified=False) - return host.name - def test_backfill_names_a_host_that_has_none(self): - host = self.make_legacy_host() + host = EventHostFactory.create().name + # A host from before `host_name` existed: the docname was the label. + frappe.db.set_value("Event Host", host, "host_name", "", update_modified=False) backfill_event_host_names() self.assertEqual(frappe.db.get_value("Event Host", host, "host_name"), host) def test_backfill_leaves_a_named_host_alone(self): - host = frappe.get_doc({"doctype": "Event Host", "host_name": "Acme Corp"}).insert( - ignore_permissions=True - ) + host = EventHostFactory.create(host_name="Acme Corp").name backfill_event_host_names() - self.assertEqual(frappe.db.get_value("Event Host", host.name, "host_name"), "Acme Corp") - - def make_event(self, team: str, host: str) -> str: - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": f"Backfill Event {frappe.generate_hash(length=6)}", - "team": team, - "category": ensure_prompt_named_record("Event Category", "Test Category"), - "host": host, - "start_date": "2030-01-01", - "start_time": "09:00:00", - "end_time": "18:00:00", - "medium": "Online", - } - ) - event.insert(ignore_permissions=True) - return event.name + self.assertEqual(frappe.db.get_value("Event Host", host, "host_name"), "Acme Corp") def test_backfill_carries_a_legacy_host_into_the_co_host_table(self): - team = create_owned_team(f"Backfill Team {frappe.generate_hash(length=6)}", "Administrator") - host = frappe.get_doc({"doctype": "Event Host", "host_name": "Acme Corp", "team": team}).insert( - ignore_permissions=True - ) - event = self.make_event(team, host.name) - frappe.db.delete("Event CoHost", {"parent": event}) + team = BuzzTeamFactory.create_owned_by().name + host = EventHostFactory.create(team=team, host_name="Acme Corp").name + event = self.create_event_without_co_hosts(team, host) backfill_event_co_hosts() - self.assertEqual(frappe.get_all("Event CoHost", filters={"parent": event}, pluck="host"), [host.name]) + self.assertEqual(frappe.get_all("Event CoHost", filters={"parent": event}, pluck="host"), [host]) def test_backfill_skips_a_host_named_after_its_own_team(self): - name = f"Minted Team {frappe.generate_hash(length=6)}" - team = create_owned_team(name, "Administrator") - host = frappe.get_doc({"doctype": "Event Host", "host_name": name, "team": team}).insert( - ignore_permissions=True - ) - event = self.make_event(team, host.name) - frappe.db.delete("Event CoHost", {"parent": event}) + team_name = f"Minted Team {frappe.generate_hash(length=6)}" + team = BuzzTeamFactory.create_owned_by(team_name=team_name).name + host = EventHostFactory.create(team=team, host_name=team_name).name + event = self.create_event_without_co_hosts(team, host) backfill_event_co_hosts() self.assertEqual(frappe.get_all("Event CoHost", filters={"parent": event}, pluck="host"), []) + + def create_event_without_co_hosts(self, team: str, host: str) -> str: + event = str(BuzzEventFactory.create(team=team, host=host).name) + frappe.db.delete("Event CoHost", {"parent": event}) + return event diff --git a/buzz/events/doctype/event_sponsor/test_event_sponsor.py b/buzz/events/doctype/event_sponsor/test_event_sponsor.py index c4677185..9bc43f18 100644 --- a/buzz/events/doctype/event_sponsor/test_event_sponsor.py +++ b/buzz/events/doctype/event_sponsor/test_event_sponsor.py @@ -4,42 +4,15 @@ import frappe from frappe.tests import IntegrationTestCase -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +from buzz.tests.factories import SponsorshipEnquiryFactory, SponsorshipTierFactory class IntegrationTestEventSponsor(IntegrationTestCase): - """ - Integration tests for EventSponsor. - Use this class for testing interactions between multiple components. - """ + def test_paid_gateway_callback_lists_the_sponsor(self): + tier = SponsorshipTierFactory.create() + enquiry = SponsorshipEnquiryFactory.create(event=tier.event, tier=tier.name) - def test_enquiry_to_sponsor_flow(self): - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_sponsorship_tier = frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": test_event.name, - "title": "Super Platinum", - "prices": [{"currency": "INR", "price": 1000}], - } - ).insert() + enquiry.on_payment_authorized("Completed") - test_enquiry = frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": test_event.name, - "company_name": "Test Studios", - "company_logo": "https://buildwithhussain.com/files/youtube2.png", - "tier": test_sponsorship_tier.name, - } - ).insert() - - # "Payment Success trigger" - test_enquiry.on_payment_authorized("Completed") - self.assertEqual(test_enquiry.status, "Paid") - - self.assertTrue(frappe.db.exists("Event Sponsor", {"enquiry": test_enquiry.name})) + self.assertEqual(enquiry.status, "Paid") + self.assertTrue(frappe.db.exists("Event Sponsor", {"enquiry": enquiry.name})) diff --git a/buzz/events/doctype/event_template/test_event_template.py b/buzz/events/doctype/event_template/test_event_template.py index 368e6c04..fed230b4 100644 --- a/buzz/events/doctype/event_template/test_event_template.py +++ b/buzz/events/doctype/event_template/test_event_template.py @@ -2,497 +2,179 @@ # For license information, please see license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests import IntegrationTestCase +from frappe.utils import today -from buzz.api.forms.test_forms import ensure_event_host from buzz.events.doctype.buzz_event.buzz_event import create_from_template -from buzz.events.doctype.event_template.event_template import create_template_from_event - - -class TestEventTemplate(FrappeTestCase): +from buzz.tests.factories import BuzzTeamFactory, EventCategoryFactory, EventHostFactory, EventTemplateFactory + +DIRECT_FIELDS = { + "medium": "Online", + "about": "About text", + "short_description": "Short desc", + "time_zone": "Asia/Kolkata", + "allow_guest_booking": 1, + "guest_verification_method": "Email OTP", + "send_ticket_email": 1, + "apply_tax": 1, + "tax_label": "GST", + "tax_percentage": 18, +} +SPONSORSHIP_FIELDS = { + "auto_send_pitch_deck": 1, + "sponsor_deck_reply_to": "test@example.com", + "sponsor_deck_cc": "cc@example.com", +} +CUSTOM_FIELD = { + "label": "Company", + "fieldname": "company", + "fieldtype": "Data", + "applied_to": "Booking", + "mandatory": 1, + "enabled": 1, + "placeholder": "Enter company name", +} + + +class TestCreateEventFromTemplate(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.create_test_fixtures() - - @classmethod - def create_test_fixtures(cls): - """Create required test data: Event Category, Host, etc.""" - # Create Event Category if not exists - if not frappe.db.exists("Event Category", "Test Category"): - frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert( - ignore_permissions=True - ) - - def tearDown(self): - """Clean up test data after each test""" - frappe.db.rollback() - - # ==================== Template Creation Tests ==================== - - def test_create_template_basic(self): - """Test creating a basic Event Template""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Test Webinar Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "medium": "Online", - "about": "Test description", - } - ) - template.insert() - - self.assertEqual(template.template_name, "Test Webinar Template") - self.assertEqual(template.category, "Test Category") - self.assertEqual(template.medium, "Online") - - def test_create_template_with_ticket_types(self): - """Test creating a template with ticket types""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Template with Tickets", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_ticket_types": [ - { - "title": "Early Bird", - "price": 100, - "currency": "INR", - "is_published": 1, - "max_tickets_available": 50, - }, - {"title": "Regular", "price": 200, "currency": "INR", "is_published": 1}, - ], - } - ) - template.insert() - - self.assertEqual(len(template.template_ticket_types), 2) - self.assertEqual(template.template_ticket_types[0].title, "Early Bird") - self.assertEqual(template.template_ticket_types[0].price, 100) - - def test_create_template_with_add_ons(self): - """Test creating a template with add-ons""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Template with Add-ons", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_add_ons": [ - {"title": "T-Shirt", "price": 500, "currency": "INR", "enabled": 1}, - { - "title": "Lunch", - "price": 300, - "currency": "INR", - "user_selects_option": 1, - "options": "Veg\nNon-Veg", - "enabled": 1, - }, - ], - } - ) - template.insert() - - self.assertEqual(len(template.template_add_ons), 2) - self.assertEqual(template.template_add_ons[1].user_selects_option, 1) - - def test_create_template_with_custom_fields(self): - """Test creating a template with custom fields""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Template with Custom Fields", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_custom_fields": [ - { - "label": "Company Name", - "fieldname": "company_name", - "fieldtype": "Data", - "applied_to": "Booking", - "mandatory": 1, - "enabled": 1, - }, - { - "label": "Dietary Preference", - "fieldname": "dietary_preference", - "fieldtype": "Select", - "options": "Veg\nNon-Veg\nVegan", - "applied_to": "Ticket", - "enabled": 1, - }, - ], - } - ) - template.insert() - - self.assertEqual(len(template.template_custom_fields), 2) - self.assertEqual(template.template_custom_fields[0].mandatory, 1) - - # ==================== Create Event from Template Tests ==================== - - def test_create_event_from_template_all_options(self): - """Test creating an event from template with all options selected""" - # Create template - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Full Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "medium": "Online", - "about": "Template about text", - "apply_tax": 1, - "tax_label": "GST", - "tax_percentage": 18, - "template_ticket_types": [ - {"title": "Standard", "price": 500, "currency": "INR", "is_published": 1} - ], - "template_add_ons": [{"title": "Workshop", "price": 1000, "currency": "INR", "enabled": 1}], - "template_custom_fields": [ - { - "label": "Phone", - "fieldname": "phone", - "fieldtype": "Phone", - "applied_to": "Booking", - "enabled": 1, - } - ], - } - ) - template.insert() - - # Create event from template with all options - options = { - "category": 1, - "host": 1, - "medium": 1, - "about": 1, - "apply_tax": 1, - "tax_label": 1, - "tax_percentage": 1, - "ticket_types": 1, - "add_ons": 1, - "custom_fields": 1, - } + cls.team = BuzzTeamFactory.create_owned_by().name + cls.category = EventCategoryFactory.create().name + cls.host = EventHostFactory.create(team=cls.team).name - event_name = create_from_template(template.name, frappe.as_json(options)) - event = frappe.get_doc("Buzz Event", event_name) + def test_copies_direct_fields(self): + template = self.create_template(**DIRECT_FIELDS) - # Verify event fields - self.assertEqual(event.category, "Test Category") - self.assertEqual(event.host, ensure_event_host("Test Host")) - self.assertEqual(event.medium, "Online") - self.assertEqual(event.about, "Template about text") - self.assertEqual(event.apply_tax, 1) - self.assertEqual(event.tax_percentage, 18) + event = self.create_event(template, dict.fromkeys(["category", "host", *DIRECT_FIELDS], 1)) - # Verify ticket types created (excluding default "Normal" ticket type) - ticket_types = frappe.get_all( - "Event Ticket Type", filters={"event": event_name, "title": "Standard"}, fields=["title"] - ) - self.assertEqual(len(ticket_types), 1) - self.assertEqual(ticket_types[0].title, "Standard") + self.assertEqual((event.category, event.host), (self.category, self.host)) + for field, value in DIRECT_FIELDS.items(): + with self.subTest(field): + self.assertEqual(event.get(field), value) - # Verify add-ons created - add_ons = frappe.get_all("Ticket Add-on", filters={"event": event_name}, fields=["title", "price"]) - self.assertEqual(len(add_ons), 1) - self.assertEqual(add_ons[0].title, "Workshop") + def test_respects_unselected_options(self): + template = self.create_template(medium="In Person", about="Should not appear", apply_tax=1) - # Verify custom fields created - custom_fields = frappe.get_all( - "Buzz Custom Field", filters={"event": event_name}, fields=["label", "fieldtype"] + event = self.create_event( + template, {"category": 1, "host": 1, "medium": 0, "about": 0, "apply_tax": 0} ) - self.assertEqual(len(custom_fields), 1) - self.assertEqual(custom_fields[0].fieldtype, "Phone") - - def test_create_event_from_template_partial_options(self): - """Test creating an event with only some options selected""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Partial Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "medium": "In Person", - "about": "Should not be copied", - "template_ticket_types": [ - {"title": "VIP", "price": 2000, "currency": "INR", "is_published": 1} - ], - } - ) - template.insert() - - # Copy category, host (required) and ticket types, but not medium/about - options = {"category": 1, "host": 1, "medium": 0, "about": 0, "ticket_types": 1} - event_name = create_from_template(template.name, frappe.as_json(options)) - event = frappe.get_doc("Buzz Event", event_name) + self.assertEqual((event.category, event.host), (self.category, self.host)) + self.assertFalse(event.about) + self.assertFalse(event.apply_tax) - # Category should be copied - self.assertEqual(event.category, "Test Category") + def test_additional_fields_override_the_template(self): + category = EventCategoryFactory.create().name + template = self.create_template() - # Host should be copied (it's mandatory) - self.assertEqual(event.host, ensure_event_host("Test Host")) + event = self.create_event(template, {"host": 1}, category=category) - # About should NOT be copied - self.assertFalse(event.about) + self.assertEqual((event.category, event.host), (category, self.host)) - # Ticket types should be copied - ticket_types = frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "VIP"}) - self.assertEqual(len(ticket_types), 1) - - def test_create_event_from_template_no_linked_docs(self): - """Test creating an event without copying linked documents""" - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "No Linked Docs Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "template_ticket_types": [ - {"title": "General", "price": 100, "currency": "INR", "is_published": 1} - ], - } - ) - template.insert() + def test_creates_ticket_types(self): + early_bird = { + "title": "Early Bird", + "price": 500, + "currency": "INR", + "max_tickets_available": 100, + "is_published": 1, + } + regular = {"title": "Regular", "price": 1000, "currency": "INR", "is_published": 1} + template = self.create_template(template_ticket_types=[early_bird, regular]) - # Copy fields but not linked docs - options = {"category": 1, "host": 1, "ticket_types": 0, "add_ons": 0, "custom_fields": 0} + event = self.create_event(template, {"category": 1, "host": 1, "ticket_types": 1}) - event_name = create_from_template(template.name, frappe.as_json(options)) - - # Event fields should be copied - event = frappe.get_doc("Buzz Event", event_name) - self.assertEqual(event.category, "Test Category") - - # No "General" ticket type should be created (only default "Normal") - ticket_types = frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "General"}) - self.assertEqual(len(ticket_types), 0) - - # ==================== Save as Template Tests ==================== - - def test_save_event_as_template(self): - """Test saving an existing event as a template""" - # Create an event with ticket types and add-ons - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Source Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "medium": "Online", - "about": "Event description", - } + ticket_types = frappe.get_all( + "Event Ticket Type", + filters={"event": event.name, "title": ["in", ["Early Bird", "Regular"]]}, + fields=["title", {"prices": ["price"]}, "max_tickets_available"], + order_by="title", ) - event.insert() - - # Create ticket type for the event - ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": event.name, - "title": "Premium", - "prices": [{"currency": "INR", "price": 1500}], - "is_published": 1, - } + self.assertEqual( + [(row.title, row.prices[0].price) for row in ticket_types], + [("Early Bird", 500), ("Regular", 1000)], ) - ticket_type.insert() - - # Create add-on for the event - add_on = frappe.get_doc( - { - "doctype": "Ticket Add-on", - "event": event.name, - "title": "Swag Kit", - "price": 500, - "currency": "INR", - "enabled": 1, - } + self.assertEqual(ticket_types[0].max_tickets_available, 100) + + def test_creates_add_ons(self): + add_on = {"title": "Workshop Access", "price": 2000, "currency": "INR", "enabled": 1} + template = self.create_template( + template_add_ons=[{**add_on, "user_selects_option": 1, "options": "Morning\nAfternoon"}] ) - add_on.insert() - # Save as template (convert event.name to string as it's an int autoname) - options = {"category": 1, "host": 1, "medium": 1, "about": 1, "ticket_types": 1, "add_ons": 1} + event = self.create_event(template, {"category": 1, "host": 1, "add_ons": 1}) - template_name = create_template_from_event( - str(event.name), "My Event Template", frappe.as_json(options) + add_ons = frappe.get_all( + "Ticket Add-on", + filters={"event": event.name}, + fields=["title", "price", "user_selects_option", "options"], ) - template = frappe.get_doc("Event Template", template_name) - - # Verify template fields - self.assertEqual(template.template_name, "My Event Template") - self.assertEqual(template.category, "Test Category") - self.assertEqual(template.medium, "Online") - - # Verify ticket types in template (excluding default "Normal") - premium_tickets = [t for t in template.template_ticket_types if t.title == "Premium"] - self.assertEqual(len(premium_tickets), 1) - self.assertEqual(premium_tickets[0].price, 1500) - - # Verify add-ons in template - self.assertEqual(len(template.template_add_ons), 1) - self.assertEqual(template.template_add_ons[0].title, "Swag Kit") - - def test_save_event_as_template_partial(self): - """Test saving event as template with only some options""" - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Partial Source Event", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "medium": "In Person", - "about": "Should be copied", - "apply_tax": 1, - "tax_percentage": 18, - } + self.assertEqual( + [(row.title, row.price, row.user_selects_option, row.options) for row in add_ons], + [("Workshop Access", 2000, 1, "Morning\nAfternoon")], ) - event.insert() - # Only save category and about (convert event.name to string as it's an int autoname) - options = {"category": 1, "host": 0, "medium": 0, "about": 1, "apply_tax": 0} + def test_creates_custom_fields(self): + template = self.create_template(template_custom_fields=[CUSTOM_FIELD]) - template_name = create_template_from_event( - str(event.name), "Partial Template 2", frappe.as_json(options) - ) - template = frappe.get_doc("Event Template", template_name) - - self.assertEqual(template.category, "Test Category") - self.assertEqual(template.about, "Should be copied") - self.assertFalse(template.host) - self.assertFalse(template.apply_tax) - - # ==================== Round Trip Tests ==================== - - def test_round_trip_event_to_template_to_event(self): - """Test full round trip: Event -> Template -> New Event""" - # Step 1: Create original event - original_event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": "Original Conference", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - "start_date": frappe.utils.today(), - "start_time": "09:00:00", - "end_time": "18:00:00", - "medium": "In Person", - "about": "Annual conference description", - "apply_tax": 1, - "tax_label": "GST", - "tax_percentage": 18, - } + event = self.create_event(template, {"category": 1, "host": 1, "custom_fields": 1}) + + custom_fields = frappe.get_all( + "Buzz Custom Field", + filters={"event": event.name}, + fields=["label", "fieldtype", "mandatory", "placeholder"], ) - original_event.insert() - - # Add ticket types - for ticket_data in [ - {"title": "Early Bird", "price": 1000}, - {"title": "Regular", "price": 1500}, - {"title": "VIP", "price": 3000}, - ]: - frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": original_event.name, - "title": ticket_data["title"], - "prices": [{"currency": "INR", "price": ticket_data["price"]}], - "is_published": 1, - } - ).insert() - - # Step 2: Save as template (convert event.name to string as it's an int autoname) - template_options = { - "category": 1, - "host": 1, - "medium": 1, - "about": 1, - "apply_tax": 1, - "tax_label": 1, - "tax_percentage": 1, - "ticket_types": 1, - } - template_name = create_template_from_event( - str(original_event.name), "Conference Template", frappe.as_json(template_options) + self.assertEqual( + [(row.label, row.fieldtype, row.mandatory, row.placeholder) for row in custom_fields], + [("Company", "Data", 1, "Enter company name")], ) - # Step 3: Create new event from template - event_options = { - "category": 1, - "host": 1, - "medium": 1, - "about": 1, - "apply_tax": 1, - "tax_label": 1, - "tax_percentage": 1, - "ticket_types": 1, - } - new_event_name = create_from_template(template_name, frappe.as_json(event_options)) - new_event = frappe.get_doc("Buzz Event", new_event_name) - - # Verify new event matches original - self.assertEqual(new_event.category, original_event.category) - self.assertEqual(new_event.host, original_event.host) - self.assertEqual(new_event.medium, original_event.medium) - self.assertEqual(new_event.about, original_event.about) - self.assertEqual(new_event.tax_percentage, original_event.tax_percentage) - - # Verify ticket types match (excluding default "Normal") - new_ticket_types = frappe.get_all( - "Event Ticket Type", - filters={"event": new_event_name, "title": ["in", ["Early Bird", "Regular", "VIP"]]}, - fields=["title", {"prices": ["price"]}], - order_by="title", - ) - self.assertEqual(len(new_ticket_types), 3) - self.assertEqual(new_ticket_types[0].title, "Early Bird") - self.assertEqual(new_ticket_types[0].prices[0].price, 1000) - - # ==================== Edge Case Tests ==================== - - def test_create_event_empty_template(self): - """Test creating event from template with minimal data""" - # Template with required fields for Buzz Event (category and host are mandatory) - template = frappe.get_doc( - { - "doctype": "Event Template", - "template_name": "Empty Template", - "category": "Test Category", - "host": ensure_event_host("Test Host"), - } + def test_skips_linked_docs_when_unselected(self): + template = self.create_template( + template_ticket_types=[{"title": "Skipped", "price": 100, "currency": "INR"}], + template_add_ons=[{"title": "Skipped Addon", "price": 50, "currency": "INR", "enabled": 1}], + template_custom_fields=[CUSTOM_FIELD], ) - template.insert() + options = {"category": 1, "host": 1, "ticket_types": 0, "add_ons": 0, "custom_fields": 0} + + event = self.create_event(template, options) + + self.assertFalse(frappe.db.exists("Event Ticket Type", {"event": event.name, "title": "Skipped"})) + self.assertFalse(frappe.db.exists("Ticket Add-on", {"event": event.name})) + self.assertFalse(frappe.db.exists("Buzz Custom Field", {"event": event.name})) - options = {"category": 1, "host": 1} - event_name = create_from_template(template.name, frappe.as_json(options)) + def test_sets_default_title_and_date(self): + template_name = f"Defaults Template {frappe.generate_hash(length=6)}" + template = self.create_template(template_name=template_name) - # Should create event without errors - self.assertTrue(frappe.db.exists("Buzz Event", event_name)) + event = self.create_event(template, {"category": 1, "host": 1}) - def test_template_name_required(self): - """Test that template_name is required""" - template = frappe.get_doc({"doctype": "Event Template", "category": "Test Category"}) + self.assertIn(template_name, event.title) + self.assertEqual(str(event.start_date), today()) - # Template uses autoname: field:template_name, so it raises ValidationError not MandatoryError - with self.assertRaises(frappe.exceptions.ValidationError): - template.insert() + def test_copies_sponsorship_settings(self): + template = self.create_template(**SPONSORSHIP_FIELDS) - def test_duplicate_template_name(self): - """Test handling of duplicate template names""" - frappe.get_doc({"doctype": "Event Template", "template_name": "Duplicate Name"}).insert() + event = self.create_event(template, dict.fromkeys(["category", "host", *SPONSORSHIP_FIELDS], 1)) - duplicate = frappe.get_doc({"doctype": "Event Template", "template_name": "Duplicate Name"}) + for field, value in SPONSORSHIP_FIELDS.items(): + with self.subTest(field): + self.assertEqual(event.get(field), value) - with self.assertRaises(frappe.exceptions.DuplicateEntryError): - duplicate.insert() + def test_requires_template_read_permission(self): + template = self.create_template() + + with self.set_user("Guest"), self.assertRaises(frappe.ValidationError): + create_from_template(template.name, frappe.as_json({"category": 1, "host": 1})) + + def create_template(self, **fields): + return EventTemplateFactory.create(team=self.team, category=self.category, host=self.host, **fields) + + def create_event(self, template, options: dict, **additional_fields): + # Team is not a template option; without it the event takes the user's team. + additional_fields = frappe.as_json({"team": self.team, **additional_fields}) + return frappe.get_doc( + "Buzz Event", create_from_template(template.name, frappe.as_json(options), additional_fields) + ) diff --git a/buzz/events/doctype/event_template/test_save_as_template.py b/buzz/events/doctype/event_template/test_save_as_template.py new file mode 100644 index 00000000..dfe64270 --- /dev/null +++ b/buzz/events/doctype/event_template/test_save_as_template.py @@ -0,0 +1,90 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.events.doctype.buzz_event.buzz_event import create_from_template +from buzz.events.doctype.event_template.event_template import create_template_from_event +from buzz.tests.factories import ( + BuzzCustomFieldFactory, + BuzzEventFactory, + EventTicketTypeFactory, + TicketAddOnFactory, +) + +EVENT_FIELDS = { + "medium": "Online", + "about": "Full event description", + "tax_label": "GST", + "tax_percentage": 18, +} +ALL_OPTIONS = dict.fromkeys(["category", "host", "apply_tax", "ticket_types", *EVENT_FIELDS], 1) + + +class TestSaveEventAsTemplate(IntegrationTestCase): + def test_saves_every_selected_option(self): + event = self.create_event_with_linked_docs() + + template = self.save_as_template(event, {**ALL_OPTIONS, "add_ons": 1, "custom_fields": 1}) + + self.assertEqual((template.category, template.host), (event.category, event.host)) + self.assertEqual(template.apply_tax, 1) + for field, value in EVENT_FIELDS.items(): + with self.subTest(field): + self.assertEqual(template.get(field), value) + # The event's default "Normal" ticket type is saved too. + self.assertEqual([row.price for row in template.template_ticket_types if row.title == "Gold"], [5000]) + self.assertEqual([row.title for row in template.template_add_ons], ["Parking"]) + self.assertEqual([row.label for row in template.template_custom_fields], ["Designation"]) + + def test_saves_only_the_selected_options(self): + event = BuzzEventFactory.create(medium="In Person", about="Included", apply_tax=1, tax_percentage=18) + + template = self.save_as_template(event, {"category": 1, "about": 1, "medium": 0, "apply_tax": 0}) + + self.assertEqual((template.category, template.about), (event.category, "Included")) + self.assertFalse(template.host) + self.assertFalse(template.apply_tax) + + def test_round_trip_preserves_data(self): + original = BuzzEventFactory.create(apply_tax=1, **{**EVENT_FIELDS, "tax_label": "Service Tax"}) + platinum = {"title": "Platinum", "prices": [{"currency": "INR", "price": 10000}]} + EventTicketTypeFactory.create(event=original.name, max_tickets_available=25, **platinum) + + template = self.save_as_template(original, ALL_OPTIONS) + team = frappe.as_json({"team": original.team}) + new_event = create_from_template(template.name, frappe.as_json(ALL_OPTIONS), team) + + copied = frappe.get_doc("Buzz Event", new_event) + for field in ("category", "host", "medium", "about", "tax_label", "tax_percentage"): + with self.subTest(field): + self.assertEqual(copied.get(field), original.get(field)) + self.assertEqual(self.ticket_type_values(new_event, "Platinum"), [(10000, 25)]) + + def test_requires_template_create_permission(self): + event = BuzzEventFactory.create() + + with self.set_user("Guest"), self.assertRaises(frappe.ValidationError): + self.save_as_template(event, {"category": 1}) + + def create_event_with_linked_docs(self): + event = BuzzEventFactory.create(apply_tax=1, **EVENT_FIELDS) + EventTicketTypeFactory.create( + event=event.name, title="Gold", prices=[{"currency": "INR", "price": 5000}] + ) + TicketAddOnFactory.create(event=event.name, title="Parking", price=200) + BuzzCustomFieldFactory.create(event=event.name, label="Designation", fieldname="designation") + return event + + def save_as_template(self, event, options: dict): + # The event's team is not copied: the template takes Administrator's sole team. + name = f"Saved Template {frappe.generate_hash(length=6)}" + return frappe.get_doc( + "Event Template", create_template_from_event(str(event.name), name, frappe.as_json(options)) + ) + + def ticket_type_values(self, event: str, title: str) -> list[tuple]: + ticket_types = frappe.get_all( + "Event Ticket Type", + filters={"event": event, "title": title}, + fields=[{"prices": ["price"]}, "max_tickets_available"], + ) + return [(row.prices[0].price, row.max_tickets_available) for row in ticket_types] diff --git a/buzz/events/doctype/event_venue/test_event_venue.py b/buzz/events/doctype/event_venue/test_event_venue.py index a10b98f2..95f1cd73 100644 --- a/buzz/events/doctype/event_venue/test_event_venue.py +++ b/buzz/events/doctype/event_venue/test_event_venue.py @@ -5,56 +5,47 @@ import frappe import requests -from frappe.tests import IntegrationTestCase +from frappe.tests import IntegrationTestCase, UnitTestCase -from buzz.api.events.test_events import create_event -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user from buzz.events.doctype.event_venue.map_link import ( - MAP_LINK_CACHE_PREFIX, SHORT_LINK_DIGITS, coordinates_of, read_map_link, ) from buzz.patches.set_event_venue_name import execute as set_event_venue_name +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, EventVenueFactory +from buzz.tests.utils import clear_map_link_cache class IntegrationTestEventVenue(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.team = create_owned_team("Venue Naming Team", create_user("venue-naming@example.com", "Owner")) - - def create_venue(self, venue_name: str): - return frappe.get_doc( - { - "doctype": "Event Venue", - "venue_name": venue_name, - "address": "1 Test Street", - "team": self.team, - } - ).insert(ignore_permissions=True) + cls.team = BuzzTeamFactory.create_owned_by().name def test_name_is_random_and_venue_name_is_the_label(self): - venue = self.create_venue("Town Hall") + venue = EventVenueFactory.create(team=self.team, venue_name="Town Hall") self.assertNotEqual(venue.name, "Town Hall") self.assertEqual(venue.get_title(), "Town Hall") def test_two_venues_can_share_a_venue_name(self): - self.assertNotEqual(self.create_venue("Shared Hall").name, self.create_venue("Shared Hall").name) + first = EventVenueFactory.create(team=self.team, venue_name="Shared Hall") + second = EventVenueFactory.create(team=self.team, venue_name="Shared Hall") + self.assertNotEqual(first.name, second.name) def test_event_reads_the_current_venue_name(self): - venue = self.create_venue("Old Hall") - event = frappe.get_doc("Buzz Event", create_event("Venue Name Event", self.team, venue=venue.name)) + venue = EventVenueFactory.create(team=self.team, venue_name="Old Hall") + event = BuzzEventFactory.create("in_person", team=self.team, venue=venue.name) self.assertEqual(event.get_venue_name(), "Old Hall") venue.venue_name = "New Hall" - venue.save(ignore_permissions=True) + venue.save() self.assertEqual(event.get_venue_name(), "New Hall") def test_patch_copies_the_old_docname_into_venue_name(self): - venue = self.create_venue("Legacy Hall") + venue = EventVenueFactory.create(team=self.team) frappe.db.set_value("Event Venue", venue.name, "venue_name", "", update_modified=False) set_event_venue_name() @@ -80,11 +71,7 @@ def open_street_map_short_code(latitude: float, longitude: float, zoom: int) -> return code + "-" * ((zoom + 8) % 3) -def clear_map_link_cache(): - frappe.cache.delete_keys(MAP_LINK_CACHE_PREFIX) - - -class TestMapLinkCoordinates(IntegrationTestCase): +class TestMapLinkCoordinates(UnitTestCase): def setUp(self): clear_map_link_cache() @@ -177,27 +164,32 @@ def test_other_hosts_are_never_requested(self, get): class TestVenueMapLink(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.team = BuzzTeamFactory.create_owned_by().name + def setUp(self): clear_map_link_cache() - def venue(self, **fields): - return frappe.get_doc({"doctype": "Event Venue", "venue_name": "Linked Hall", **fields}) - def test_map_link_sets_the_location_and_makes_address_optional(self): - venue = self.venue(map_link=GOOGLE_PLACE_LINK).insert(ignore_permissions=True) + venue = self.create_venue(map_link=GOOGLE_PLACE_LINK) self.assertEqual((venue.type, venue.latitude, venue.longitude), ("Open Street Map", 18.9903, 72.8174)) self.assertFalse(venue.address) def test_pasted_embed_code_becomes_the_google_embed(self): embed = '' - venue = self.venue(map_link=embed).insert(ignore_permissions=True) + venue = self.create_venue(map_link=embed) self.assertEqual(venue.type, "Embed Google Maps") self.assertIn("maps/embed?pb=abc", venue.google_maps_embed_code) def test_address_is_required_when_the_link_shows_no_location(self): with self.assertRaises(frappe.ValidationError): - self.venue(map_link="https://www.openstreetmap.org/user/someone").insert(ignore_permissions=True) + self.create_venue(map_link="https://www.openstreetmap.org/user/someone") with self.assertRaises(frappe.ValidationError): - self.venue().insert(ignore_permissions=True) + self.create_venue() + + def create_venue(self, **fields): + return EventVenueFactory.create(team=self.team, address="", **fields) diff --git a/buzz/events/doctype/offline_payment_method/test_offline_payment_method.py b/buzz/events/doctype/offline_payment_method/test_offline_payment_method.py index 46327e71..b15fb63c 100644 --- a/buzz/events/doctype/offline_payment_method/test_offline_payment_method.py +++ b/buzz/events/doctype/offline_payment_method/test_offline_payment_method.py @@ -4,58 +4,21 @@ import frappe from frappe.tests import IntegrationTestCase +from buzz.tests.factories import BuzzEventFactory, OfflinePaymentMethodFactory -class TestOfflinePaymentMethod(IntegrationTestCase): - def setUp(self): - # Clean up any leftover Offline Payment Method records from previous tests - frappe.db.delete("Offline Payment Method") +class TestOfflinePaymentMethod(IntegrationTestCase): def test_unique_title_per_event(self): - """Test that two methods with the same title cannot exist for the same event.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() + event = BuzzEventFactory.create().name + OfflinePaymentMethodFactory.create(event=event, title="Bank Transfer") with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() + OfflinePaymentMethodFactory.create(event=event, title="Bank Transfer") def test_same_title_different_events(self): - """Test that two methods with the same title can exist for different events.""" - events = frappe.get_all("Buzz Event", limit=2, pluck="name") - if len(events) < 2: - self.skipTest("Need at least 2 events") - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "UPI Payment", - "event": events[0], - "enabled": 1, - } - ).insert() - - # Should not raise - method2 = frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "UPI Payment", - "event": events[1], - "enabled": 1, - } - ).insert() - - self.assertTrue(method2.name) + first, second = BuzzEventFactory.create_list(2) + OfflinePaymentMethodFactory.create(event=first.name, title="UPI Payment") + + method = OfflinePaymentMethodFactory.create(event=second.name, title="UPI Payment") + + self.assertEqual(method.title, "UPI Payment") diff --git a/buzz/events/doctype/sponsor_enquiry_form/test_sponsor_enquiry_form.py b/buzz/events/doctype/sponsor_enquiry_form/test_sponsor_enquiry_form.py index dd2badbd..ba00c15d 100644 --- a/buzz/events/doctype/sponsor_enquiry_form/test_sponsor_enquiry_form.py +++ b/buzz/events/doctype/sponsor_enquiry_form/test_sponsor_enquiry_form.py @@ -2,31 +2,24 @@ from frappe.tests import IntegrationTestCase from frappe.utils import add_days, now_datetime -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.test_permissions import add_member, create_event +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, BuzzTeamMembershipFactory, UserFactory class TestSponsorEnquiryFormClosing(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + owner = UserFactory.create_once("form-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(owner).name + cls.manager = cls.add_member("form-manager@example.com", cls.team, "Manager") + cls.viewer = cls.add_member("form-viewer@example.com", cls.team, "Viewer") + cls.frontdesk = cls.add_member("form-frontdesk@example.com", cls.team, "Frontdesk") + other_team = BuzzTeamFactory.create_owned_by().name + cls.other_team_manager = cls.add_member("form-other-manager@example.com", other_team, "Manager") + def setUp(self): - frappe.set_user("Administrator") - suffix = frappe.generate_hash(length=6) - self.owner = create_user(f"form-owner-{suffix}@example.com", "Owner") - self.team = create_owned_team(f"Form Team {suffix}", self.owner) - self.event = create_event(f"Form {suffix}", self.team, is_published=1) + self.event = str(BuzzEventFactory.create(team=self.team).name) self.form = self.form_of(self.event) - self.suffix = suffix - - def tearDown(self): - frappe.set_user("Administrator") - frappe.db.rollback() - - def form_of(self, event: str): - return frappe.get_doc("Sponsor Enquiry Form", {"event": event}) - - def member(self, team_role: str, team: str | None = None) -> str: - user = create_user(f"{team_role.lower()}-{frappe.generate_hash(length=6)}@example.com", team_role) - add_member(team or self.team, user, team_role) - return user def test_closing_and_opening_round_trips(self): self.assertFalse(self.form.set_closed(False)) @@ -58,24 +51,29 @@ def test_archived_event_stays_closed(self): form.set_closed(False) def test_team_manager_may_toggle(self): - frappe.set_user(self.member("Manager")) - - self.assertTrue(self.form_of(self.event).set_closed(True)) + with self.set_user(self.manager): + self.assertTrue(self.form_of(self.event).set_closed(True)) def test_outsiders_cannot_write(self): - other_owner = create_user(f"other-owner-{self.suffix}@example.com", "Other") - other_team = create_owned_team(f"Other Team {self.suffix}", other_owner) outsiders = { - "viewer": self.member("Viewer"), - "frontdesk": self.member("Frontdesk"), - "other team manager": self.member("Manager", other_team), + "viewer": self.viewer, + "frontdesk": self.frontdesk, + "other team manager": self.other_team_manager, "guest": "Guest", } for label, user in outsiders.items(): - with self.subTest(label): - frappe.set_user(user) + with self.subTest(label), self.set_user(user): form = frappe.get_doc("Sponsor Enquiry Form", self.form.name, check_permission=False) with self.assertRaises(frappe.PermissionError): form.check_permission("write") with self.assertRaises(frappe.PermissionError): form.set_closed(True) + + @classmethod + def add_member(cls, email: str, team: str, team_role: str) -> str: + user = UserFactory.create_once(email).name + BuzzTeamMembershipFactory.create(team=team, user=user, team_role=team_role) + return user + + def form_of(self, event: str): + return frappe.get_doc("Sponsor Enquiry Form", {"event": event}) diff --git a/buzz/events/doctype/sponsorship_tier/test_sponsorship_tier.py b/buzz/events/doctype/sponsorship_tier/test_sponsorship_tier.py index d24fe565..353c6a87 100644 --- a/buzz/events/doctype/sponsorship_tier/test_sponsorship_tier.py +++ b/buzz/events/doctype/sponsorship_tier/test_sponsorship_tier.py @@ -1,78 +1,63 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.test_permissions import add_member, create_event +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + SponsorshipTierFactory, + UserFactory, +) class TestSponsorshipTier(IntegrationTestCase): - def setUp(self): - frappe.set_user("Administrator") - suffix = frappe.generate_hash(length=6) - self.owner = create_user(f"tier-owner-{suffix}@example.com", "Owner") - self.team = create_owned_team(f"Tier Team {suffix}", self.owner) - self.event = create_event(f"Tier Event {suffix}", self.team, is_published=1) - self.suffix = suffix - - def tearDown(self): - frappe.set_user("Administrator") - frappe.db.rollback() - - def member(self, team_role: str, team: str | None = None) -> str: - user = create_user(f"{team_role.lower()}-{frappe.generate_hash(length=6)}@example.com", team_role) - add_member(team or self.team, user, team_role) - return user - - def new_tier(self, event: str | None = None) -> frappe.Document: - return frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": event or self.event, - "title": f"Gold {frappe.generate_hash(length=6)}", - "prices": [{"currency": "INR", "price": 1000}], - } - ) + @classmethod + def setUpClass(cls): + super().setUpClass() + owner = UserFactory.create_once("tier-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(owner).name + cls.event = BuzzEventFactory.create(team=cls.team).name + cls.manager = cls.add_member("tier-manager@example.com", cls.team, "Manager") + cls.viewer = cls.add_member("tier-viewer@example.com", cls.team, "Viewer") + other_team = BuzzTeamFactory.create_owned_by().name + cls.other_team_manager = cls.add_member("tier-other-manager@example.com", other_team, "Manager") def test_manager_can_insert_update_and_disable(self): - frappe.set_user(self.member("Manager")) + with self.set_user(self.manager): + tier = SponsorshipTierFactory.create(event=self.event) + tier.prices[0].price = 2000 + tier.save() + tier.enabled = 0 + tier.save() - tier = self.new_tier().insert() - tier.prices[0].price = 2000 - tier.save() - tier.enabled = 0 - tier.save() - - self.assertFalse(frappe.get_doc("Sponsorship Tier", tier.name).enabled) + self.assertEqual(frappe.db.get_value("Sponsorship Tier", tier.name, "enabled"), 0) def test_member_of_another_team_is_refused(self): - other_owner = create_user(f"other-owner-{self.suffix}@example.com", "Other") - other_team = create_owned_team(f"Other Tier Team {self.suffix}", other_owner) - - frappe.set_user(self.member("Manager", other_team)) - with self.assertRaises(frappe.PermissionError): - self.new_tier().insert() + with self.set_user(self.other_team_manager), self.assertRaises(frappe.PermissionError): + SponsorshipTierFactory.create(event=self.event) def test_viewer_is_refused(self): - frappe.set_user(self.member("Viewer")) - with self.assertRaises(frappe.PermissionError): - self.new_tier().insert() + with self.set_user(self.viewer), self.assertRaises(frappe.PermissionError): + SponsorshipTierFactory.create(event=self.event) def test_event_cannot_change_on_a_saved_tier(self): - other_event = create_event(f"Other Event {self.suffix}", self.team, is_published=1) - tier = self.new_tier().insert(ignore_permissions=True) + tier = SponsorshipTierFactory.create(event=self.event) - tier.event = other_event + tier.event = BuzzEventFactory.create(team=self.team).name with self.assertRaises(frappe.CannotChangeConstantError): - tier.save(ignore_permissions=True) + tier.save() def test_tier_needs_at_least_one_price(self): - tier = self.new_tier() - tier.prices = [] with self.assertRaises(frappe.ValidationError): - tier.insert(ignore_permissions=True) + SponsorshipTierFactory.create(event=self.event, prices=[]) def test_currency_cannot_repeat(self): - tier = self.new_tier() - tier.append("prices", {"currency": "INR", "price": 500}) + prices = [{"currency": "INR", "price": 1000}, {"currency": "INR", "price": 500}] with self.assertRaises(frappe.ValidationError): - tier.insert(ignore_permissions=True) + SponsorshipTierFactory.create(event=self.event, prices=prices) + + @classmethod + def add_member(cls, email: str, team: str, team_role: str) -> str: + user = UserFactory.create_once(email).name + BuzzTeamMembershipFactory.create(team=team, user=user, team_role=team_role) + return user diff --git a/buzz/events/test_og_image.py b/buzz/events/test_og_image.py index f8b90ba9..805329d1 100644 --- a/buzz/events/test_og_image.py +++ b/buzz/events/test_og_image.py @@ -2,39 +2,23 @@ from unittest.mock import patch import frappe -from frappe.tests import IntegrationTestCase +from frappe.tests import IntegrationTestCase, UnitTestCase from PIL import Image -from buzz.api.events.test_events import create_event from buzz.events.banner_pattern import banner_pattern -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.events.doctype.buzz_theme.test_buzz_theme import copy_of_classic from buzz.events.og_image import EventOgImage, generate, theme_colours +from buzz.tests.factories import BuzzEventFactory, BuzzThemeFactory, FileFactory from buzz.www.event.index import EventPage -def og_files(event: str) -> list: - return frappe.get_all( - "File", filters={"attached_to_name": event, "attached_to_field": "og_image"}, pluck="file_url" - ) - - class TestEventOgImage(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - frappe.set_user("Administrator") - cls.team = create_owned_team("Og Image Team", create_user("og-image-owner@example.com", "Owner")) - def setUp(self): - self.event = create_event("Og Image", self.team, route="og-image-event", is_published=1) + self.event = str(BuzzEventFactory.create().name) def tearDown(self): + # Removes the rendered files from disk too, which a rollback leaves behind frappe.delete_doc("Buzz Event", self.event, force=True) - def og_image(self) -> str: - return frappe.db.get_value("Buzz Event", self.event, "og_image") - def test_renders_a_share_sized_png(self): content = EventOgImage(frappe.get_doc("Buzz Event", self.event)).render() self.assertEqual(Image.open(io.BytesIO(content)).size, (1200, 630)) @@ -43,7 +27,7 @@ def test_unchanged_event_keeps_its_image(self): generate(self.event) first = self.og_image() generate(self.event) - self.assertEqual((self.og_image(), og_files(self.event)), (first, [first])) + self.assertEqual((self.og_image(), self.og_files()), (first, [first])) def test_changed_event_replaces_its_image(self): generate(self.event) @@ -51,7 +35,7 @@ def test_changed_event_replaces_its_image(self): frappe.db.set_value("Buzz Event", self.event, "title", "Renamed") generate(self.event) self.assertNotEqual(self.og_image(), first) - self.assertEqual(og_files(self.event), [self.og_image()]) + self.assertEqual(self.og_files(), [self.og_image()]) def test_unpublished_event_is_not_rendered(self): frappe.db.set_value("Buzz Event", self.event, "is_published", 0) @@ -63,7 +47,7 @@ def test_undrawable_title_clears_the_image(self): frappe.db.set_value("Buzz Event", self.event, "title", "फ्रैपे यात्रा") generate(self.event) self.assertIsNone(self.og_image()) - self.assertEqual(og_files(self.event), []) + self.assertEqual(self.og_files(), []) def test_emoji_title_is_rendered_in_colour(self): frappe.db.set_value("Buzz Event", self.event, "title", "👀") @@ -84,34 +68,38 @@ def test_event_page_shares_the_generated_image(self): "Buzz Event", self.event, {"meta_image": "", "banner_image": "", "card_image": ""} ) generate(self.event) - meta = EventPage("og-image-event").as_context()["meta"] + route = frappe.db.get_value("Buzz Event", self.event, "route") + meta = EventPage(route).as_context()["meta"] self.assertTrue(meta["image"].endswith(self.og_image())) def test_transparent_banner_sits_on_the_page_colour(self): banner = io.BytesIO() Image.new("RGBA", (1200, 400), (0, 0, 0, 0)).save(banner, "PNG") - file = frappe.get_doc( - { - "doctype": "File", - "file_name": "clear-banner.png", - "is_private": 0, - "content": banner.getvalue(), - } - ).insert() + file = FileFactory.create(file_name="clear-banner.png", content=banner.getvalue()) frappe.db.set_value("Buzz Event", self.event, "banner_image", file.file_url) image = EventOgImage(frappe.get_doc("Buzz Event", self.event)) corner = Image.open(io.BytesIO(image.render())).getpixel((0, 0)) self.assertEqual("#%02x%02x%02x" % corner[:3], image.colours["page-bg"]) def test_css_only_colour_falls_back_to_the_scheme(self): - theme = copy_of_classic("Oklch Og Theme") + theme = BuzzThemeFactory.build() row = next(row for row in theme.tokens if row.token == "page-bg") row.value = row.dark_value = "oklch(20% 0.02 250)" theme.insert() self.assertTrue(theme_colours(theme.name)["page-bg"].startswith("#")) + def og_image(self) -> str: + return frappe.db.get_value("Buzz Event", self.event, "og_image") + + def og_files(self) -> list: + return frappe.get_all( + "File", + filters={"attached_to_name": self.event, "attached_to_field": "og_image"}, + pluck="file_url", + ) + -class TestBannerPattern(IntegrationTestCase): +class TestBannerPattern(UnitTestCase): def test_matches_the_page_script(self): # Values produced by bannerPattern() in event_banner.ts expected = { diff --git a/buzz/install.py b/buzz/install.py index 811a9f70..5a309a79 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -130,47 +130,12 @@ def before_tests(): def setup_test_records(): create_talk_proposal_statuses() - # Administrator's only membership, so the team resolves for fixtures that omit one. + # Administrator's only membership: doctypes that fill `team` from a sole membership + # (Event Template, Talk Proposal submissions) resolve to it in tests. admin_team = create_default_team_for("Administrator").name - # The shared test event sells paid tickets, so its team needs the flag. frappe.db.set_value("Buzz Team Settings", admin_team, PAID_EVENTS_FLAG, 1) frappe.clear_document_cache("Buzz Team Settings", admin_team) - test_category = frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert( - ignore_if_duplicate=True - ) - test_venue = ( - frappe.db.exists("Event Venue", {"venue_name": "Test Venue"}) - or frappe.get_doc( - {"doctype": "Event Venue", "venue_name": "Test Venue", "address": "test", "team": admin_team} - ) - .insert() - .name - ) - test_host = frappe.get_doc( - {"doctype": "Event Host", "host_name": "Test Host", "team": admin_team} - ).insert(ignore_if_duplicate=True) - - test_event_exists = frappe.db.exists("Buzz Event", {"route": "test-route"}) - if test_event_exists: - frappe.delete_doc("Buzz Event", test_event_exists, force=True) - test_event = frappe.get_doc( - { - "doctype": "Buzz Event", - "team": admin_team, - "category": test_category.name, - "venue": test_venue, - "host": test_host.name, - "title": "Test Event", - "route": "test-route", - "start_date": frappe.utils.today(), - "start_time": "10:00:00", - "end_date": frappe.utils.add_days(frappe.utils.today(), 7), - "end_time": "18:00:00", - } - ).insert(ignore_if_duplicate=True) - test_event.db_set("registrations_close_at", None) - def after_install(): create_event_categories() @@ -188,7 +153,7 @@ def on_migrate(): def create_administrator_team(): """Give a site without teams one owned by Administrator, so /b opens to the manager dashboard.""" - if not frappe.db.exists("Buzz Team"): + if not frappe.db.count("Buzz Team"): create_default_team_for("Administrator") diff --git a/buzz/patches/test_migrate_sponsor_enquiry_forms.py b/buzz/patches/test_migrate_sponsor_enquiry_forms.py index 6bcfc733..5096aa3a 100644 --- a/buzz/patches/test_migrate_sponsor_enquiry_forms.py +++ b/buzz/patches/test_migrate_sponsor_enquiry_forms.py @@ -1,100 +1,23 @@ import frappe from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team from buzz.patches.migrate_sponsor_enquiry_forms import SponsorFormMigration +from buzz.tests.factories import ( + BuzzCustomFieldFactory, + BuzzEventFactory, + SponsorshipEnquiryFactory, + SponsorshipTierFactory, +) -class SponsorFormMigrationTestCase(IntegrationTestCase): - @staticmethod - def ensure_category(): - name = "Sponsor Migration Test Category" - if not frappe.db.exists("Event Category", name): - frappe.get_doc({"doctype": "Event Category", "name": name}).insert(ignore_permissions=True) - return name - - @staticmethod - def ensure_host(): - name = "Sponsor Migration Test Host" - host = frappe.db.get_value("Event Host", {"host_name": name}) - if not host: - host = ( - frappe.get_doc({"doctype": "Event Host", "host_name": name}) - .insert(ignore_permissions=True) - .name - ) - return host - - def setUp(self): - frappe.set_user("Administrator") - frappe.clear_messages() - self.category = self.ensure_category() - self.host = self.ensure_host() - self.team = create_owned_team(f"Sponsor Migration {frappe.generate_hash(length=6)}", "Administrator") - +class TestMigrateSponsorEnquiryForms(IntegrationTestCase): def tearDown(self): - frappe.set_user("Administrator") + # The migration reads every event on the site, so one test's conflicts must not reach the next. frappe.db.rollback() - def make_legacy_event(self): - event = frappe.get_doc( - { - "doctype": "Buzz Event", - "title": f"Sponsor Migration {frappe.generate_hash(length=6)}", - "start_date": "2030-01-01", - "end_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "category": self.category, - "host": self.host, - "team": self.team, - "is_published": 1, - } - ).insert(ignore_permissions=True) - form = frappe.get_doc("Sponsor Enquiry Form", {"event": event.name}) - frappe.delete_doc("Sponsor Enquiry Form", form.name, force=1) - frappe.clear_document_cache("Buzz Event", event.name) - return event - - def add_legacy_form(self, event, **values): - row = frappe.get_doc( - { - "doctype": "Buzz Event Form", - "parent": event.name, - "parenttype": "Buzz Event", - "parentfield": "custom_forms", - "form_doctype": "Sponsorship Enquiry", - "route": "legacy-sponsor", - "publish": 1, - "login_required": 1, - **values, - } - ) - row.db_insert() - return row - - def add_legacy_question(self, event, **values): - question = frappe.get_doc( - { - "doctype": "Buzz Custom Field", - "event": event.name, - "applied_to": "Custom Form", - "custom_form_doctype": "Sponsorship Enquiry", - "enabled": 1, - "label": "Legacy budget", - "fieldname": "legacy_budget", - "fieldtype": "Number", - "order": 1, - **values, - } - ) - question.db_insert() - return question - - -class TestMigrateSponsorEnquiryForms(SponsorFormMigrationTestCase): def test_migrates_settings_questions_and_preserves_existing_enquiries(self): + auto_close_at = f"{add_days(today(), 365)} 00:00:00" event = self.make_legacy_event() legacy = self.add_legacy_form( event, @@ -104,32 +27,20 @@ def test_migrates_settings_questions_and_preserves_existing_enquiries(self): success_message="We will reply soon.", closed_title="Closed", closed_message="Try next year.", - auto_close_at="2031-01-01 00:00:00", + auto_close_at=auto_close_at, excluded_fields="website", ) question = self.add_legacy_question(event, mandatory=1, options="unused", order=2) disabled = self.add_legacy_question( event, label="Disabled", fieldname="disabled_question", enabled=0, order=1 ) - tier = frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": event.name, - "title": "Legacy tier", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert(ignore_permissions=True) - enquiry = frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": event.name, - "company_name": "Existing Sponsor", - "company_logo": "/files/acme.png", - "tier": tier.name, - "status": "Withdrawn", - "additional_fields": [{"fieldname": "legacy_budget", "label": "Legacy budget", "value": "0"}], - } - ).insert(ignore_permissions=True) + tier = SponsorshipTierFactory.create(event=event.name) + enquiry = SponsorshipEnquiryFactory.create( + event=event.name, + tier=tier.name, + status="Withdrawn", + additional_fields=[{"fieldname": "legacy_budget", "label": "Legacy budget", "value": "0"}], + ) legacy_form_count = frappe.db.count("Buzz Event Form", {"parent": event.name}) SponsorFormMigration().run() @@ -141,7 +52,7 @@ def test_migrates_settings_questions_and_preserves_existing_enquiries(self): self.assertEqual(form.success_message, "We will reply soon.") self.assertEqual(form.closed_title, "Closed") self.assertEqual(form.closed_message, "Try next year.") - self.assertEqual(str(form.auto_close_at), "2031-01-01 00:00:00") + self.assertEqual(str(form.auto_close_at), auto_close_at) self.assertEqual(form.excluded_fields, "website") self.assertEqual( [row.fieldname for row in form.custom_fields], ["disabled_question", "legacy_budget"] @@ -226,3 +137,43 @@ def test_route_collision_preflight_before_any_event_is_changed(self): self.assertFalse(frappe.db.exists("Sponsor Enquiry Form", {"event": migratable.name})) self.assertTrue(frappe.db.exists("Buzz Event Form", legacy.name)) + + def make_legacy_event(self): + """An event from before Sponsor Enquiry Form existed.""" + event = BuzzEventFactory.create() + form = frappe.db.get_value("Sponsor Enquiry Form", {"event": event.name}) + frappe.delete_doc("Sponsor Enquiry Form", form, force=1) + frappe.clear_document_cache("Buzz Event", event.name) + return event + + def add_legacy_form(self, event, **values): + row = event.append( + "custom_forms", + { + "form_doctype": "Sponsorship Enquiry", + "route": "legacy-sponsor", + "publish": 1, + "login_required": 1, + **values, + }, + ) + row.db_insert() + return row + + def add_legacy_question(self, event, **values): + """Written straight to the table: legacy keys break the rules `validate` now applies.""" + question = BuzzCustomFieldFactory.build( + **{ + "event": event.name, + "applied_to": "Custom Form", + "custom_form_doctype": "Sponsorship Enquiry", + "label": "Legacy budget", + "fieldname": "legacy_budget", + "fieldtype": "Number", + "enabled": 1, + "order": 1, + **values, + } + ) + question.db_insert() + return question diff --git a/buzz/patches/test_move_sponsorship_tier_price_to_prices.py b/buzz/patches/test_move_sponsorship_tier_price_to_prices.py index 258bab0e..98244fa6 100644 --- a/buzz/patches/test_move_sponsorship_tier_price_to_prices.py +++ b/buzz/patches/test_move_sponsorship_tier_price_to_prices.py @@ -2,35 +2,25 @@ from frappe.tests import IntegrationTestCase from buzz.patches.move_sponsorship_tier_price_to_prices import execute +from buzz.tests.factories import SponsorshipTierFactory class TestMoveSponsorshipTierPriceToPrices(IntegrationTestCase): def setUp(self): if not frappe.db.has_column("Sponsorship Tier", "price"): self.skipTest("The legacy price column is gone from this site.") - frappe.set_user("Administrator") - self.tier = frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": frappe.db.get_value("Buzz Event", {"route": "test-route"}), - "title": "Legacy price tier", - "prices": [{"currency": "INR", "price": 1}], - } - ).insert(ignore_permissions=True) - frappe.db.delete("Buzz Price", {"parent": self.tier.name}) + self.tier = SponsorshipTierFactory.create().name + frappe.db.delete("Buzz Price", {"parent": self.tier}) tier = frappe.qb.DocType("Sponsorship Tier") frappe.qb.update(tier).set(tier.price, 750).set(tier.currency, "USD").where( - tier.name == self.tier.name + tier.name == self.tier ).run() - def tearDown(self): - frappe.db.rollback() - def test_legacy_price_becomes_the_first_row(self): execute() execute() rows = frappe.get_all( - "Buzz Price", filters={"parent": self.tier.name}, fields=["currency", "price", "idx"] + "Buzz Price", filters={"parent": self.tier}, fields=["currency", "price", "idx"] ) self.assertEqual([(row.currency, row.price, row.idx) for row in rows], [("USD", 750, 1)]) diff --git a/buzz/proposals/doctype/event_proposal/test_event_proposal.py b/buzz/proposals/doctype/event_proposal/test_event_proposal.py index 8d267bf1..9b6d2492 100644 --- a/buzz/proposals/doctype/event_proposal/test_event_proposal.py +++ b/buzz/proposals/doctype/event_proposal/test_event_proposal.py @@ -4,48 +4,13 @@ import frappe from frappe.tests import IntegrationTestCase -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +from buzz.tests.factories import EventHostFactory, EventProposalFactory class IntegrationTestEventProposal(IntegrationTestCase): - """ - Integration tests for EventProposal. - Use this class for testing interactions between multiple components. - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - if not frappe.db.exists("Event Category", "Test Proposal Category"): - category = frappe.new_doc("Event Category") - category.name = "Test Proposal Category" - category.insert(ignore_permissions=True) - cls.category = "Test Proposal Category" - - def make_proposal(self, **kwargs): - proposal = frappe.new_doc("Event Proposal") - proposal.update( - { - "title": f"Proposal {frappe.generate_hash(length=6)}", - "category": self.category, - "medium": "Online", - "start_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "about": "

About the event

", - } - ) - proposal.update(kwargs) - proposal.insert(ignore_permissions=True) - return proposal - def test_create_host_creates_and_links_event_host(self): company = f"Acme {frappe.generate_hash(length=6)}" - proposal = self.make_proposal(host_company=company, about_the_company="We host events.") + proposal = EventProposalFactory.create(host_company=company, about_the_company="We host events.") host = proposal.create_host() @@ -55,21 +20,18 @@ def test_create_host_creates_and_links_event_host(self): def test_create_host_reuses_existing_host(self): company = f"Existing {frappe.generate_hash(length=6)}" - existing = frappe.new_doc("Event Host") - existing.host_name = company - existing.insert(ignore_permissions=True) + existing = EventHostFactory.create(host_name=company) - proposal = self.make_proposal(host_company=company) + proposal = EventProposalFactory.create(host_company=company) proposal.create_host() self.assertEqual(proposal.host, existing.name) def test_reuse_fills_only_empty_host_fields(self): - # Existing host with empty logo/about -> proposal values fill them in. company = f"Empty {frappe.generate_hash(length=6)}" - frappe.get_doc({"doctype": "Event Host", "host_name": company}).insert(ignore_permissions=True) + EventHostFactory.create(host_name=company) - proposal = self.make_proposal( + proposal = EventProposalFactory.create( host_company=company, host_company_logo="/files/proposal-logo.png", about_the_company="Proposal about.", @@ -81,18 +43,10 @@ def test_reuse_fills_only_empty_host_fields(self): self.assertEqual(host.about, "Proposal about.") def test_reuse_does_not_overwrite_populated_host_fields(self): - # Existing host already has logo/about -> proposal must not clobber them. company = f"Populated {frappe.generate_hash(length=6)}" - frappe.get_doc( - { - "doctype": "Event Host", - "host_name": company, - "logo": "/files/original-logo.png", - "about": "Original about.", - } - ).insert(ignore_permissions=True) - - proposal = self.make_proposal( + EventHostFactory.create(host_name=company, logo="/files/original-logo.png", about="Original about.") + + proposal = EventProposalFactory.create( host_company=company, host_company_logo="/files/proposal-logo.png", about_the_company="Proposal about.", @@ -104,20 +58,19 @@ def test_reuse_does_not_overwrite_populated_host_fields(self): self.assertEqual(host.about, "Original about.") def test_create_host_requires_company_name(self): - proposal = self.make_proposal() + proposal = EventProposalFactory.create() with self.assertRaises(frappe.ValidationError): proposal.create_host() def test_create_host_throws_if_already_linked(self): - company = f"Acme {frappe.generate_hash(length=6)}" - proposal = self.make_proposal(host_company=company) + proposal = EventProposalFactory.create(host_company=f"Acme {frappe.generate_hash(length=6)}") proposal.create_host() with self.assertRaises(frappe.ValidationError): proposal.create_host() def test_submit_auto_creates_host_from_company(self): company = f"Auto {frappe.generate_hash(length=6)}" - proposal = self.make_proposal(host_company=company, status="Approved") + proposal = EventProposalFactory.create("approved", host_company=company) proposal.submit() @@ -126,28 +79,18 @@ def test_submit_auto_creates_host_from_company(self): self.assertEqual(proposal.status, "Event Created") def test_start_and_end_time_are_mandatory(self): - proposal = frappe.new_doc("Event Proposal") - proposal.update( - { - "title": f"Proposal {frappe.generate_hash(length=6)}", - "category": self.category, - "medium": "Online", - "start_date": "2030-01-01", - "about": "

About the event

", - } - ) with self.assertRaises(frappe.MandatoryError): - proposal.insert(ignore_permissions=True) + EventProposalFactory.create(start_time=None, end_time=None) def test_submit_without_host_or_company_throws(self): - proposal = self.make_proposal(status="Approved") + proposal = EventProposalFactory.create("approved") with self.assertRaises(frappe.ValidationError): proposal.submit() def test_free_event_flag_carries_to_the_created_event(self): - """get_mapped_doc matches on fieldname, so both doctypes must use the same one.""" + # get_mapped_doc matches on fieldname, so both doctypes must use the same one. company = f"Free {frappe.generate_hash(length=6)}" - proposal = self.make_proposal(host_company=company, status="Approved", free_event=1) + proposal = EventProposalFactory.create("approved", host_company=company, free_event=1) proposal.submit() diff --git a/buzz/proposals/doctype/sponsorship_enquiry/test_sponsorship_enquiry.py b/buzz/proposals/doctype/sponsorship_enquiry/test_sponsorship_enquiry.py index 8286ded9..948bcfb2 100644 --- a/buzz/proposals/doctype/sponsorship_enquiry/test_sponsorship_enquiry.py +++ b/buzz/proposals/doctype/sponsorship_enquiry/test_sponsorship_enquiry.py @@ -6,228 +6,130 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import set_team_settings - -EXTRA_TEST_RECORD_DEPENDENCIES = [] -IGNORE_TEST_RECORD_DEPENDENCIES = [] +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + EmailTemplateFactory, + SponsorshipEnquiryFactory, +) class TestSponsorshipEnquiryEmail(IntegrationTestCase): - """Tests for Sponsorship Enquiry pitch deck email with template fallback logic.""" + def test_no_email_when_disabled(self): + event = BuzzEventFactory.create(auto_send_pitch_deck=0) + BuzzTeamFactory.set_settings(event.team, {"auto_send_pitch_deck": 0}) - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - cls.test_event.auto_send_pitch_deck = False - cls.test_event.sponsor_deck_email_template = None - cls.test_event.sponsor_deck_cc = None - cls.test_event.sponsor_deck_reply_to = None - cls.test_event.save() - - set_team_settings( - cls.test_event.team, - auto_send_pitch_deck=0, - default_sponsor_deck_email_template=None, - default_sponsor_deck_cc=None, - default_sponsor_deck_reply_to=None, - ) + with patch("frappe.sendmail") as sendmail: + SponsorshipEnquiryFactory.create(event=event.name).send_pitch_deck() - def set_team_defaults(self, **values): - set_team_settings(self.test_event.team, **values) + sendmail.assert_not_called() - def clear_team_defaults(self): - self.set_team_defaults( - auto_send_pitch_deck=0, - default_sponsor_deck_email_template=None, - default_sponsor_deck_reply_to=None, - default_sponsor_deck_cc=None, + def test_uses_event_settings(self): + event = BuzzEventFactory.create( + auto_send_pitch_deck=1, + sponsor_deck_email_template=self.create_template("EVENT"), + sponsor_deck_reply_to="event@test.com", + sponsor_deck_cc="event-cc@test.com", ) - def _create_enquiry(self): - return frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": self.test_event.name, - "company_name": "Test Sponsor Co", - "company_logo": "/files/test-logo.png", - } - ).insert() - - 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( + email = self.email_sent_on_enquiry(event) + + self.assertIn("EVENT", email["subject"]) + self.assertEqual(email["reply_to"], "event@test.com") + self.assertEqual(email["cc"], "event-cc@test.com") + + def test_falls_back_to_team_settings(self): + event = BuzzEventFactory.create(auto_send_pitch_deck=0) + BuzzTeamFactory.set_settings( + event.team, { - "doctype": "Email Template", - "name": name, - "subject": f"{subject_prefix} - {{{{ event.title }}}}", - "response": f"

{subject_prefix} content

", - } - ).insert() + "auto_send_pitch_deck": 1, + "default_sponsor_deck_email_template": self.create_template("TEAM"), + "default_sponsor_deck_reply_to": "team@test.com", + "default_sponsor_deck_cc": "team-cc@test.com", + }, + ) - def tearDown(self): - for e in frappe.get_all("Sponsorship Enquiry", {"company_name": "Test Sponsor Co"}, pluck="name"): - frappe.delete_doc("Sponsorship Enquiry", e, force=True) + email = self.email_sent_on_enquiry(event) - @classmethod - def tearDownClass(cls): - cls.test_event.auto_send_pitch_deck = False - cls.test_event.sponsor_deck_email_template = None - cls.test_event.sponsor_deck_cc = None - cls.test_event.sponsor_deck_reply_to = None - cls.test_event.save() - - set_team_settings( - cls.test_event.team, - auto_send_pitch_deck=0, - default_sponsor_deck_email_template=None, - default_sponsor_deck_cc=None, - default_sponsor_deck_reply_to=None, + self.assertIn("TEAM", email["subject"]) + self.assertEqual(email["reply_to"], "team@test.com") + self.assertEqual(email["cc"], "team-cc@test.com") + + def test_event_settings_take_precedence(self): + event = BuzzEventFactory.create( + auto_send_pitch_deck=1, + sponsor_deck_email_template=self.create_template("EVENT"), + sponsor_deck_reply_to="event@test.com", + ) + BuzzTeamFactory.set_settings( + event.team, + { + "auto_send_pitch_deck": 1, + "default_sponsor_deck_email_template": self.create_template("TEAM"), + "default_sponsor_deck_reply_to": "team@test.com", + }, ) - super().tearDownClass() - - @patch("frappe.sendmail") - def test_no_email_when_disabled(self, mock_sendmail): - self.test_event.auto_send_pitch_deck = False - self.test_event.save() - - enquiry = self._create_enquiry() - enquiry.send_pitch_deck() - - mock_sendmail.assert_not_called() - - @patch("frappe.sendmail") - def test_uses_event_settings(self, mock_sendmail): - template = self._create_template("Event Sponsor Template", "EVENT") - try: - self.test_event.auto_send_pitch_deck = True - self.test_event.sponsor_deck_email_template = template.name - self.test_event.sponsor_deck_reply_to = "event@test.com" - self.test_event.sponsor_deck_cc = "event-cc@test.com" - self.test_event.save() - - # after_insert hook triggers send_pitch_deck automatically - self._create_enquiry() - - mock_sendmail.assert_called_once() - args = mock_sendmail.call_args[1] - self.assertIn("EVENT", args["subject"]) - self.assertEqual(args["reply_to"], "event@test.com") - self.assertEqual(args["cc"], "event-cc@test.com") - finally: - self.test_event.auto_send_pitch_deck = False - self.test_event.sponsor_deck_email_template = None - self.test_event.sponsor_deck_reply_to = None - self.test_event.sponsor_deck_cc = None - self.test_event.save() - frappe.delete_doc("Email Template", template.name, force=True) - - @patch("frappe.sendmail") - def test_falls_back_to_team_settings(self, mock_sendmail): - template = self._create_template("Team Sponsor Template", "TEAM") - try: - self.set_team_defaults( - auto_send_pitch_deck=1, - default_sponsor_deck_email_template=template.name, - default_sponsor_deck_reply_to="team@test.com", - default_sponsor_deck_cc="team-cc@test.com", - ) - - # after_insert hook triggers send_pitch_deck automatically - self._create_enquiry() - - mock_sendmail.assert_called_once() - args = mock_sendmail.call_args[1] - self.assertIn("TEAM", args["subject"]) - self.assertEqual(args["reply_to"], "team@test.com") - self.assertEqual(args["cc"], "team-cc@test.com") - finally: - self.clear_team_defaults() - frappe.delete_doc("Email Template", template.name, force=True) - - @patch("frappe.sendmail") - def test_event_settings_take_precedence(self, mock_sendmail): - event_template = self._create_template("Event Template", "EVENT") - team_template = self._create_template("Team Template", "TEAM") - try: - self.test_event.auto_send_pitch_deck = True - self.test_event.sponsor_deck_email_template = event_template.name - self.test_event.sponsor_deck_reply_to = "event@test.com" - self.test_event.save() - - self.set_team_defaults( - auto_send_pitch_deck=1, - default_sponsor_deck_email_template=team_template.name, - default_sponsor_deck_reply_to="team@test.com", - ) - - # after_insert hook triggers send_pitch_deck automatically - self._create_enquiry() - - mock_sendmail.assert_called_once() - args = mock_sendmail.call_args[1] - self.assertIn("EVENT", args["subject"]) - self.assertEqual(args["reply_to"], "event@test.com") - finally: - self.test_event.auto_send_pitch_deck = False - self.test_event.sponsor_deck_email_template = None - self.test_event.sponsor_deck_reply_to = None - self.test_event.save() - self.clear_team_defaults() - frappe.delete_doc("Email Template", event_template.name, force=True) - frappe.delete_doc("Email Template", team_template.name, force=True) + email = self.email_sent_on_enquiry(event) -class TestSponsorshipApprovalNotification(IntegrationTestCase): - """Where the approval email sends the applicant next.""" + self.assertIn("EVENT", email["subject"]) + self.assertEqual(email["reply_to"], "event@test.com") + + def create_template(self, subject_prefix: str) -> str: + return EmailTemplateFactory.create( + subject=f"{subject_prefix} - {{{{ event.title }}}}", + response=f"

{subject_prefix} content

", + ).name + + def email_sent_on_enquiry(self, event) -> dict: + """The `after_insert` hook sends the pitch deck.""" + with patch("frappe.sendmail") as sendmail: + SponsorshipEnquiryFactory.create(event=event.name) + + sendmail.assert_called_once() + return sendmail.call_args[1] + +class TestSponsorshipApprovalNotification(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + cls.event = BuzzEventFactory.create().name - def tearDown(self): - frappe.set_user("Administrator") - frappe.db.rollback() - - def make_enquiry(self, owner=None, contact_email=None): - enquiry = frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": self.test_event.name, - "company_name": "Approval Co", - "company_logo": "/files/test-logo.png", - "contact_email": contact_email, - } - ).insert(ignore_permissions=True) - if owner: - frappe.db.set_value("Sponsorship Enquiry", enquiry.name, "owner", owner) - enquiry.reload() - return enquiry + def test_an_account_holder_is_sent_to_their_dashboard(self): + enquiry = self.create_enquiry() - @patch("frappe.sendmail") - def test_an_account_holder_is_sent_to_their_dashboard(self, mock_sendmail): - enquiry = self.make_enquiry() - enquiry.send_approval_notification() + message = self.approval_email(enquiry)["args"]["message"] - message = mock_sendmail.call_args[1]["args"]["message"] self.assertIn(f"/b/account/sponsorships/{enquiry.name}", message) - @patch("frappe.sendmail") - def test_a_guest_enquiry_is_not_sent_to_a_page_it_cannot_open(self, mock_sendmail): - enquiry = self.make_enquiry(owner="Guest", contact_email="guest-applicant@example.com") - enquiry.send_approval_notification() + def test_a_guest_enquiry_is_not_sent_to_a_page_it_cannot_open(self): + enquiry = self.create_enquiry(owner="Guest", contact_email="guest-applicant@example.com") - args = mock_sendmail.call_args[1] - self.assertEqual(args["recipients"], ["guest-applicant@example.com"]) + email = self.approval_email(enquiry) + + self.assertEqual(email["recipients"], ["guest-applicant@example.com"]) # Nobody satisfies is_applicant on a Guest-owned enquiry, so the link would 403. - self.assertNotIn("/b/account/sponsorships/", args["args"]["message"]) - self.assertIn("be in touch", args["args"]["message"]) + self.assertNotIn("/b/account/sponsorships/", email["args"]["message"]) + self.assertIn("be in touch", email["args"]["message"]) + + def test_a_guest_enquiry_without_a_contact_email_sends_nothing(self): + enquiry = self.create_enquiry(owner="Guest") + + with patch("frappe.sendmail") as sendmail: + enquiry.send_approval_notification() + + sendmail.assert_not_called() - @patch("frappe.sendmail") - def test_a_guest_enquiry_without_a_contact_email_sends_nothing(self, mock_sendmail): - enquiry = self.make_enquiry(owner="Guest") - enquiry.send_approval_notification() + def create_enquiry(self, owner: str | None = None, contact_email: str | None = None): + enquiry = SponsorshipEnquiryFactory.create(event=self.event, contact_email=contact_email) + if owner: + frappe.db.set_value("Sponsorship Enquiry", enquiry.name, "owner", owner) + enquiry.reload() + return enquiry - mock_sendmail.assert_not_called() + def approval_email(self, enquiry) -> dict: + with patch("frappe.sendmail") as sendmail: + enquiry.send_approval_notification() + return sendmail.call_args[1] diff --git a/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py b/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py index 24cd9c7e..38a020a6 100644 --- a/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py +++ b/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py @@ -3,152 +3,83 @@ import frappe from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today -from buzz.api.forms.test_forms import ensure_event_host, ensure_prompt_named_record -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, TalkProposalFactory, UserFactory -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +class TalkProposalTestCase(IntegrationTestCase): + @classmethod + def create_event_owned_by(cls, user: str) -> str: + return str(BuzzEventFactory.create(team=BuzzTeamFactory.create_owned_by(user).name).name) -def make_test_user(email: str, roles: list[str] | None = None) -> str: - if not frappe.db.exists("User", email): - frappe.get_doc( - { - "doctype": "User", - "email": email, - "first_name": email.split("@")[0], - "send_welcome_email": 0, - } - ).insert(ignore_permissions=True) - user = frappe.get_doc("User", email) - user.add_roles("Buzz User", *(roles or [])) - return user.name - - -def make_test_event(category: str, host: str, team: str | None = None) -> str: - event = frappe.new_doc("Buzz Event") - event.update( - { - "title": f"Proposal Perm Event {frappe.generate_hash(length=6)}", - "start_date": "2030-01-01", - "end_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "category": category, - "host": host, - "is_published": 1, - "team": team, - } - ) - event.insert(ignore_permissions=True) - return event.name - - -def make_guest_proposal(event: str, speaker_email: str, title: str | None = None) -> str: - """Simulate a guest form submission: owner and submitted_by both end up 'Guest'.""" - original_user = frappe.session.user - frappe.set_user("Guest") - try: - proposal = frappe.get_doc( - { - "doctype": "Talk Proposal", - "title": title or f"Guest Talk {frappe.generate_hash(length=6)}", - "event": event, - "speakers": [{"first_name": "Speaker", "email": speaker_email}], - } - ).insert(ignore_permissions=True) - finally: - frappe.set_user(original_user) - return proposal.name - - -class TestTalkProposalSpeakerAccess(IntegrationTestCase): + +class TestTalkProposalSpeakerAccess(TalkProposalTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Proposal Perm Category") - cls.host = ensure_event_host("Proposal Perm Host") - cls.speaker_user = make_test_user("speaker-perm@example.com") - cls.other_user = make_test_user("other-perm@example.com") - cls.manager_user = make_test_user("manager-perm@example.com", roles=["Event Manager"]) - cls.team = create_owned_team("Proposal Perm Team", cls.manager_user) - cls.event = make_test_event(cls.category, cls.host, team=cls.team) - cls.guest_proposal = make_guest_proposal(cls.event, cls.speaker_user) - - def tearDown(self): - frappe.set_user("Administrator") + cls.speaker_user = UserFactory.create_once("speaker-perm@example.com").name + cls.other_user = UserFactory.create_once("other-perm@example.com").name + cls.manager_user = UserFactory.create_once("manager-perm@example.com").name + cls.event = cls.create_event_owned_by(cls.manager_user) + cls.guest_proposal = TalkProposalFactory.create_as_guest(cls.event, cls.speaker_user).name def test_speaker_can_read_guest_submitted_proposal(self): - frappe.set_user(self.speaker_user) - proposal = frappe.get_doc("Talk Proposal", self.guest_proposal) - self.assertTrue(proposal.has_permission("read")) + with self.set_user(self.speaker_user): + self.assertTrue(frappe.get_doc("Talk Proposal", self.guest_proposal).has_permission("read")) def test_speaker_can_write_guest_submitted_proposal(self): - frappe.set_user(self.speaker_user) - proposal = frappe.get_doc("Talk Proposal", self.guest_proposal) - self.assertTrue(proposal.has_permission("write")) + with self.set_user(self.speaker_user): + self.assertTrue(frappe.get_doc("Talk Proposal", self.guest_proposal).has_permission("write")) def test_speaker_sees_guest_submitted_proposal_in_list(self): - frappe.set_user(self.speaker_user) - names = frappe.get_list("Talk Proposal", pluck="name") - self.assertIn(self.guest_proposal, names) + with self.set_user(self.speaker_user): + self.assertIn(self.guest_proposal, frappe.get_list("Talk Proposal", pluck="name")) def test_non_speaker_cannot_read_or_list_proposal(self): - frappe.set_user(self.other_user) - proposal = frappe.get_doc("Talk Proposal", self.guest_proposal) - self.assertFalse(proposal.has_permission("read")) - self.assertFalse(proposal.has_permission("write")) - self.assertNotIn(self.guest_proposal, frappe.get_list("Talk Proposal", pluck="name")) + with self.set_user(self.other_user): + proposal = frappe.get_doc("Talk Proposal", self.guest_proposal) + self.assertFalse(proposal.has_permission("read")) + self.assertFalse(proposal.has_permission("write")) + self.assertNotIn(self.guest_proposal, frappe.get_list("Talk Proposal", pluck="name")) def test_submitter_sees_proposal_even_without_speaker_row(self): - proposal = frappe.get_doc( - { - "doctype": "Talk Proposal", - "title": f"Submitter Talk {frappe.generate_hash(length=6)}", - "event": self.event, - "submitted_by": self.speaker_user, - "speakers": [{"first_name": "Someone", "email": "someone-else@example.com"}], - } - ).insert(ignore_permissions=True) - - frappe.set_user(self.speaker_user) - self.assertIn(proposal.name, frappe.get_list("Talk Proposal", pluck="name")) - self.assertTrue(proposal.has_permission("read")) + proposal = TalkProposalFactory.create( + event=self.event, + submitted_by=self.speaker_user, + speakers=[{"first_name": "Someone", "email": "someone-else@example.com"}], + ) + + with self.set_user(self.speaker_user): + self.assertIn(proposal.name, frappe.get_list("Talk Proposal", pluck="name")) + self.assertTrue(proposal.has_permission("read")) def test_speaker_email_match_is_case_insensitive(self): - proposal = make_guest_proposal(self.event, "Mixed-Case@Example.COM") - user = make_test_user("mixed-case@example.com") + proposal = TalkProposalFactory.create_as_guest(self.event, "Mixed-Case@Example.COM").name + user = UserFactory.create_once("mixed-case@example.com").name - frappe.set_user(user) - doc = frappe.get_doc("Talk Proposal", proposal) - self.assertTrue(doc.has_permission("read")) - self.assertTrue(doc.has_permission("write")) - self.assertIn(proposal, frappe.get_list("Talk Proposal", pluck="name")) + with self.set_user(user): + doc = frappe.get_doc("Talk Proposal", proposal) + self.assertTrue(doc.has_permission("read")) + self.assertTrue(doc.has_permission("write")) + self.assertIn(proposal, frappe.get_list("Talk Proposal", pluck="name")) def test_team_member_sees_their_teams_proposals(self): - frappe.set_user(self.manager_user) - self.assertIn(self.guest_proposal, frappe.get_list("Talk Proposal", pluck="name")) + with self.set_user(self.manager_user): + self.assertIn(self.guest_proposal, frappe.get_list("Talk Proposal", pluck="name")) -class TestCreateTalk(IntegrationTestCase): +class TestCreateTalk(TalkProposalTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Proposal Perm Category") - cls.host = ensure_event_host("Proposal Perm Host") - cls.speaker_user = make_test_user("create-talk-speaker@example.com") - cls.owner_user = make_test_user("create-talk-owner@example.com", roles=["Event Manager"]) - cls.event = make_test_event( - cls.category, cls.host, team=create_owned_team("Create Talk Owner Team", cls.owner_user) - ) + cls.speaker_user = UserFactory.create_once("create-talk-speaker@example.com").name + cls.event = cls.create_event_owned_by(UserFactory.create_once("create-talk-owner@example.com").name) def test_create_talk_accepts_the_proposal(self): - proposal = frappe.get_doc("Talk Proposal", make_guest_proposal(self.event, self.speaker_user)) + proposal = frappe.get_doc( + "Talk Proposal", TalkProposalFactory.create_as_guest(self.event, self.speaker_user).name + ) talk = proposal.create_talk() @@ -157,45 +88,47 @@ def test_create_talk_accepts_the_proposal(self): def test_speaker_without_an_account_gets_a_website_user(self): email = f"new-speaker-{frappe.generate_hash(length=6)}@example.com" - proposal = frappe.get_doc("Talk Proposal", make_guest_proposal(self.event, email)) + proposal = frappe.get_doc( + "Talk Proposal", TalkProposalFactory.create_as_guest(self.event, email).name + ) proposal.create_talk() self.assertEqual(frappe.db.get_value("User", email, "user_type"), "Website User") def test_a_member_of_the_events_team_can_accept_a_proposal(self): - manager = make_test_user("create-talk-manager@example.com", roles=["Event Manager"]) - team = create_owned_team("Create Talk Team", manager) - event = make_test_event(self.category, self.host, team=team) - proposal = frappe.get_doc("Talk Proposal", make_guest_proposal(event, self.speaker_user)) + manager = UserFactory.create_once("create-talk-manager@example.com").name + event = self.create_event_owned_by(manager) + proposal = frappe.get_doc( + "Talk Proposal", TalkProposalFactory.create_as_guest(event, self.speaker_user).name + ) - frappe.set_user(manager) - self.addCleanup(frappe.set_user, "Administrator") - talk = proposal.run_method("create_talk") + with self.set_user(manager): + talk = proposal.run_method("create_talk") self.assertEqual(talk.proposal, proposal.name) self.assertEqual(frappe.db.get_value("Talk Proposal", proposal.name, "status"), "Accepted") def test_a_listed_speaker_cannot_accept_their_own_proposal(self): - # A speaker who already has a profile reaches the end of create_talk: nothing it - # touches needs a permission the speaker lacks. - if not frappe.db.exists("Speaker Profile", {"user": self.speaker_user}): - frappe.get_doc({"doctype": "Speaker Profile", "user": self.speaker_user}).insert() - proposal = frappe.get_doc("Talk Proposal", make_guest_proposal(self.event, self.speaker_user)) - - frappe.set_user(self.speaker_user) - self.addCleanup(frappe.set_user, "Administrator") - # run_doc_method loads the document with a read check and nothing more. - doc = frappe.get_doc("Talk Proposal", proposal.name, check_permission=True) + # With a speaker profile in place, only the permission check can stop create_talk. + frappe.get_doc( + "Talk Proposal", TalkProposalFactory.create_as_guest(self.event, self.speaker_user).name + ).create_talk() + proposal = TalkProposalFactory.create_as_guest(self.event, self.speaker_user).name - with self.assertRaises(frappe.PermissionError): - doc.run_method("create_talk") + with self.set_user(self.speaker_user): + # run_doc_method loads the document with a read check and nothing more. + doc = frappe.get_doc("Talk Proposal", proposal, check_permission=True) + with self.assertRaises(frappe.PermissionError): + doc.run_method("create_talk") - self.assertEqual(frappe.db.get_value("Talk Proposal", proposal.name, "status"), "Review Pending") - self.assertEqual(frappe.db.count("Event Talk", {"proposal": proposal.name}), 0) + self.assertEqual(frappe.db.get_value("Talk Proposal", proposal, "status"), "Review Pending") + self.assertEqual(frappe.db.count("Event Talk", {"proposal": proposal}), 0) def test_second_create_talk_leaves_no_partial_state(self): - proposal = frappe.get_doc("Talk Proposal", make_guest_proposal(self.event, self.speaker_user)) + proposal = frappe.get_doc( + "Talk Proposal", TalkProposalFactory.create_as_guest(self.event, self.speaker_user).name + ) proposal.create_talk() proposal.reload() @@ -205,101 +138,98 @@ def test_second_create_talk_leaves_no_partial_state(self): self.assertEqual(frappe.db.count("Event Talk", {"proposal": proposal.name}), 1) -class TestTalkProposalSpeakerChanges(IntegrationTestCase): +class TestTalkProposalSpeakerChanges(TalkProposalTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Proposal Perm Category") - cls.host = ensure_event_host("Proposal Perm Host") - cls.speaker_user = make_test_user("guard-speaker@example.com") - cls.manager_user = make_test_user("guard-manager@example.com", roles=["Event Manager"]) - cls.team = create_owned_team("Proposal Guard Team", cls.manager_user) - cls.event = make_test_event(cls.category, cls.host, team=cls.team) - - def tearDown(self): - frappe.set_user("Administrator") - - def proposal(self, event: str | None = None, status: str = "Review Pending") -> str: - name = make_guest_proposal(event or self.event, self.speaker_user) - if status != "Review Pending": - frappe.db.set_value("Talk Proposal", name, "status", status) - return name - - def as_speaker(self, name: str): - frappe.set_user(self.speaker_user) - return frappe.get_doc("Talk Proposal", name) + cls.speaker_user = UserFactory.create_once("guard-speaker@example.com").name + cls.manager_user = UserFactory.create_once("guard-manager@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.manager_user).name + cls.event = str(BuzzEventFactory.create(team=cls.team).name) def test_speaker_edits_an_open_proposal(self): - doc = self.as_speaker(self.proposal()) + doc = self.open_as_speaker(self.create_proposal()) doc.title = "Edited by the speaker" doc.save() self.assertEqual(frappe.db.get_value("Talk Proposal", doc.name, "title"), "Edited by the speaker") def test_speaker_withdraws_an_open_proposal(self): - doc = self.as_speaker(self.proposal()) + doc = self.open_as_speaker(self.create_proposal()) doc.status = "Withdrawn" doc.save() self.assertEqual(frappe.db.get_value("Talk Proposal", doc.name, "status"), "Withdrawn") def test_speaker_cannot_accept_their_own_proposal(self): - doc = self.as_speaker(self.proposal()) + doc = self.open_as_speaker(self.create_proposal()) doc.status = "Accepted" self.assertRaises(frappe.PermissionError, doc.save) def test_speaker_cannot_edit_an_accepted_proposal(self): - doc = self.as_speaker(self.proposal(status="Accepted")) + doc = self.open_as_speaker(self.create_proposal(status="Accepted")) doc.title = "Edited after acceptance" self.assertRaises(frappe.PermissionError, doc.save) def test_speaker_cannot_edit_after_the_event(self): - past = make_test_event(self.category, self.host, team=self.team) - frappe.db.set_value("Buzz Event", past, {"start_date": "2020-01-01", "end_date": "2020-01-02"}) - doc = self.as_speaker(self.proposal(event=past)) + past = str(BuzzEventFactory.create(team=self.team).name) + frappe.db.set_value( + "Buzz Event", past, {"start_date": add_days(today(), -10), "end_date": add_days(today(), -9)} + ) + doc = self.open_as_speaker(self.create_proposal(event=past)) doc.title = "Edited after the event" self.assertRaises(frappe.PermissionError, doc.save) def test_speaker_cannot_move_a_proposal_to_another_event(self): - other = make_test_event(self.category, self.host, team=self.team) - doc = self.as_speaker(self.proposal()) + other = BuzzEventFactory.create(team=self.team).name + doc = self.open_as_speaker(self.create_proposal()) doc.event = other self.assertRaises(frappe.CannotChangeConstantError, doc.save) def test_speaker_cannot_move_a_proposal_into_an_event_they_run(self): - own_team = create_owned_team("Proposal Speaker Own Team", self.speaker_user) - own_event = make_test_event(self.category, self.host, team=own_team) - doc = self.as_speaker(self.proposal()) + own_event = self.create_event_owned_by(self.speaker_user) + doc = self.open_as_speaker(self.create_proposal()) doc.event = own_event doc.status = "Accepted" self.assertRaises(frappe.CannotChangeConstantError, doc.save) def test_the_team_cannot_move_a_proposal_to_another_event(self): - other = make_test_event(self.category, self.host, team=self.team) - name = self.proposal() - frappe.set_user(self.manager_user) - doc = frappe.get_doc("Talk Proposal", name) - doc.event = other - self.assertRaises(frappe.CannotChangeConstantError, doc.save) + other = BuzzEventFactory.create(team=self.team).name + name = self.create_proposal() + with self.set_user(self.manager_user): + doc = frappe.get_doc("Talk Proposal", name) + doc.event = other + self.assertRaises(frappe.CannotChangeConstantError, doc.save) def test_a_proposal_cannot_lose_its_event(self): - doc = self.as_speaker(self.proposal()) + doc = self.open_as_speaker(self.create_proposal()) doc.event = None self.assertRaises(frappe.CannotChangeConstantError, doc.save) def test_speaker_cannot_remove_themselves(self): - doc = self.as_speaker(self.proposal()) + doc = self.open_as_speaker(self.create_proposal()) doc.speakers = [] self.assertRaises(frappe.PermissionError, doc.save) def test_speaker_adds_a_co_speaker(self): - doc = self.as_speaker(self.proposal()) + doc = self.open_as_speaker(self.create_proposal()) doc.append("speakers", {"first_name": "Co", "email": "guard-co@example.com"}) doc.save() self.assertEqual(len(frappe.get_doc("Talk Proposal", doc.name).speakers), 2) def test_the_team_still_accepts_a_proposal(self): - name = self.proposal() - frappe.set_user(self.manager_user) - doc = frappe.get_doc("Talk Proposal", name) - doc.status = "Accepted" - doc.save() + name = self.create_proposal() + with self.set_user(self.manager_user): + doc = frappe.get_doc("Talk Proposal", name) + doc.status = "Accepted" + doc.save() self.assertEqual(frappe.db.get_value("Talk Proposal", name, "status"), "Accepted") + + def create_proposal(self, event: str | None = None, status: str = "Review Pending") -> str: + name = TalkProposalFactory.create_as_guest(event or self.event, self.speaker_user).name + if status != "Review Pending": + frappe.db.set_value("Talk Proposal", name, "status", status) + return name + + def open_as_speaker(self, name: str): + """The speaker stays signed in until the test ends.""" + self.enterContext(self.set_user(self.speaker_user)) + return frappe.get_doc("Talk Proposal", name) diff --git a/buzz/test_default_team_patches.py b/buzz/test_default_team_patches.py index 8da31268..76ccb94e 100644 --- a/buzz/test_default_team_patches.py +++ b/buzz/test_default_team_patches.py @@ -4,49 +4,29 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.events.doctype.buzz_team.test_buzz_team import create_user, payload_for from buzz.install import create_administrator_team from buzz.patches.assign_default_team import execute as backfill_teams from buzz.patches.create_default_teams import execute as create_default_teams from buzz.patches.create_default_teams import get_enabled_event_managers +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory, EventVenueFactory, UserFactory class TestCreateDefaultTeams(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Built before setUp retires every manager, so the team owner it adds does not count. + cls.team = BuzzTeamFactory.create_owned_by().name + def setUp(self): - frappe.set_user("Administrator") - # The patch reads the whole site, so a manager belonging to the database — or to the - # previous test — would decide who owns this test's team. + # The patch reads every manager on the site, so earlier ones would pick the owner. for user in get_enabled_event_managers(): - self.retire(user) - - def create_manager(self, email: str, first_name: str) -> str: - user = create_user(email, first_name) - frappe.get_doc("User", user).add_roles("Event Manager") - return user - - @staticmethod - def retire(user: str) -> None: - frappe.get_doc("User", user).remove_roles("Event Manager") - - def create_event_owned_by(self, user: str, suffix: str) -> str: - event = frappe.get_doc(payload_for("Buzz Event", suffix)) - event.flags.ignore_mandatory = True - event.insert(ignore_permissions=True) - frappe.db.set_value("Buzz Event", event.name, "owner", user, update_modified=False) - return event.name - - def team_of(self, user: str) -> str | None: - return frappe.db.get_value("Buzz Team Membership", {"user": user, "enabled": 1}, "team") - - def role_in(self, team: str, user: str) -> str | None: - return frappe.db.get_value( - "Buzz Team Membership", {"team": team, "user": user, "enabled": 1}, "team_role" - ) + self.remove_event_manager_role(user) def test_every_manager_lands_in_one_shared_team(self): - owner = self.create_manager("shared-owner@example.com", "Owner") - other = self.create_manager("shared-other@example.com", "Other") - self.create_event_owned_by(owner, "Shared") + owner = self.create_manager("shared-owner@example.com") + other = self.create_manager("shared-other@example.com") + self.create_event_owned_by(owner) create_default_teams() @@ -55,11 +35,11 @@ def test_every_manager_lands_in_one_shared_team(self): self.assertEqual(self.role_in(team, other), "Admin") def test_owner_is_the_manager_who_created_the_most_events(self): - quiet = self.create_manager("top-quiet@example.com", "Quiet") - busy = self.create_manager("top-busy@example.com", "Busy") - self.create_event_owned_by(quiet, "Quiet One") - self.create_event_owned_by(busy, "Busy One") - self.create_event_owned_by(busy, "Busy Two") + quiet = self.create_manager("top-quiet@example.com") + busy = self.create_manager("top-busy@example.com") + self.create_event_owned_by(quiet) + self.create_event_owned_by(busy) + self.create_event_owned_by(busy) create_default_teams() @@ -68,10 +48,10 @@ def test_owner_is_the_manager_who_created_the_most_events(self): self.assertEqual(self.role_in(team, quiet), "Admin") def test_an_equal_event_count_goes_to_the_earlier_manager(self): - first = self.create_manager("tie-first@example.com", "First") - second = self.create_manager("tie-second@example.com", "Second") - self.create_event_owned_by(second, "Tie One") - self.create_event_owned_by(first, "Tie Two") + first = self.create_manager("tie-first@example.com") + second = self.create_manager("tie-second@example.com") + self.create_event_owned_by(second) + self.create_event_owned_by(first) create_default_teams() @@ -80,8 +60,8 @@ def test_an_equal_event_count_goes_to_the_earlier_manager(self): self.assertEqual(self.role_in(team, second), "Admin") def test_a_site_without_events_falls_back_to_the_earlier_manager(self): - first = self.create_manager("no-events-first@example.com", "First") - second = self.create_manager("no-events-second@example.com", "Second") + first = self.create_manager("no-events-first@example.com") + second = self.create_manager("no-events-second@example.com") create_default_teams() @@ -90,8 +70,8 @@ def test_a_site_without_events_falls_back_to_the_earlier_manager(self): self.assertEqual(self.role_in(team, second), "Admin") def test_a_disabled_manager_gets_no_membership(self): - active = self.create_manager("lapsed-active@example.com", "Active") - lapsed = self.create_manager("lapsed-manager@example.com", "Lapsed") + active = self.create_manager("lapsed-active@example.com") + lapsed = self.create_manager("lapsed-manager@example.com") frappe.db.set_value("User", lapsed, "enabled", 0) self.addCleanup(frappe.db.set_value, "User", lapsed, "enabled", 1) @@ -102,10 +82,10 @@ def test_a_disabled_manager_gets_no_membership(self): def test_administrator_neither_owns_nor_joins_the_team(self): frappe.get_doc("User", "Administrator").add_roles("Event Manager") - self.addCleanup(self.retire, "Administrator") - manager = self.create_manager("admin-excluded@example.com", "Excluded") - colleague = self.create_manager("admin-excluded-peer@example.com", "Peer") - self.create_event_owned_by("Administrator", "Administrator's Own") + self.addCleanup(self.remove_event_manager_role, "Administrator") + manager = self.create_manager("admin-excluded@example.com") + colleague = self.create_manager("admin-excluded-peer@example.com") + self.create_event_owned_by("Administrator") create_default_teams() @@ -122,9 +102,9 @@ def test_a_site_without_managers_gets_no_team(self): self.assertEqual(frappe.db.count("Buzz Team"), teams) def test_a_rerun_adds_no_team_and_demotes_no_one(self): - owner = self.create_manager("rerun-owner@example.com", "Owner") - other = self.create_manager("rerun-other@example.com", "Other") - self.create_event_owned_by(owner, "Rerun") + owner = self.create_manager("rerun-owner@example.com") + other = self.create_manager("rerun-other@example.com") + self.create_event_owned_by(owner) create_default_teams() teams = frappe.db.count("Buzz Team") @@ -136,18 +116,33 @@ def test_a_rerun_adds_no_team_and_demotes_no_one(self): self.assertEqual(self.role_in(self.team_of(owner), owner), "Owner") self.assertEqual(self.role_in(self.team_of(other), other), "Admin") + def create_manager(self, email: str) -> str: + user = UserFactory.create_once(email) + user.add_roles("Event Manager") + return user.name -class TestAssignDefaultTeam(IntegrationTestCase): - def setUp(self): - frappe.set_user("Administrator") + @staticmethod + def remove_event_manager_role(user: str) -> None: + frappe.get_doc("User", user).remove_roles("Event Manager") + + def create_event_owned_by(self, user: str) -> str: + event = BuzzEventFactory.create(team=self.team) + frappe.db.set_value("Buzz Event", event.name, "owner", user, update_modified=False) + return event.name - def create_teamless_venue(self, name: str) -> str: - venue = frappe.get_doc(payload_for("Event Venue", name)).insert(ignore_permissions=True) - frappe.db.set_value("Event Venue", venue.name, "team", None, update_modified=False) - return venue.name + def team_of(self, user: str) -> str | None: + return frappe.db.get_value("Buzz Team Membership", {"user": user, "enabled": 1}, "team") + def role_in(self, team: str, user: str) -> str | None: + return frappe.db.get_value( + "Buzz Team Membership", {"team": team, "user": user, "enabled": 1}, "team_role" + ) + + +class TestAssignDefaultTeam(IntegrationTestCase): def test_backfills_teamless_rows_and_is_idempotent(self): - venue = self.create_teamless_venue("Legacy") + venue = EventVenueFactory.create().name + frappe.db.set_value("Event Venue", venue, "team", None, update_modified=False) backfill_teams() assigned = frappe.db.get_value("Event Venue", venue, "team") @@ -167,30 +162,31 @@ def test_a_site_without_teamless_rows_gets_no_fallback_team(self): class TestCreateAdministratorTeam(IntegrationTestCase): - def setUp(self): - frappe.set_user("Administrator") - - def clear_teams(self): - frappe.db.savepoint("before_clearing_teams") - self.addCleanup(frappe.db.rollback, save_point="before_clearing_teams") - for doctype in ("Buzz Team Membership", "Buzz Team Settings", "Buzz Team"): - frappe.db.delete(doctype) - def test_a_site_without_teams_gets_one_owned_by_administrator(self): self.clear_teams() create_administrator_team() - team = frappe.db.get_value( - "Buzz Team Membership", {"user": "Administrator", "team_role": "Owner"}, "team" - ) - self.assertTrue(team) + self.assertTrue(self.administrator_team()) self.assertEqual(frappe.db.count("Buzz Team"), 1) def test_a_site_with_teams_gets_no_new_team(self): self.clear_teams() - frappe.get_doc({"doctype": "Buzz Team", "team_name": "Existing Team"}).insert() + BuzzTeamFactory.create_owned_by() + teams = frappe.db.count("Buzz Team") create_administrator_team() - self.assertEqual(frappe.db.count("Buzz Team"), 1) + self.assertEqual(frappe.db.count("Buzz Team"), teams) + self.assertIsNone(self.administrator_team()) + + def clear_teams(self): + frappe.db.savepoint("before_clearing_teams") + self.addCleanup(frappe.db.rollback, save_point="before_clearing_teams") + for doctype in ("Buzz Team Membership", "Buzz Team Settings", "Buzz Team"): + frappe.db.delete(doctype) + + def administrator_team(self) -> str | None: + return frappe.db.get_value( + "Buzz Team Membership", {"user": "Administrator", "team_role": "Owner"}, "team" + ) diff --git a/buzz/test_permissions.py b/buzz/test_permissions.py index 9576b26d..26430d6f 100644 --- a/buzz/test_permissions.py +++ b/buzz/test_permissions.py @@ -2,264 +2,113 @@ # See license.txt import frappe -from frappe.desk.search import search_link -from frappe.tests import IntegrationTestCase -from buzz.api.checkin.exceptions import TicketNotFound -from buzz.api.exceptions import NotPermitted -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user, payload_for -from buzz.permissions import team_query_conditions +from buzz.tests.base_test_cases import TeamPermissionTestCase +from buzz.tests.factories import ( + BuzzTeamMembershipFactory, + EventTicketFactory, + UserFactory, +) -# Buzz Team Membership carries a team column but is scoped by its own pair of hooks. -SELF_SCOPED_DOCTYPES = frozenset({"Buzz Team Membership"}) - -def linked_to(doctype: str) -> set[str]: - """Doctypes with a Link field named after `doctype`'s short name, e.g. `team` -> Buzz Team.""" - fieldname = doctype.replace("Buzz ", "").lower() - parents = frappe.get_all( - "DocField", - filters={"fieldname": fieldname, "fieldtype": "Link", "options": doctype}, - pluck="parent", - ) - return {parent for parent in parents if not frappe.get_meta(parent).istable} - - -def tenant_doctypes() -> set[str]: - """Every doctype a team owns, derived from the schema rather than a second hardcoded list.""" - return (linked_to("Buzz Team") | linked_to("Buzz Event")) - SELF_SCOPED_DOCTYPES +class RoleTestCase(TeamPermissionTestCase): + @classmethod + def add_members(cls, team: str, *members: tuple[str, str]) -> list[str]: + return [ + BuzzTeamMembershipFactory.create( + team=team, user=UserFactory.create_once(email).name, team_role=team_role + ).name + for email, team_role in members + ] -def add_member(team: str, user: str, team_role: str) -> str: - return ( - frappe.get_doc( - {"doctype": "Buzz Team Membership", "team": team, "user": user, "team_role": team_role} - ) - .insert(ignore_permissions=True) - .name - ) - - -def create_event(title: str, team: str, is_published: int = 0) -> str: - payload = payload_for("Buzz Event", title) - return ( - frappe.get_doc({**payload, "team": team, "is_published": is_published}) - .insert(ignore_permissions=True) - .name - ) - - -def create_ticket_type(event: str) -> str: - return ( - frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": event, - "title": f"Type {frappe.generate_hash(length=6)}", - } - ) - .insert(ignore_permissions=True) - .name - ) - - -def create_booking(event: str, user: str, owner: str | None = None) -> str: - # Event Booking.validate prices the attendee rows, so it needs at least one. - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": event, - "user": user, - "attendees": [ - {"ticket_type": create_ticket_type(event), "first_name": "Attendee", "email": user} - ], - } - ).insert(ignore_permissions=True) - frappe.db.set_value("Event Booking", booking.name, "owner", owner or user, update_modified=False) - return booking.name - - -def create_ticket( - event: str, - owner: str, - attendee_email: str | None = None, - booking: str | None = None, - submit: bool = False, -) -> str: - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "event": event, - "ticket_type": create_ticket_type(event), - "attendee_name": "Attendee", - "attendee_email": attendee_email or owner, - "booking": booking, - } - ).insert(ignore_permissions=True) - if submit: - ticket.submit() - frappe.db.set_value("Event Ticket", ticket.name, "owner", owner, update_modified=False) - return ticket.name - - -class TeamPermissionTestCase(IntegrationTestCase): +class TestRoleMatrix(RoleTestCase): @classmethod def setUpClass(cls): super().setUpClass() - frappe.set_user("Administrator") - - cls.alice = create_user("perm-alice@example.com", "Alice") - cls.bob = create_user("perm-bob@example.com", "Bob") - cls.outsider = create_user("perm-outsider@example.com", "Outsider") - - cls.team_a = create_owned_team("Perm Team A", cls.alice) - cls.team_b = create_owned_team("Perm Team B", cls.bob) - - cls.event_a = create_event("Perm A", cls.team_a) - cls.event_b = create_event("Perm B", cls.team_b) - - def setUp(self): - frappe.set_user("Administrator") - self.addCleanup(frappe.set_user, "Administrator") - - def as_user(self, user: str): - frappe.set_user(user) - - -class TestCrossTeamIsolation(TeamPermissionTestCase): - def listed_names(self, doctype: str, **kwargs) -> list[str]: - return frappe.get_list(doctype, pluck="name", **kwargs) - - def test_team_direct_lists_exclude_other_teams(self): - self.as_user(self.alice) - - for doctype in linked_to("Buzz Team") - SELF_SCOPED_DOCTYPES: - with self.subTest(doctype=doctype): - teams = frappe.get_list(doctype, pluck="team") - - self.assertNotIn(self.team_b, teams) - - def test_event_derived_lists_exclude_other_teams(self): - ticket_type = frappe.get_doc( - {"doctype": "Event Ticket Type", "event": self.event_b, "title": "Hidden"} - ).insert(ignore_permissions=True) - - self.as_user(self.alice) - - self.assertNotIn(ticket_type.name, self.listed_names("Event Ticket Type")) - - def test_team_lists_exclude_other_teams(self): - self.as_user(self.alice) - teams = self.listed_names("Buzz Team") - - self.assertIn(self.team_a, teams) - self.assertNotIn(self.team_b, teams) - - def test_membership_lists_exclude_other_teams(self): - self.as_user(self.alice) - - self.assertNotIn(self.team_b, frappe.get_list("Buzz Team Membership", pluck="team")) - - def test_every_tenant_doctype_is_wired_to_both_hooks(self): - # Catches a new team-owned doctype that nobody registered in hooks.py. - query_conditions = frappe.get_hooks("permission_query_conditions") - has_permission = frappe.get_hooks("has_permission") - - for doctype in tenant_doctypes(): - with self.subTest(doctype=doctype): - self.assertTrue(query_conditions.get(doctype)) - self.assertTrue(has_permission.get(doctype)) + cls.viewer, cls.manager, cls.admin = ( + "perm-viewer@example.com", + "perm-manager@example.com", + "perm-admin@example.com", + ) + cls.add_members(cls.team_a, (cls.viewer, "Viewer"), (cls.manager, "Manager"), (cls.admin, "Admin")) - def test_every_query_condition_is_valid_sql(self): - # Whatever these hooks return is stringified into the WHERE clause, so a - # criterion rendered in pypika's default dialect reaches the database as-is. - hooks = frappe.get_hooks("permission_query_conditions", app_name="buzz") + def test_viewer_reads_but_cannot_write(self): + self.assertTrue(self.has_event_permission(self.viewer, "read")) + self.assertFalse(self.has_event_permission(self.viewer, "write")) - for doctype, methods in hooks.items(): - for method in methods: - with self.subTest(doctype=doctype): - condition = frappe.call(frappe.get_attr(method), self.alice, doctype=doctype) - self.assertIsInstance(condition, str) - frappe.db.sql(f"SELECT `name` FROM `tab{doctype}` WHERE {condition} LIMIT 1") + def test_manager_writes_but_cannot_delete(self): + self.assertTrue(self.has_event_permission(self.manager, "write")) + self.assertFalse(self.has_event_permission(self.manager, "delete")) - def test_opening_another_teams_event_is_denied(self): - self.as_user(self.alice) + def test_admin_deletes(self): + self.assertTrue(self.has_event_permission(self.admin, "delete")) - with self.assertRaises(frappe.PermissionError): - frappe.get_doc("Buzz Event", self.event_b).check_permission("read") + def test_manager_cannot_write_another_teams_event(self): + self.assertFalse(self.has_permission_as(self.manager, "write", "Buzz Event", self.event_b)) - def test_opening_another_teams_derived_doc_is_denied(self): - ticket_type = frappe.get_doc( - {"doctype": "Event Ticket Type", "event": self.event_b, "title": "Denied"} - ).insert(ignore_permissions=True) + def has_event_permission(self, user: str, ptype: str) -> bool: + return self.has_permission_as(user, ptype, "Buzz Event", self.event_a) - self.as_user(self.alice) - with self.assertRaises(frappe.PermissionError): - frappe.get_doc("Event Ticket Type", ticket_type.name).check_permission("read") +class TestTeamSettingsPermissions(RoleTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.manager, cls.admin = "perm-settings-manager@example.com", "perm-settings-admin@example.com" + cls.add_members(cls.team_a, (cls.manager, "Manager"), (cls.admin, "Admin")) - def test_system_manager_is_unrestricted(self): - self.assertIn(self.event_b, self.listed_names("Buzz Event")) + def test_manager_reads_but_cannot_write(self): + self.assertTrue(self.has_settings_permission(self.manager, "read")) + self.assertFalse(self.has_settings_permission(self.manager, "write")) - def test_unstamped_rows_stay_readable_by_system_managers_only(self): - orphan = create_event("Perm Orphan", self.team_a) - frappe.db.set_value("Buzz Event", orphan, "team", None, update_modified=False) + def test_admin_writes(self): + self.assertTrue(self.has_settings_permission(self.admin, "write")) - self.assertIn(orphan, self.listed_names("Buzz Event")) + def test_non_member_is_refused(self): + self.assertFalse(self.has_settings_permission(self.outsider, "read")) - self.as_user(self.alice) + def test_another_teams_settings_are_not_listed(self): + self.assertNotIn(self.team_b, self.list_as(self.alice, "Buzz Team Settings", pluck="team")) - self.assertNotIn(orphan, self.listed_names("Buzz Event")) - # has_permission tolerates the missing team so a half-migrated site does not hard-break. - frappe.get_doc("Buzz Event", orphan).check_permission("read") + def has_settings_permission(self, user: str, ptype: str) -> bool: + return self.has_permission_as(user, ptype, "Buzz Team Settings", self.team_a) -class TestMultiTeamMembership(TeamPermissionTestCase): +class TestMultiTeamMembership(RoleTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.single_team_user = create_user("perm-one-team@example.com", "Single") - add_member(cls.team_a, cls.single_team_user, "Manager") - - cls.both_teams_user = create_user("perm-both-teams@example.com", "Both") - add_member(cls.team_a, cls.both_teams_user, "Manager") - cls.membership_b = add_member(cls.team_b, cls.both_teams_user, "Manager") - - def listed_events(self) -> list[str]: - return frappe.get_list("Buzz Event", pluck="name") + cls.single_team_user = "perm-one-team@example.com" + cls.both_teams_user = "perm-both-teams@example.com" + cls.add_members(cls.team_a, (cls.single_team_user, "Manager"), (cls.both_teams_user, "Manager")) + [cls.membership_b] = cls.add_members(cls.team_b, (cls.both_teams_user, "Manager")) def test_single_team_member_sees_their_team_and_only_their_team(self): - self.as_user(self.single_team_user) - events = self.listed_events() + events = self.list_as(self.single_team_user, "Buzz Event") self.assertIn(self.event_a, events) self.assertNotIn(self.event_b, events) def test_single_team_member_writes_their_team_only(self): - self.as_user(self.single_team_user) - - self.assertTrue(frappe.has_permission("Buzz Event", "write", doc=self.event_a)) - self.assertFalse(frappe.has_permission("Buzz Event", "write", doc=self.event_b)) + self.assertTrue(self.has_permission_as(self.single_team_user, "write", "Buzz Event", self.event_a)) + self.assertFalse(self.has_permission_as(self.single_team_user, "write", "Buzz Event", self.event_b)) def test_member_of_both_teams_sees_both(self): - self.as_user(self.both_teams_user) - events = self.listed_events() + events = self.list_as(self.both_teams_user, "Buzz Event") self.assertIn(self.event_a, events) self.assertIn(self.event_b, events) def test_member_of_both_teams_writes_both(self): - self.as_user(self.both_teams_user) - - self.assertTrue(frappe.has_permission("Buzz Event", "write", doc=self.event_a)) - self.assertTrue(frappe.has_permission("Buzz Event", "write", doc=self.event_b)) + self.assertTrue(self.has_permission_as(self.both_teams_user, "write", "Buzz Event", self.event_a)) + self.assertTrue(self.has_permission_as(self.both_teams_user, "write", "Buzz Event", self.event_b)) def test_member_of_both_teams_sees_derived_rows_from_both(self): - ours = create_ticket(self.event_a, self.alice) - theirs = create_ticket(self.event_b, self.bob) + ours = EventTicketFactory.create(event=self.event_a).name + theirs = EventTicketFactory.create(event=self.event_b).name - self.as_user(self.both_teams_user) - tickets = frappe.get_list("Event Ticket", pluck="name") + tickets = self.list_as(self.both_teams_user, "Event Ticket") self.assertIn(ours, tickets) self.assertIn(theirs, tickets) @@ -267,395 +116,10 @@ def test_member_of_both_teams_sees_derived_rows_from_both(self): def test_disabling_one_membership_leaves_the_other_intact(self): membership = frappe.get_doc("Buzz Team Membership", self.membership_b) membership.enabled = 0 - membership.save(ignore_permissions=True) + membership.save() self.addCleanup(frappe.db.set_value, "Buzz Team Membership", self.membership_b, "enabled", 1) - self.as_user(self.both_teams_user) - events = self.listed_events() + events = self.list_as(self.both_teams_user, "Buzz Event") self.assertIn(self.event_a, events) self.assertNotIn(self.event_b, events) - - -class TestTeamLinkQuery(TeamPermissionTestCase): - def searched(self, txt: str = "") -> list[str]: - results = search_link("Buzz Team", txt, reference_doctype="Buzz Event") - return [result["value"] for result in results] - - def test_link_search_offers_only_the_users_teams(self): - self.as_user(self.alice) - teams = self.searched() - - self.assertIn(self.team_a, teams) - self.assertNotIn(self.team_b, teams) - - def test_link_search_matches_the_team_name(self): - self.as_user(self.alice) - - self.assertEqual(self.searched("Perm Team A"), [self.team_a]) - self.assertEqual(self.searched("Perm Team B"), []) - - def test_system_manager_is_narrowed_here_despite_reading_every_team(self): - # Administrator is on neither team, but the permission hooks let it list both. - self.assertIn(self.team_b, frappe.get_list("Buzz Team", pluck="name")) - - self.assertNotIn(self.team_b, self.searched()) - - def test_non_member_gets_nothing(self): - self.as_user(self.outsider) - - self.assertEqual(self.searched(), []) - - -class TestRoleMatrix(TeamPermissionTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.viewer = create_user("perm-viewer@example.com", "Viewer") - cls.manager = create_user("perm-manager@example.com", "Manager") - cls.admin = create_user("perm-admin@example.com", "Admin") - - for user, team_role in ((cls.viewer, "Viewer"), (cls.manager, "Manager"), (cls.admin, "Admin")): - add_member(cls.team_a, user, team_role) - - def can(self, user: str, ptype: str) -> bool: - self.as_user(user) - return frappe.has_permission("Buzz Event", ptype, doc=self.event_a) - - def test_viewer_reads_but_cannot_write(self): - self.assertTrue(self.can(self.viewer, "read")) - self.assertFalse(self.can(self.viewer, "write")) - - def test_manager_writes_but_cannot_delete(self): - self.assertTrue(self.can(self.manager, "write")) - self.assertFalse(self.can(self.manager, "delete")) - - def test_admin_deletes(self): - self.assertTrue(self.can(self.admin, "delete")) - - def test_manager_cannot_write_another_teams_event(self): - self.as_user(self.manager) - - self.assertFalse(frappe.has_permission("Buzz Event", "write", doc=self.event_b)) - - -class TestNonMemberCarveOuts(TeamPermissionTestCase): - def test_attendee_lists_own_ticket_without_a_membership(self): - mine = create_ticket(self.event_b, self.outsider) - theirs = create_ticket(self.event_b, self.bob) - - self.as_user(self.outsider) - tickets = frappe.get_list("Event Ticket", pluck="name") - - self.assertIn(mine, tickets) - self.assertNotIn(theirs, tickets) - - def test_attendee_lists_a_ticket_someone_else_created(self): - mine = create_ticket(self.event_b, owner=self.bob, attendee_email=self.outsider) - - self.as_user(self.outsider) - - self.assertIn(mine, frappe.get_list("Event Ticket", pluck="name")) - - def test_booker_lists_a_ticket_held_by_someone_else(self): - booking = create_booking(self.event_b, user=self.outsider) - theirs = create_ticket( - self.event_b, owner=self.bob, attendee_email="perm-guest@example.com", booking=booking - ) - - self.as_user(self.outsider) - - self.assertIn(theirs, frappe.get_list("Event Ticket", pluck="name")) - - def test_an_unrelated_user_sees_neither_the_ticket_nor_the_booking(self): - booking = create_booking(self.event_b, user=self.bob) - by_attendee = create_ticket(self.event_b, owner=self.bob, attendee_email=self.alice) - by_booking = create_ticket(self.event_b, owner=self.bob, attendee_email=self.alice, booking=booking) - - self.as_user(self.outsider) - tickets = frappe.get_list("Event Ticket", pluck="name") - - self.assertNotIn(by_attendee, tickets) - self.assertNotIn(by_booking, tickets) - self.assertNotIn(booking, frappe.get_list("Event Booking", pluck="name")) - - def test_an_unrelated_user_cannot_open_someone_elses_ticket(self): - theirs = create_ticket(self.event_b, owner=self.bob, attendee_email=self.alice) - - self.as_user(self.outsider) - - with self.assertRaises(frappe.PermissionError): - frappe.get_doc("Event Ticket", theirs).check_permission("read") - - def test_an_unstamped_event_does_not_open_its_tickets_to_a_stranger(self): - orphan = create_event("Perm Unstamped", self.team_b) - theirs = create_ticket(orphan, owner=self.bob, attendee_email=self.alice) - frappe.db.set_value("Buzz Event", orphan, "team", None, update_modified=False) - - self.as_user(self.outsider) - - self.assertNotIn(theirs, frappe.get_list("Event Ticket", pluck="name")) - with self.assertRaises(frappe.PermissionError): - frappe.get_doc("Event Ticket", theirs).check_permission("read") - - def test_attendee_reads_but_cannot_write_their_ticket(self): - mine = create_ticket(self.event_b, owner=self.bob, attendee_email=self.outsider) - - self.as_user(self.outsider) - - self.assertTrue(frappe.has_permission("Event Ticket", "read", doc=mine)) - self.assertFalse(frappe.has_permission("Event Ticket", "write", doc=mine)) - - def test_buyer_lists_a_guest_checkout_booking(self): - # Guest checkout runs as Administrator, so `owner` never names the buyer. - booking = create_booking(self.event_b, user=self.outsider, owner="Administrator") - - self.as_user(self.outsider) - - self.assertIn(booking, frappe.get_list("Event Booking", pluck="name")) - - def test_buyer_reads_their_own_booking_but_writes_nothing(self): - # Bookings are only ever written by the service flow and by organisers. The - # portal reads them; transfers and cancellations go through their own doctypes. - mine = create_booking(self.event_b, user=self.outsider, owner="Administrator") - theirs = create_booking(self.event_b, user=self.bob) - - self.as_user(self.outsider) - - self.assertTrue(frappe.has_permission("Event Booking", "read", doc=mine)) - self.assertFalse(frappe.has_permission("Event Booking", "write", doc=mine)) - self.assertFalse(frappe.has_permission("Event Booking", "write", doc=theirs)) - - def test_nobody_deletes_a_booking_from_the_portal(self): - # Bookings are cancelled, never deleted — the role carries no delete at all, so this - # holds for the buyer's own row as much as for a stranger's. - mine = create_booking(self.event_b, user=self.outsider) - guest_checkout = create_booking(self.event_b, user=self.outsider, owner="Guest") - theirs = create_booking(self.event_b, user=self.bob) - - self.as_user(self.outsider) - - for booking in (mine, guest_checkout, theirs): - self.assertFalse(frappe.has_permission("Event Booking", "delete", doc=booking)) - with self.assertRaises(frappe.PermissionError): - frappe.delete_doc("Event Booking", booking) - - def test_attendee_gets_no_carve_out_on_payments(self): - self.as_user(self.outsider) - - with self.assertRaises(frappe.PermissionError): - frappe.get_list("Event Payment", pluck="name") - - def test_published_sponsorship_tier_is_visible_to_non_members(self): - published = create_event("Perm Published", self.team_b, is_published=1) - visible = frappe.get_doc( - {"doctype": "Sponsorship Tier", "event": published, "title": "Gold", "prices": [{"price": 1}]} - ).insert(ignore_permissions=True) - hidden = frappe.get_doc( - { - "doctype": "Sponsorship Tier", - "event": self.event_b, - "title": "Silver", - "prices": [{"price": 1}], - } - ).insert(ignore_permissions=True) - - self.as_user(self.outsider) - tiers = frappe.get_list("Sponsorship Tier", pluck="name") - - self.assertIn(visible.name, tiers) - self.assertNotIn(hidden.name, tiers) - - def test_published_events_stay_visible_to_non_members(self): - published = create_event("Perm Public", self.team_b, is_published=1) - - self.as_user(self.outsider) - - self.assertIn(published, frappe.get_list("Buzz Event", pluck="name")) - self.assertNotIn(self.event_b, frappe.get_list("Buzz Event", pluck="name")) - - def test_guest_event_reads_are_never_narrowed(self): - self.assertIsNone(team_query_conditions(user="Guest", doctype="Buzz Event")) - - -class TicketHolderTestCase(TeamPermissionTestCase): - """A ticket is owned by whoever booked it, which is rarely the attendee holding it.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.booker = create_user("perm-booker@example.com", "Booker") - cls.holder = create_user("perm-holder@example.com", "Holder") - cls.stranger = create_user("perm-stranger@example.com", "Stranger") - cls.viewer = create_user("perm-viewer@example.com", "Viewer") - add_member(cls.team_b, cls.viewer, "Viewer") - - def listed_tickets(self, user: str) -> list[str]: - self.as_user(user) - return frappe.get_list("Event Ticket", pluck="name") - - def held_ticket(self, owner: str | None = None) -> str: - return create_ticket(self.event_b, owner or self.booker, attendee_email=self.holder, submit=True) - - -class TestTicketHolderVisibility(TicketHolderTestCase): - def test_holder_sees_a_guest_booked_ticket(self): - ticket = self.held_ticket(owner="Guest") - - self.assertIn(ticket, self.listed_tickets(self.holder)) - - def test_booker_still_sees_the_tickets_they_created(self): - ticket = self.held_ticket() - - self.assertIn(ticket, self.listed_tickets(self.booker)) - - def test_team_member_reads_every_ticket_for_their_event(self): - ticket = self.held_ticket() - - self.as_user(self.bob) - self.assertTrue(frappe.has_permission("Event Ticket", "read", doc=ticket)) - - def test_viewer_sees_their_teams_tickets(self): - ticket = self.held_ticket() - - self.assertIn(ticket, self.listed_tickets(self.viewer)) - - -class TestTicketImmutability(TicketHolderTestCase): - """Reading a ticket you hold is a carve-out. Changing one is not.""" - - def test_holder_cannot_cancel_or_delete_their_ticket(self): - ticket = self.held_ticket() - - self.as_user(self.holder) - self.assertFalse(frappe.has_permission("Event Ticket", "cancel", doc=ticket)) - self.assertFalse(frappe.has_permission("Event Ticket", "delete", doc=ticket)) - - def test_holder_saving_their_ticket_is_refused(self): - ticket = create_ticket(self.event_b, self.booker, attendee_email=self.holder) - - self.as_user(self.holder) - doc = frappe.get_doc("Event Ticket", ticket) - doc.attendee_name = "Renamed" - - with self.assertRaises(frappe.PermissionError): - doc.save() - - def test_stranger_cannot_write_another_persons_ticket(self): - ticket = self.held_ticket() - - self.as_user(self.stranger) - self.assertFalse(frappe.has_permission("Event Ticket", "write", doc=ticket)) - - def test_submitted_ticket_is_frozen_even_for_administrator(self): - doc = frappe.get_doc("Event Ticket", self.held_ticket()) - doc.event = self.event_a - - with self.assertRaises(frappe.UpdateAfterSubmitError): - doc.save() - - def test_only_the_transfer_fields_stay_editable_after_submit(self): - editable = { - field.fieldname for field in frappe.get_meta("Event Ticket").fields if field.allow_on_submit - } - - # The ticket transfer flow needs these four and nothing else. - self.assertEqual(editable, {"first_name", "last_name", "attendee_name", "attendee_email"}) - - -class TestTeamSettingsPermissions(TeamPermissionTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.manager = create_user("perm-settings-manager@example.com", "Manager") - add_member(cls.team_a, cls.manager, "Manager") - cls.admin = create_user("perm-settings-admin@example.com", "Admin") - add_member(cls.team_a, cls.admin, "Admin") - - def can(self, user: str, ptype: str) -> bool: - self.as_user(user) - return frappe.has_permission("Buzz Team Settings", ptype, doc=self.team_a) - - def test_manager_reads_but_cannot_write(self): - self.assertTrue(self.can(self.manager, "read")) - self.assertFalse(self.can(self.manager, "write")) - - def test_admin_writes(self): - self.assertTrue(self.can(self.admin, "write")) - - def test_non_member_is_refused(self): - self.assertFalse(self.can(self.outsider, "read")) - - def test_another_teams_settings_are_not_listed(self): - self.as_user(self.alice) - - self.assertNotIn(self.team_b, frappe.get_list("Buzz Team Settings", pluck="team")) - - -class TestTalkProposalComposition(TeamPermissionTestCase): - def create_proposal(self, event: str, submitted_by: str) -> str: - proposal = frappe.get_doc( - { - "doctype": "Talk Proposal", - "event": event, - "title": f"Proposal {frappe.generate_hash(length=6)}", - "submitted_by": submitted_by, - "status": frappe.db.get_value("Talk Proposal Status", {}, "name"), - "speakers": [{"first_name": "Speaker", "email": submitted_by}], - } - ) - return proposal.insert(ignore_permissions=True).name - - def test_speaker_sees_own_proposal_without_a_membership(self): - mine = self.create_proposal(self.event_b, self.outsider) - - self.as_user(self.outsider) - - self.assertIn(mine, frappe.get_list("Talk Proposal", pluck="name")) - - def test_team_member_sees_their_teams_proposals_only(self): - ours = self.create_proposal(self.event_a, self.outsider) - theirs = self.create_proposal(self.event_b, self.outsider) - - self.as_user(self.alice) - proposals = frappe.get_list("Talk Proposal", pluck="name") - - self.assertIn(ours, proposals) - self.assertNotIn(theirs, proposals) - - -class TestCheckinIsolation(TeamPermissionTestCase): - def test_frontdesk_cannot_check_in_another_teams_ticket(self): - from buzz.api.checkin import validate_ticket_for_checkin - - frontdesk = create_user("perm-frontdesk@example.com", "Frontdesk") - add_member(self.team_a, frontdesk, "Frontdesk") - - ticket_type = frappe.get_doc( - {"doctype": "Event Ticket Type", "event": self.event_b, "title": "Scan"} - ).insert(ignore_permissions=True) - ticket = frappe.get_doc( - { - "doctype": "Event Ticket", - "event": self.event_b, - "ticket_type": ticket_type.name, - "attendee_name": "Other Team", - "attendee_email": "other-team@example.com", - } - ).insert(ignore_permissions=True) - - self.as_user(frontdesk) - - with self.assertRaises(NotPermitted): - validate_ticket_for_checkin(ticket.name) - - def test_unknown_ticket_still_raises_not_found(self): - from buzz.api.checkin import validate_ticket_for_checkin - - frontdesk = create_user("perm-frontdesk-2@example.com", "Frontdesk") - add_member(self.team_a, frontdesk, "Frontdesk") - - self.as_user(frontdesk) - - with self.assertRaises(TicketNotFound): - validate_ticket_for_checkin("no-such-ticket") diff --git a/buzz/test_telemetry.py b/buzz/test_telemetry.py index 341c9c2a..cfb49c15 100644 --- a/buzz/test_telemetry.py +++ b/buzz/test_telemetry.py @@ -1,40 +1,13 @@ import json -from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import patch import frappe -from frappe.tests import IntegrationTestCase +from frappe.tests import IntegrationTestCase, UnitTestCase from buzz import telemetry, telemetry_scan -from buzz.api.checkin import checkin_ticket -from buzz.www import dashboard - - -@contextmanager -def capturing(): - """Telemetry on, and whatever was captured sent as if the transaction committed.""" - with ( - patch("buzz.telemetry.is_enabled", return_value=True), - patch("buzz.telemetry.frappe_capture") as mock_capture, - ): - yield mock_capture - frappe.db.after_commit.run() - - -def captured(mock_capture) -> list[tuple[str, dict]]: - return [(call.args[0], call.kwargs.get("properties", {})) for call in mock_capture.call_args_list] - - -def captured_names(mock_capture) -> list[str]: - return [name for name, _properties in captured(mock_capture)] - - -def properties_of(mock_capture, event: str) -> dict: - matches = [properties for name, properties in captured(mock_capture) if name == event] - if len(matches) != 1: - raise AssertionError(f"expected one {event}, got {captured_names(mock_capture)}") - return matches[0] +from buzz.tests.factories import BuzzEventFactory +from buzz.tests.utils import capturing, properties_of class TestTelemetryWrapper(IntegrationTestCase): @@ -85,6 +58,8 @@ def test_capture_waits_for_commit(self): mock_capture.assert_called_once() frappe.db.after_commit.reset() + +class TestTelemetryHelpers(UnitTestCase): def test_entry_from_referrer(self): cases = { "http://site/app/buzz-event/1": "desk", @@ -96,21 +71,17 @@ def test_entry_from_referrer(self): "http://site/events/my-event": "website", "": "api", } - original_request = getattr(frappe.local, "request", None) - try: - for referrer, expected in cases.items(): - with self.subTest(referrer=referrer): - frappe.local.request = SimpleNamespace(headers={"Referer": referrer}) - self.assertEqual(telemetry.get_entry(), expected) - finally: - frappe.local.request = original_request + self.addCleanup(setattr, frappe.local, "request", getattr(frappe.local, "request", None)) + for referrer, expected in cases.items(): + with self.subTest(referrer=referrer): + frappe.local.request = SimpleNamespace(headers={"Referer": referrer}) + self.assertEqual(telemetry.get_entry(), expected) def test_entry_for_import(self): frappe.flags.in_import = True - try: - self.assertEqual(telemetry.get_entry(), "import") - finally: - frappe.flags.in_import = False + self.addCleanup(setattr, frappe.flags, "in_import", False) + + self.assertEqual(telemetry.get_entry(), "import") def test_count_bucket(self): cases = { @@ -129,306 +100,12 @@ def test_count_bucket(self): self.assertEqual(telemetry.count_bucket(count), expected) -class TestTelemetryEvents(IntegrationTestCase): +class TestSiteProfile(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - def setUp(self): - frappe.set_user("Administrator") - - def new_event(self, **fields): - return frappe.get_doc( - { - "doctype": "Buzz Event", - "category": self.test_event.category, - "host": self.test_event.host, - "team": self.test_event.team, - "title": f"Telemetry {frappe.generate_hash(length=6)}", - "start_date": frappe.utils.today(), - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "is_published": 0, - **fields, - } - ) - - def free_ticket_type(self): - return frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": f"Telemetry Free {frappe.generate_hash(length=6)}", - } - ).insert() - - def confirmed_booking(self, attendee_count=1): - ticket_type = self.free_ticket_type() - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "attendees": [ - { - "ticket_type": ticket_type.name, - "first_name": f"Guest {index}", - "email": f"telemetry-{index}@example.com", - } - for index in range(attendee_count) - ], - } - ).insert() - booking.submit() - return booking - - def test_event_created_from_blank(self): - with capturing() as mock_capture: - self.new_event().insert() - - self.assertEqual( - properties_of(mock_capture, "event_created"), - { - **telemetry.shared_properties(), - "source": "blank", - "medium": "Online", - "free_event": False, - "published": False, - }, - ) - self.assertNotIn("event_published", captured_names(mock_capture)) - - def test_event_published_at_creation(self): - with capturing() as mock_capture: - self.new_event(is_published=1).insert() - - self.assertTrue(properties_of(mock_capture, "event_created")["published"]) - self.assertEqual(properties_of(mock_capture, "event_published")["medium"], "Online") - - def test_event_created_from_template(self): - event = self.new_event() - event.flags.from_template = True - with capturing() as mock_capture: - event.insert() - - self.assertEqual(properties_of(mock_capture, "event_created")["source"], "template") - - def test_event_published_fires_once_when_published(self): - event = self.new_event().insert() - - with capturing() as mock_capture: - event.is_published = 1 - event.save() - event.title = f"{event.title} renamed" - event.save() - - self.assertEqual(captured_names(mock_capture), ["event_published"]) - self.assertEqual(properties_of(mock_capture, "event_published")["medium"], "Online") - - def test_booking_confirmed(self): - with capturing() as mock_capture: - self.confirmed_booking(attendee_count=2) - - properties = properties_of(mock_capture, "booking_confirmed") - self.assertEqual(properties["payment"], "free") - self.assertEqual(properties["attendees"], "2-5") - self.assertFalse(properties["coupon"]) - self.assertFalse(properties["add_ons"]) - self.assertFalse(properties["utm"]) - - def test_booking_draft_sends_nothing(self): - ticket_type = self.free_ticket_type() - with capturing() as mock_capture: - frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": ticket_type.name, "first_name": "Draft", "email": "draft@example.com"} - ], - } - ).insert() - - self.assertNotIn("booking_confirmed", captured_names(mock_capture)) - - def test_ticket_checked_in(self): - booking = self.confirmed_booking() - ticket = frappe.db.get_value("Event Ticket", {"booking": booking.name}, "name") - - with capturing() as mock_capture: - checkin_ticket(ticket) - - self.assertEqual(captured_names(mock_capture), ["ticket_checked_in"]) + BuzzEventFactory.create() - def test_tickets_cancelled(self): - booking = self.confirmed_booking(attendee_count=2) - tickets = frappe.get_all("Event Ticket", filters={"booking": booking.name}, pluck="name") - request = frappe.get_doc( - { - "doctype": "Ticket Cancellation Request", - "booking": booking.name, - "status": "Accepted", - "tickets": [{"ticket": tickets[0]}], - } - ).insert() - - with capturing() as mock_capture, patch("frappe.sendmail"): - request.submit() - - self.assertEqual( - properties_of(mock_capture, "tickets_cancelled"), - {**telemetry.shared_properties(), "scope": "tickets", "tickets": "1", "via_refund": False}, - ) - - def test_full_booking_cancelled_counts_its_tickets(self): - booking = self.confirmed_booking(attendee_count=2) - request = frappe.get_doc( - { - "doctype": "Ticket Cancellation Request", - "booking": booking.name, - "status": "Accepted", - "cancel_full_booking": 1, - } - ).insert() - - with capturing() as mock_capture, patch("frappe.sendmail"): - request.submit() - - properties = properties_of(mock_capture, "tickets_cancelled") - self.assertEqual((properties["scope"], properties["tickets"]), ("booking", "2-5")) - - def test_booking_refunded_once_when_processed(self): - ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": f"Telemetry Paid {frappe.generate_hash(length=6)}", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": ticket_type.name, "first_name": "Paid", "email": "paid@example.com"} - ], - } - ).insert() - booking.payment_status = "Paid" - booking.submit() - - with capturing() as mock_capture: - refund = frappe.get_doc( - { - "doctype": "Event Booking Refund", - "booking": booking.name, - "amount": booking.total_amount, - "status": "Initiated", - "refund_id": f"rfnd_telemetry_{frappe.generate_hash(length=6)}", - } - ).insert() - self.assertNotIn("booking_refunded", captured_names(mock_capture)) - - refund.status = "Processed" - refund.save() - refund.save() - - self.assertEqual( - properties_of(mock_capture, "booking_refunded"), - {**telemetry.shared_properties(), "full": True, "covers_tickets": False}, - ) - - def test_sponsorship_paid(self): - with patch( - "buzz.proposals.doctype.sponsorship_enquiry.sponsorship_enquiry.SponsorshipEnquiry.send_pitch_deck" - ): - enquiry = frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": self.test_event.name, - "company_name": "Paying Co", - "company_logo": "/files/paying.png", - "tier": frappe.db.get_value("Sponsorship Tier", {"event": self.test_event.name}, "name"), - } - ).insert() - - with ( - capturing() as mock_capture, - patch("buzz.proposals.doctype.sponsorship_enquiry.sponsorship_enquiry.mark_payment_as_received"), - ): - enquiry.on_payment_authorized("Failed") - enquiry.on_payment_authorized("Completed") - - self.assertEqual(captured_names(mock_capture), ["sponsorship_paid"]) - - def test_talk_proposal_submitted(self): - with capturing() as mock_capture: - frappe.get_doc( - { - "doctype": "Talk Proposal", - "event": self.test_event.name, - "title": "A talk", - "speakers": [{"first_name": "Ada", "email": "ada@example.com"}], - } - ).insert() - - self.assertEqual(properties_of(mock_capture, "talk_proposal_submitted")["speakers"], "1") - - def test_event_proposal_submitted(self): - with capturing() as mock_capture: - frappe.get_doc( - { - "doctype": "Event Proposal", - "title": "An event", - "category": self.test_event.category, - "start_date": frappe.utils.add_days(frappe.utils.today(), 10), - "start_time": "10:00:00", - "end_time": "12:00:00", - "about": "About", - "medium": "In Person", - } - ).insert() - - properties = properties_of(mock_capture, "event_proposal_submitted") - self.assertEqual(properties["medium"], "In Person") - self.assertFalse(properties["free_event"]) - - def test_sponsorship_enquiry_created(self): - with ( - capturing() as mock_capture, - patch( - "buzz.proposals.doctype.sponsorship_enquiry.sponsorship_enquiry.SponsorshipEnquiry.send_pitch_deck" - ), - ): - frappe.get_doc( - { - "doctype": "Sponsorship Enquiry", - "event": self.test_event.name, - "company_name": "Acme", - "company_logo": "/files/acme.png", - } - ).insert() - - self.assertEqual(properties_of(mock_capture, "sponsorship_enquiry_created")["tier_selected"], False) - - def test_active_site_for_signed_in_user_only(self): - with capturing() as mock_capture: - dashboard.get_context() - self.assertEqual(captured_names(mock_capture), ["active_site"]) - self.assertEqual(mock_capture.call_args.kwargs["interval"], "1d") - - frappe.set_user("Guest") - with capturing() as mock_capture: - dashboard.get_context() - mock_capture.assert_not_called() - - -class TestSiteProfile(IntegrationTestCase): def test_nothing_is_queried_when_telemetry_is_off(self): with ( patch("buzz.telemetry_scan.is_enabled", return_value=False), diff --git a/buzz/test_telemetry_events.py b/buzz/test_telemetry_events.py new file mode 100644 index 00000000..bacfe097 --- /dev/null +++ b/buzz/test_telemetry_events.py @@ -0,0 +1,239 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from buzz import telemetry +from buzz.api.checkin import checkin_ticket +from buzz.api.forms import submit_event_proposal +from buzz.tests.factories import ( + BuzzEventFactory, + EventBookingFactory, + EventBookingRefundFactory, + EventTicketTypeFactory, + SponsorshipEnquiryFactory, + SponsorshipTierFactory, + TalkProposalFactory, + TicketCancellationRequestFactory, +) +from buzz.tests.utils import captured_names, capturing, properties_of +from buzz.www import dashboard + +SPONSORSHIP_ENQUIRY_MODULE = "buzz.proposals.doctype.sponsorship_enquiry.sponsorship_enquiry" + + +class TestEventTelemetry(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = BuzzEventFactory.create() + + def test_event_created_from_blank(self): + with capturing() as mock_capture: + self.new_event().insert() + + self.assertEqual( + properties_of(mock_capture, "event_created"), + { + **telemetry.shared_properties(), + "source": "blank", + "medium": "Online", + "free_event": False, + "published": False, + }, + ) + self.assertNotIn("event_published", captured_names(mock_capture)) + + def test_event_published_at_creation(self): + with capturing() as mock_capture: + self.new_event(is_published=1).insert() + + self.assertTrue(properties_of(mock_capture, "event_created")["published"]) + self.assertEqual(properties_of(mock_capture, "event_published")["medium"], "Online") + + def test_event_created_from_template(self): + event = self.new_event() + event.flags.from_template = True + with capturing() as mock_capture: + event.insert() + + self.assertEqual(properties_of(mock_capture, "event_created")["source"], "template") + + def test_event_published_fires_once_when_published(self): + event = self.new_event().insert() + + with capturing() as mock_capture: + event.is_published = 1 + event.save() + event.title = f"{event.title} renamed" + event.save() + + self.assertEqual(captured_names(mock_capture), ["event_published"]) + self.assertEqual(properties_of(mock_capture, "event_published")["medium"], "Online") + + def test_active_site_for_signed_in_user_only(self): + with capturing() as mock_capture: + dashboard.get_context() + self.assertEqual(captured_names(mock_capture), ["active_site"]) + self.assertEqual(mock_capture.call_args.kwargs["interval"], "1d") + + with self.set_user("Guest"), capturing() as mock_capture: + dashboard.get_context() + mock_capture.assert_not_called() + + def new_event(self, **overrides): + """Built, not saved, so the insert happens inside the capture.""" + links = {"team": self.event.team, "category": self.event.category, "host": self.event.host} + return BuzzEventFactory.build("unpublished", medium="Online", **links, **overrides) + + +class TestBookingTelemetry(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = BuzzEventFactory.create().name + + def test_booking_confirmed(self): + with capturing() as mock_capture: + self.create_confirmed_booking(attendee_count=2) + + properties = properties_of(mock_capture, "booking_confirmed") + self.assertEqual(properties["payment"], "free") + self.assertEqual(properties["attendees"], "2-5") + self.assertFalse(properties["coupon"]) + self.assertFalse(properties["add_ons"]) + self.assertFalse(properties["utm"]) + + def test_booking_draft_sends_nothing(self): + ticket_type = EventTicketTypeFactory.create(event=self.event).name + with capturing() as mock_capture: + EventBookingFactory.create(event=self.event, attendees=self.attendees(ticket_type, 1)) + + self.assertNotIn("booking_confirmed", captured_names(mock_capture)) + + def test_ticket_checked_in(self): + booking = self.create_confirmed_booking() + ticket = frappe.db.get_value("Event Ticket", {"booking": booking.name}, "name") + + with capturing() as mock_capture: + checkin_ticket(ticket) + + self.assertEqual(captured_names(mock_capture), ["ticket_checked_in"]) + + def test_tickets_cancelled(self): + booking = self.create_confirmed_booking(attendee_count=2).name + ticket = frappe.get_all("Event Ticket", filters={"booking": booking}, pluck="name")[0] + request = TicketCancellationRequestFactory.create( + booking=booking, status="Accepted", tickets=[{"ticket": ticket}] + ) + + with capturing() as mock_capture, patch("frappe.sendmail"): + request.submit() + + self.assertEqual( + properties_of(mock_capture, "tickets_cancelled"), + {**telemetry.shared_properties(), "scope": "tickets", "tickets": "1", "via_refund": False}, + ) + + def test_full_booking_cancelled_counts_its_tickets(self): + booking = self.create_confirmed_booking(attendee_count=2).name + request = TicketCancellationRequestFactory.create( + booking=booking, status="Accepted", cancel_full_booking=1 + ) + + with capturing() as mock_capture, patch("frappe.sendmail"): + request.submit() + + properties = properties_of(mock_capture, "tickets_cancelled") + self.assertEqual((properties["scope"], properties["tickets"]), ("booking", "2-5")) + + def test_booking_refunded_once_when_processed(self): + ticket_type = EventTicketTypeFactory.create("paid", event=self.event).name + booking = EventBookingFactory.create(event=self.event, attendees=self.attendees(ticket_type, 1)) + booking.payment_status = "Paid" + booking.submit() + + with capturing() as mock_capture: + refund = EventBookingRefundFactory.create(booking=booking.name, amount=booking.total_amount) + self.assertNotIn("booking_refunded", captured_names(mock_capture)) + + refund.status = "Processed" + refund.save() + refund.save() + + self.assertEqual( + properties_of(mock_capture, "booking_refunded"), + {**telemetry.shared_properties(), "full": True, "covers_tickets": False}, + ) + + def create_confirmed_booking(self, attendee_count: int = 1): + ticket_type = EventTicketTypeFactory.create(event=self.event).name + booking = EventBookingFactory.create( + event=self.event, attendees=self.attendees(ticket_type, attendee_count) + ) + booking.submit() + return booking + + def attendees(self, ticket_type: str, count: int) -> list[dict]: + return [ + { + "ticket_type": ticket_type, + "first_name": f"Guest {index}", + "email": f"telemetry-{index}@example.com", + } + for index in range(count) + ] + + +class TestProposalTelemetry(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = BuzzEventFactory.create() + + @patch(f"{SPONSORSHIP_ENQUIRY_MODULE}.SponsorshipEnquiry.send_pitch_deck") + def test_sponsorship_paid(self, _send_pitch_deck): + tier = SponsorshipTierFactory.create(event=self.event.name).name + enquiry = SponsorshipEnquiryFactory.create(event=self.event.name, tier=tier) + + with capturing() as mock_capture, patch(f"{SPONSORSHIP_ENQUIRY_MODULE}.mark_payment_as_received"): + enquiry.on_payment_authorized("Failed") + enquiry.on_payment_authorized("Completed") + + self.assertEqual(captured_names(mock_capture), ["sponsorship_paid"]) + + def test_talk_proposal_submitted(self): + speakers = [{"first_name": "Ada", "email": "ada@example.com"}] + with capturing() as mock_capture: + TalkProposalFactory.create(event=self.event.name, speakers=speakers) + + self.assertEqual(properties_of(mock_capture, "talk_proposal_submitted")["speakers"], "1") + + def test_event_proposal_submitted(self): + self.enterContext(self.change_settings("Buzz Settings", accept_event_proposals=1)) + self.addCleanup(frappe.clear_document_cache, "Buzz Settings", "Buzz Settings") + with capturing() as mock_capture: + submit_event_proposal(data=self.event_proposal(medium="In Person")) + + properties = properties_of(mock_capture, "event_proposal_submitted") + self.assertEqual(properties["medium"], "In Person") + self.assertFalse(properties["free_event"]) + + @patch(f"{SPONSORSHIP_ENQUIRY_MODULE}.SponsorshipEnquiry.send_pitch_deck") + def test_sponsorship_enquiry_created(self, _send_pitch_deck): + with capturing() as mock_capture: + SponsorshipEnquiryFactory.create(event=self.event.name) + + self.assertEqual(properties_of(mock_capture, "sponsorship_enquiry_created")["tier_selected"], False) + + def event_proposal(self, **values) -> dict: + return { + "title": "An event", + "category": self.event.category, + "start_date": add_days(today(), 10), + "start_time": "10:00:00", + "end_time": "12:00:00", + "about": "About", + **values, + } diff --git a/buzz/tests/base_test_cases.py b/buzz/tests/base_test_cases.py new file mode 100644 index 00000000..d19d0692 --- /dev/null +++ b/buzz/tests/base_test_cases.py @@ -0,0 +1,273 @@ +from unittest.mock import MagicMock, patch + +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.api.booking.schemas import BookingRequest +from buzz.api.booking.services import create_add_on_doc +from buzz.payments import handle_refund_notification +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + EventBookingFactory, + EventPaymentFactory, + EventSponsorFactory, + EventTicketTypeFactory, + IntegrationRequestFactory, + PaymentGatewayFactory, + SponsorshipEnquiryFactory, + SponsorshipTierFactory, + TicketAddOnFactory, + UserFactory, +) +from buzz.ticketing.doctype.event_booking.event_booking import RAZORPAY + +BOOKER = "booking-owner@example.com" +OUTSIDER = "booking-outsider@example.com" +TICKET_PRICE = 500 +ADD_ON_PRICE = 200 +# 500 per ticket with 10% tax on top. +CHARGED_PER_TICKET = 550.0 +GATEWAY_CONTROLLER = "buzz.ticketing.doctype.event_booking.event_booking.get_controller" + + +class BookingTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.booker = UserFactory.create_once(BOOKER).name + cls.outsider = UserFactory.create_once(OUTSIDER).name + cls.event = BuzzEventFactory.create() + cls.event.reload() + + def setUp(self): + self.enterContext(self.set_user("Administrator")) + frappe.clear_messages() + self.addCleanup(frappe.clear_document_cache, "Buzz Event", self.event.name) + self.set_event({"is_published": 1, "registrations_close_at": None, "allow_guest_booking": 0}) + self.free_ticket_type = EventTicketTypeFactory.create(event=self.event.name) + + def set_event(self, values): + frappe.db.set_value("Buzz Event", self.event.name, values) + frappe.clear_document_cache("Buzz Event", self.event.name) + + def enable_phone_otp(self): + self.set_event({"allow_guest_booking": 1, "guest_verification_method": "Phone OTP"}) + + def booking_request(self, **overrides): + values = { + "attendees": [ + { + "first_name": "Booker", + "email": "booker@example.com", + "ticket_type": str(self.free_ticket_type.name), + } + ], + "event": str(self.event.name), + } + values.update(overrides) + return BookingRequest(**values) + + +class SponsorshipTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = str(BuzzEventFactory.create().name) + + def setUp(self): + self.enterContext(self.set_user("Administrator")) + frappe.clear_messages() + self.tier = SponsorshipTierFactory.create( + event=self.event, title="Gold", prices=[{"currency": "INR", "price": 5000}] + ) + self.enquiry = SponsorshipEnquiryFactory.create( + event=self.event, tier=self.tier.name, company_name="Acme Corp", company_logo="/files/acme.png" + ) + + def make_stranger(self) -> str: + return UserFactory.create_once("sponsorship-stranger@example.com").name + + def make_sponsor(self): + return EventSponsorFactory.create( + event=self.event, + tier=self.tier.name, + enquiry=self.enquiry.name, + company_name="Acme Corp", + company_logo="/files/acme.png", + ) + + +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)) + + +class BookingRefundTestCase(IntegrationTestCase): + """A paid booking of one ticket per price in `ticket_prices`, on an event with 10% tax.""" + + ticket_prices = (TICKET_PRICE, TICKET_PRICE) + attendee_names = ("John", "Jenny") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = cls.create_event() + cls.ticket_types = [ + EventTicketTypeFactory.create(event=cls.event, prices=[{"currency": "INR", "price": price}]).name + for price in cls.ticket_prices + ] + cls.gateway = razorpay_gateway() + + @classmethod + def create_event(cls) -> str: + return BuzzEventFactory.create("with_tax", tax_percentage=10).name + + def setUp(self): + self.enterContext(self.set_user("Administrator")) + self.booking = self.create_paid_booking(self.attendee_names, self.ticket_types) + + def create_paid_booking(self, names, ticket_types): + attendees = [ + {"ticket_type": ticket_type, "first_name": name, "email": f"{name.lower()}@example.com"} + for name, ticket_type in zip(names, ticket_types, strict=True) + ] + booking = EventBookingFactory.create(event=self.event, attendees=attendees, payment_status="Paid") + booking.submit() + return booking + + def make_payment(self, gateway: str | None = None): + self.payment = EventPaymentFactory.create( + "received", + user=self.booking.user, + amount=self.booking.total_amount, + currency=self.booking.currency, + reference_doctype="Event Booking", + reference_docname=self.booking.name, + payment_gateway=gateway or self.gateway, + ) + + def initiate_refund(self, amount: float, tickets: list[str] | None = None, refund_id: str | None = None): + client = MagicMock() + client.refund_payment.return_value = { + "id": refund_id or self.refund_id(), + "status": "pending", + "amount": int(amount * 100), + } + with patch(GATEWAY_CONTROLLER, return_value=client): + self.booking.refund(amount=amount, tickets=tickets) + self.booking.reload() + return client + + def send_refund_webhook(self, refund_id: str, status: str, amount: float) -> str: + log = self.create_refund_log(self.refund_payload(refund_id, status, amount)) + self.handle_refund_log(log) + return log + + def refund_payload(self, refund_id: str, status: str, amount: float) -> dict: + entity = { + "id": refund_id, + "status": status, + "amount": int(amount * 100), + "payment_id": self.payment.payment_id, + } + return {"event": f"refund.{status}", "payload": {"refund": {"entity": entity}}} + + def create_refund_log(self, payload: dict) -> str: + return IntegrationRequestFactory.create(data=frappe.as_json(payload)).name + + def handle_refund_log(self, log: str) -> None: + # Cancelling a ticket emails the attendee, and delivery is not under test. + with patch("frappe.sendmail"): + handle_refund_notification("Integration Request", log) + self.booking.reload() + + def refund_id(self, suffix: str = "1") -> str: + """Refund ids are unique in the database, and rollback is per class.""" + return f"rfnd_{self._testMethodName}_{suffix}" + + def refunds(self) -> list: + names = frappe.get_all( + "Event Booking Refund", + filters={"booking": self.booking.name}, + order_by="creation asc", + pluck="name", + ) + return [frappe.get_doc("Event Booking Refund", name) for name in names] + + def refundable_tickets(self) -> list[str]: + return [ticket["ticket"] for ticket in self.booking.get_refund_summary()["tickets"]] + + def cancellation_requests(self) -> list: + return frappe.get_all( + "Ticket Cancellation Request", + filters={"booking": self.booking.name}, + fields=["name", "docstatus"], + ) + + +class TeamPermissionTestCase(IntegrationTestCase): + """Alice owns team A, Bob owns team B, and each team has one unpublished event.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.alice = UserFactory.create_once("perm-alice@example.com").name + cls.bob = UserFactory.create_once("perm-bob@example.com").name + cls.outsider = UserFactory.create_once("perm-outsider@example.com").name + cls.team_a = BuzzTeamFactory.create_owned_by(cls.alice, team_name="Perm Team A").name + cls.team_b = BuzzTeamFactory.create_owned_by(cls.bob, team_name="Perm Team B").name + cls.event_a = BuzzEventFactory.create("unpublished", team=cls.team_a).name + cls.event_b = BuzzEventFactory.create("unpublished", team=cls.team_b).name + + def setUp(self): + self.enterContext(self.set_user("Administrator")) + + def has_permission_as(self, user: str, ptype: str, doctype: str, doc: str) -> bool: + with self.set_user(user): + return frappe.has_permission(doctype, ptype, doc=doc) + + def list_as(self, user: str, doctype: str, pluck: str = "name") -> list: + with self.set_user(user): + return frappe.get_list(doctype, pluck=pluck) + + +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) + ] + + +def razorpay_gateway() -> str: + """Refunds only go through a gateway named Razorpay, so this name is fixed.""" + if not frappe.db.exists("Payment Gateway", RAZORPAY): + PaymentGatewayFactory.create(gateway=RAZORPAY) + return RAZORPAY diff --git a/buzz/tests/factories/__init__.py b/buzz/tests/factories/__init__.py index 02e4642f..32bca5db 100644 --- a/buzz/tests/factories/__init__.py +++ b/buzz/tests/factories/__init__.py @@ -1,25 +1,71 @@ +from buzz.tests.factories.additional_event_page_factory import AdditionalEventPageFactory +from buzz.tests.factories.buzz_campaign_factory import BuzzCampaignFactory from buzz.tests.factories.buzz_coupon_code_factory import BuzzCouponCodeFactory from buzz.tests.factories.buzz_custom_field_factory import BuzzCustomFieldFactory from buzz.tests.factories.buzz_event_factory import BuzzEventFactory from buzz.tests.factories.buzz_team_factory import BuzzTeamFactory +from buzz.tests.factories.buzz_team_membership_factory import BuzzTeamMembershipFactory +from buzz.tests.factories.buzz_theme_factory import BuzzThemeFactory +from buzz.tests.factories.email_template_factory import EmailTemplateFactory +from buzz.tests.factories.event_booking_factory import EventBookingFactory +from buzz.tests.factories.event_booking_refund_factory import EventBookingRefundFactory from buzz.tests.factories.event_category_factory import EventCategoryFactory from buzz.tests.factories.event_host_factory import EventHostFactory +from buzz.tests.factories.event_payment_factory import EventPaymentFactory +from buzz.tests.factories.event_proposal_factory import EventProposalFactory +from buzz.tests.factories.event_sponsor_factory import EventSponsorFactory +from buzz.tests.factories.event_talk_factory import EventTalkFactory +from buzz.tests.factories.event_template_factory import EventTemplateFactory +from buzz.tests.factories.event_ticket_factory import EventTicketFactory from buzz.tests.factories.event_ticket_type_factory import EventTicketTypeFactory +from buzz.tests.factories.event_venue_factory import EventVenueFactory +from buzz.tests.factories.file_factory import FileFactory +from buzz.tests.factories.integration_request_factory import IntegrationRequestFactory from buzz.tests.factories.offline_payment_method_factory import OfflinePaymentMethodFactory +from buzz.tests.factories.payment_gateway_factory import PaymentGatewayFactory +from buzz.tests.factories.speaker_profile_factory import SpeakerProfileFactory from buzz.tests.factories.sponsorship_enquiry_factory import SponsorshipEnquiryFactory +from buzz.tests.factories.sponsorship_tier_factory import SponsorshipTierFactory +from buzz.tests.factories.talk_proposal_factory import TalkProposalFactory from buzz.tests.factories.ticket_add_on_factory import TicketAddOnFactory +from buzz.tests.factories.ticket_cancellation_request_factory import ( + TicketCancellationRequestFactory, +) from buzz.tests.factories.user_factory import UserFactory +from buzz.tests.factories.user_invitation_factory import UserInvitationFactory __all__ = [ + "AdditionalEventPageFactory", + "BuzzCampaignFactory", "BuzzCouponCodeFactory", "BuzzCustomFieldFactory", "BuzzEventFactory", "BuzzTeamFactory", + "BuzzTeamMembershipFactory", + "BuzzThemeFactory", + "EmailTemplateFactory", + "EventBookingFactory", + "EventBookingRefundFactory", "EventCategoryFactory", "EventHostFactory", + "EventPaymentFactory", + "EventProposalFactory", + "EventSponsorFactory", + "EventTalkFactory", + "EventTemplateFactory", + "EventTicketFactory", "EventTicketTypeFactory", + "EventVenueFactory", + "FileFactory", + "IntegrationRequestFactory", "OfflinePaymentMethodFactory", + "PaymentGatewayFactory", + "SpeakerProfileFactory", "SponsorshipEnquiryFactory", + "SponsorshipTierFactory", + "TalkProposalFactory", "TicketAddOnFactory", + "TicketCancellationRequestFactory", "UserFactory", + "UserInvitationFactory", ] diff --git a/buzz/tests/factories/additional_event_page_factory.py b/buzz/tests/factories/additional_event_page_factory.py new file mode 100644 index 00000000..918712f2 --- /dev/null +++ b/buzz/tests/factories/additional_event_page_factory.py @@ -0,0 +1,22 @@ +from typing import Any + +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.additional_event_page.additional_event_page import AdditionalEventPage + +_fake = Faker() + + +class AdditionalEventPageFactory(BaseFactory[AdditionalEventPage]): + doctype = "Additional Event Page" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzEventFactory + + return { + "event": self.overrides.get("event") or BuzzEventFactory.create().name, + "title": _fake.unique.catch_phrase(), + "content": f"

{_fake.sentence()}

", + } diff --git a/buzz/tests/factories/buzz_campaign_factory.py b/buzz/tests/factories/buzz_campaign_factory.py new file mode 100644 index 00000000..b1386001 --- /dev/null +++ b/buzz/tests/factories/buzz_campaign_factory.py @@ -0,0 +1,28 @@ +from typing import Any + +import frappe +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.buzz_marketing.doctype.buzz_campaign.buzz_campaign import BuzzCampaign + +_fake = Faker() + + +class BuzzCampaignFactory(BaseFactory[BuzzCampaign]): + """Disabled by default: an enabled campaign refuses to save unless Frappe CRM is installed.""" + + doctype = "Buzz Campaign" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzTeamFactory + + # Prompt-autonamed, and rows outlive a run, so a hash beats Faker's per-process `unique`. + name = f"Campaign {frappe.generate_hash(length=8)}" + return { + "name": name, + "title": name, + "description": _fake.sentence(), + "team": self.overrides.get("team") or BuzzTeamFactory.create_owned_by().name, + } diff --git a/buzz/tests/factories/buzz_coupon_code_factory.py b/buzz/tests/factories/buzz_coupon_code_factory.py index 3ca68c31..166bc0d7 100644 --- a/buzz/tests/factories/buzz_coupon_code_factory.py +++ b/buzz/tests/factories/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/tests/factories/buzz_custom_field_factory.py b/buzz/tests/factories/buzz_custom_field_factory.py index 5185bcc5..98da3b12 100644 --- a/buzz/tests/factories/buzz_custom_field_factory.py +++ b/buzz/tests/factories/buzz_custom_field_factory.py @@ -15,7 +15,7 @@ class BuzzCustomFieldFactory(BaseFactory[BuzzCustomField]): @property def default_attributes(self) -> dict[str, Any]: - from buzz.tests.factories.buzz_event_factory import BuzzEventFactory + from buzz.tests.factories import BuzzEventFactory return { "event": self.overrides.get("event") or BuzzEventFactory.create().name, diff --git a/buzz/tests/factories/buzz_event_factory.py b/buzz/tests/factories/buzz_event_factory.py index 806c9685..45cfeade 100644 --- a/buzz/tests/factories/buzz_event_factory.py +++ b/buzz/tests/factories/buzz_event_factory.py @@ -1,3 +1,4 @@ +from functools import cached_property from typing import Any from faker import Faker @@ -16,11 +17,9 @@ class BuzzEventFactory(BaseFactory[BuzzEvent]): @property def default_attributes(self) -> dict[str, Any]: - from buzz.tests.factories.buzz_team_factory import BuzzTeamFactory - from buzz.tests.factories.event_category_factory import EventCategoryFactory - from buzz.tests.factories.event_host_factory import EventHostFactory + from buzz.tests.factories import EventCategoryFactory, EventHostFactory - team = self.overrides.get("team") or BuzzTeamFactory.create_owned_by().name + team = self.team # Registrations close once an event ends, so a fixed date would expire. event_date = add_days(today(), 30) return { @@ -35,3 +34,25 @@ def default_attributes(self) -> dict[str, Any]: "medium": "Online", "is_published": 1, } + + @property + def unpublished(self) -> dict[str, Any]: + return {"is_published": 0} + + @property + def in_person(self) -> dict[str, Any]: + from buzz.tests.factories import EventVenueFactory + + venue = self.overrides.get("venue") or EventVenueFactory.create(team=self.team).name + return {"medium": "In Person", "venue": venue} + + @property + def with_tax(self) -> dict[str, Any]: + return {"apply_tax": 1, "tax_inclusive": 0, "tax_label": "GST", "tax_percentage": 18} + + @cached_property + def team(self) -> str: + """Built once, so the host, the venue and the event share a team.""" + from buzz.tests.factories import BuzzTeamFactory + + return self.overrides.get("team") or BuzzTeamFactory.create_owned_by().name diff --git a/buzz/tests/factories/buzz_team_factory.py b/buzz/tests/factories/buzz_team_factory.py index b9dde862..9db820f4 100644 --- a/buzz/tests/factories/buzz_team_factory.py +++ b/buzz/tests/factories/buzz_team_factory.py @@ -19,17 +19,18 @@ class BuzzTeamFactory(BaseFactory[BuzzTeam]): @classmethod def create_owned_by(cls, user: str | None = None, **overrides: Any) -> BuzzTeam: """Prefer over `create()`: an Administrator-owned test team becomes its default team.""" - from buzz.tests.factories.user_factory import UserFactory + from buzz.tests.factories import UserFactory owner = user or UserFactory.create_once(LINK_TEAM_OWNER).name team = cls.create(flags={"owner_user": owner}, **overrides) # Paid tickets are the common case in tests; a test about the flag turns it off. - cls.set_feature(team.name, PAID_EVENTS_FLAG, 1) + cls.set_settings(team.name, {PAID_EVENTS_FLAG: 1}) return team @classmethod - def set_feature(cls, team: str, flag: str, value: int): - frappe.db.set_value("Buzz Team Settings", team, flag, value) + def set_settings(cls, team: str, values: dict[str, Any]): + """Not a Single, so `change_settings` cannot set it.""" + frappe.db.set_value("Buzz Team Settings", team, values) frappe.clear_document_cache("Buzz Team Settings", team) @property diff --git a/buzz/tests/factories/buzz_team_membership_factory.py b/buzz/tests/factories/buzz_team_membership_factory.py new file mode 100644 index 00000000..ac82cddc --- /dev/null +++ b/buzz/tests/factories/buzz_team_membership_factory.py @@ -0,0 +1,18 @@ +from typing import Any + +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.buzz_team_membership.buzz_team_membership import BuzzTeamMembership + + +class BuzzTeamMembershipFactory(BaseFactory[BuzzTeamMembership]): + doctype = "Buzz Team Membership" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzTeamFactory, UserFactory + + return { + "team": self.overrides.get("team") or BuzzTeamFactory.create_owned_by().name, + "user": self.overrides.get("user") or UserFactory.create().name, + } diff --git a/buzz/tests/factories/buzz_theme_factory.py b/buzz/tests/factories/buzz_theme_factory.py new file mode 100644 index 00000000..060ccc39 --- /dev/null +++ b/buzz/tests/factories/buzz_theme_factory.py @@ -0,0 +1,30 @@ +from typing import Any + +import frappe +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.buzz_theme.buzz_theme import BuzzTheme + +STANDARD_THEME = "Classic" + + +class BuzzThemeFactory(BaseFactory[BuzzTheme]): + """A custom copy of Classic, so every required token is present.""" + + doctype = "Buzz Theme" + + @property + def default_attributes(self) -> dict[str, Any]: + return { + "theme_name": f"Theme {frappe.generate_hash(length=8)}", + "tokens": standard_tokens(), + } + + +def standard_tokens() -> list[dict]: + return frappe.get_all( + "Buzz Theme Token", + filters={"parenttype": "Buzz Theme", "parent": STANDARD_THEME}, + fields=["token", "type", "value", "dark_value"], + order_by="idx", + ) diff --git a/buzz/tests/factories/email_template_factory.py b/buzz/tests/factories/email_template_factory.py new file mode 100644 index 00000000..9bb8ea81 --- /dev/null +++ b/buzz/tests/factories/email_template_factory.py @@ -0,0 +1,21 @@ +from typing import Any + +import frappe +from faker import Faker +from frappe.email.doctype.email_template.email_template import EmailTemplate +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +_fake = Faker() + + +class EmailTemplateFactory(BaseFactory[EmailTemplate]): + doctype = "Email Template" + + @property + def default_attributes(self) -> dict[str, Any]: + # Prompt-autonamed and rows outlive a run, so a hash rather than Faker's `unique`. + return { + "name": f"Template {frappe.generate_hash(length=8)}", + "subject": _fake.sentence(), + "response": f"

{_fake.paragraph()}

", + } diff --git a/buzz/tests/factories/event_booking_factory.py b/buzz/tests/factories/event_booking_factory.py new file mode 100644 index 00000000..6877651c --- /dev/null +++ b/buzz/tests/factories/event_booking_factory.py @@ -0,0 +1,37 @@ +from typing import Any + +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.ticketing.doctype.event_booking.event_booking import EventBooking + +_fake = Faker() + +# Shared so bookings built as link targets do not hit the User creation throttle. +LINK_BOOKER = "factory-booker@example.com" + + +class EventBookingFactory(BaseFactory[EventBooking]): + """`insert` sets `owner` to the session user, so set another owner after `create()`.""" + + doctype = "Event Booking" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzEventFactory, UserFactory + + event = self.overrides.get("event") or BuzzEventFactory.create().name + return { + "event": event, + "user": self.overrides.get("user") or UserFactory.create_once(LINK_BOOKER).name, + "attendees": self.overrides.get("attendees") or [self.attendee_row(event)], + } + + def attendee_row(self, event: str) -> dict[str, Any]: + from buzz.tests.factories import EventTicketTypeFactory + + return { + "ticket_type": EventTicketTypeFactory.create(event=event).name, + "first_name": _fake.first_name(), + "email": _fake.email(), + } diff --git a/buzz/tests/factories/event_booking_refund_factory.py b/buzz/tests/factories/event_booking_refund_factory.py new file mode 100644 index 00000000..3062f91f --- /dev/null +++ b/buzz/tests/factories/event_booking_refund_factory.py @@ -0,0 +1,20 @@ +from typing import Any + +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.ticketing.doctype.event_booking_refund.event_booking_refund import EventBookingRefund + + +class EventBookingRefundFactory(BaseFactory[EventBookingRefund]): + """An initiated refund. Pass `amount`; a refund of the booking total counts as a full refund.""" + + doctype = "Event Booking Refund" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import EventBookingFactory + + return { + "booking": self.overrides.get("booking") or EventBookingFactory.create().name, + "status": "Initiated", + } diff --git a/buzz/tests/factories/event_host_factory.py b/buzz/tests/factories/event_host_factory.py index 0f32091e..27ca7450 100644 --- a/buzz/tests/factories/event_host_factory.py +++ b/buzz/tests/factories/event_host_factory.py @@ -13,7 +13,7 @@ class EventHostFactory(BaseFactory[EventHost]): @property def default_attributes(self) -> dict[str, Any]: - from buzz.tests.factories.buzz_team_factory import BuzzTeamFactory + from buzz.tests.factories import BuzzTeamFactory return { "host_name": _fake.company(), diff --git a/buzz/tests/factories/event_payment_factory.py b/buzz/tests/factories/event_payment_factory.py new file mode 100644 index 00000000..52a9703d --- /dev/null +++ b/buzz/tests/factories/event_payment_factory.py @@ -0,0 +1,24 @@ +from typing import Any + +import frappe +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.ticketing.doctype.event_payment.event_payment import EventPayment + + +class EventPaymentFactory(BaseFactory[EventPayment]): + """An unpaid payment. Pass `reference_doctype`, `reference_docname` and `payment_gateway`.""" + + doctype = "Event Payment" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import UserFactory + from buzz.tests.factories.event_booking_factory import LINK_BOOKER + + return {"user": self.overrides.get("user") or UserFactory.create_once(LINK_BOOKER).name} + + @property + def received(self) -> dict[str, Any]: + # Webhooks find the payment by its id, so each one is unique. + return {"payment_received": 1, "payment_id": f"pay_{frappe.generate_hash(length=10)}"} diff --git a/buzz/tests/factories/event_proposal_factory.py b/buzz/tests/factories/event_proposal_factory.py new file mode 100644 index 00000000..8aac63b5 --- /dev/null +++ b/buzz/tests/factories/event_proposal_factory.py @@ -0,0 +1,33 @@ +from typing import Any + +from faker import Faker +from frappe.utils import add_days, today +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.proposals.doctype.event_proposal.event_proposal import EventProposal + +_fake = Faker() + + +class EventProposalFactory(BaseFactory[EventProposal]): + """An online one-day proposal with no host. `validate` refuses a start date in the past.""" + + doctype = "Event Proposal" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import EventCategoryFactory + + return { + "title": f"Proposal {_fake.catch_phrase()}", + "category": self.overrides.get("category") or EventCategoryFactory.create().name, + "medium": "Online", + "start_date": add_days(today(), 30), + "start_time": "10:00:00", + "end_time": "18:00:00", + "about": f"

{_fake.paragraph()}

", + } + + @property + def approved(self) -> dict[str, Any]: + return {"status": "Approved"} diff --git a/buzz/tests/factories/event_sponsor_factory.py b/buzz/tests/factories/event_sponsor_factory.py new file mode 100644 index 00000000..c524da2d --- /dev/null +++ b/buzz/tests/factories/event_sponsor_factory.py @@ -0,0 +1,26 @@ +from typing import Any + +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.event_sponsor.event_sponsor import EventSponsor + +_fake = Faker() + + +class EventSponsorFactory(BaseFactory[EventSponsor]): + """A sponsor on a new tier of `event`, with no enquiry behind it.""" + + doctype = "Event Sponsor" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzEventFactory, SponsorshipTierFactory + + event = self.overrides.get("event") or BuzzEventFactory.create().name + return { + "event": event, + "tier": self.overrides.get("tier") or SponsorshipTierFactory.create(event=event).name, + "company_name": _fake.company(), + "company_logo": "/files/logo.png", + } diff --git a/buzz/tests/factories/event_talk_factory.py b/buzz/tests/factories/event_talk_factory.py new file mode 100644 index 00000000..43fc651d --- /dev/null +++ b/buzz/tests/factories/event_talk_factory.py @@ -0,0 +1,23 @@ +from typing import Any + +import frappe +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.event_talk.event_talk import EventTalk + +_fake = Faker() + + +class EventTalkFactory(BaseFactory[EventTalk]): + doctype = "Event Talk" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzEventFactory + + return { + "event": self.overrides.get("event") or BuzzEventFactory.create().name, + "title": _fake.sentence(nb_words=4).rstrip("."), + "submitted_by": frappe.session.user, + } diff --git a/buzz/tests/factories/event_template_factory.py b/buzz/tests/factories/event_template_factory.py new file mode 100644 index 00000000..769a8257 --- /dev/null +++ b/buzz/tests/factories/event_template_factory.py @@ -0,0 +1,23 @@ +from typing import Any + +import frappe +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.event_template.event_template import EventTemplate + + +class EventTemplateFactory(BaseFactory[EventTemplate]): + doctype = "Event Template" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzTeamFactory, EventCategoryFactory, EventHostFactory + + team = self.overrides.get("team") or BuzzTeamFactory.create_owned_by().name + # Named after `template_name`, and rows outlive a run, so a hash beats Faker's `unique`. + return { + "template_name": f"Template {frappe.generate_hash(length=8)}", + "team": team, + "category": self.overrides.get("category") or EventCategoryFactory.create().name, + "host": self.overrides.get("host") or EventHostFactory.create(team=team).name, + } diff --git a/buzz/tests/factories/event_ticket_factory.py b/buzz/tests/factories/event_ticket_factory.py new file mode 100644 index 00000000..1cbbf241 --- /dev/null +++ b/buzz/tests/factories/event_ticket_factory.py @@ -0,0 +1,31 @@ +from typing import Any + +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.ticketing.doctype.event_ticket.event_ticket import EventTicket + +_fake = Faker() + + +class EventTicketFactory(BaseFactory[EventTicket]): + """A draft. The booking flow submits tickets, so use `submitted` for that.""" + + doctype = "Event Ticket" + + @property + def default_attributes(self) -> dict[str, Any]: + 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 { + "event": event, + "ticket_type": ticket_type, + "first_name": _fake.first_name(), + "attendee_email": _fake.email(), + } + + @property + def submitted(self) -> dict[str, Any]: + return {"docstatus": 1} diff --git a/buzz/tests/factories/event_ticket_type_factory.py b/buzz/tests/factories/event_ticket_type_factory.py index 47dd4275..25a1319b 100644 --- a/buzz/tests/factories/event_ticket_type_factory.py +++ b/buzz/tests/factories/event_ticket_type_factory.py @@ -15,7 +15,7 @@ class EventTicketTypeFactory(BaseFactory[EventTicketType]): @property def default_attributes(self) -> dict[str, Any]: - from buzz.tests.factories.buzz_event_factory import BuzzEventFactory + from buzz.tests.factories import BuzzEventFactory return { "event": self.overrides.get("event") or BuzzEventFactory.create().name, @@ -26,3 +26,7 @@ def default_attributes(self) -> dict[str, Any]: @property def paid(self) -> dict[str, Any]: return {"prices": [{"currency": "INR", "price": 500}]} + + @property + def unpublished(self) -> dict[str, Any]: + return {"is_published": 0} diff --git a/buzz/tests/factories/event_venue_factory.py b/buzz/tests/factories/event_venue_factory.py new file mode 100644 index 00000000..59fdd444 --- /dev/null +++ b/buzz/tests/factories/event_venue_factory.py @@ -0,0 +1,22 @@ +from typing import Any + +from faker import Faker +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.event_venue.event_venue import EventVenue + +_fake = Faker() + + +class EventVenueFactory(BaseFactory[EventVenue]): + doctype = "Event Venue" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzTeamFactory + + return { + "venue_name": f"{_fake.company()} Hall", + "address": _fake.address(), + "team": self.overrides.get("team") or BuzzTeamFactory.create_owned_by().name, + } diff --git a/buzz/tests/factories/file_factory.py b/buzz/tests/factories/file_factory.py new file mode 100644 index 00000000..9226ca28 --- /dev/null +++ b/buzz/tests/factories/file_factory.py @@ -0,0 +1,17 @@ +from typing import Any + +import frappe +from frappe.core.doctype.file.file import File +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + + +class FileFactory(BaseFactory[File]): + doctype = "File" + + @property + def default_attributes(self) -> dict[str, Any]: + return { + "file_name": f"{frappe.generate_hash(length=8)}.txt", + "content": b"factory file", + "is_private": 0, + } diff --git a/buzz/tests/factories/integration_request_factory.py b/buzz/tests/factories/integration_request_factory.py new file mode 100644 index 00000000..39eaaefe --- /dev/null +++ b/buzz/tests/factories/integration_request_factory.py @@ -0,0 +1,14 @@ +from typing import Any + +from frappe.integrations.doctype.integration_request.integration_request import IntegrationRequest +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + + +class IntegrationRequestFactory(BaseFactory[IntegrationRequest]): + """A queued Razorpay webhook. Pass the payload as JSON in `data`.""" + + doctype = "Integration Request" + + @property + def default_attributes(self) -> dict[str, Any]: + return {"integration_request_service": "Razorpay", "is_remote_request": 1, "status": "Queued"} diff --git a/buzz/tests/factories/offline_payment_method_factory.py b/buzz/tests/factories/offline_payment_method_factory.py index 6756447c..a859b973 100644 --- a/buzz/tests/factories/offline_payment_method_factory.py +++ b/buzz/tests/factories/offline_payment_method_factory.py @@ -13,7 +13,7 @@ class OfflinePaymentMethodFactory(BaseFactory[OfflinePaymentMethod]): @property def default_attributes(self) -> dict[str, Any]: - from buzz.tests.factories.buzz_event_factory import BuzzEventFactory + from buzz.tests.factories import BuzzEventFactory return { "event": self.overrides.get("event") or BuzzEventFactory.create().name, diff --git a/buzz/tests/factories/payment_gateway_factory.py b/buzz/tests/factories/payment_gateway_factory.py new file mode 100644 index 00000000..258bed5e --- /dev/null +++ b/buzz/tests/factories/payment_gateway_factory.py @@ -0,0 +1,14 @@ +from typing import Any + +import frappe +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory +from payments.payments.doctype.payment_gateway.payment_gateway import PaymentGateway + + +class PaymentGatewayFactory(BaseFactory[PaymentGateway]): + doctype = "Payment Gateway" + + @property + def default_attributes(self) -> dict[str, Any]: + # Named after `gateway` and rows outlive a run, so a hash rather than Faker's `unique`. + return {"gateway": f"Gateway {frappe.generate_hash(length=8)}"} diff --git a/buzz/tests/factories/speaker_profile_factory.py b/buzz/tests/factories/speaker_profile_factory.py new file mode 100644 index 00000000..66ac570f --- /dev/null +++ b/buzz/tests/factories/speaker_profile_factory.py @@ -0,0 +1,17 @@ +from typing import Any + +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.speaker_profile.speaker_profile import SpeakerProfile + + +class SpeakerProfileFactory(BaseFactory[SpeakerProfile]): + """`display_name` is fetched from the user's full name on save, so name the user instead.""" + + doctype = "Speaker Profile" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import UserFactory + + return {"user": self.overrides.get("user") or UserFactory.create().name} diff --git a/buzz/tests/factories/sponsorship_enquiry_factory.py b/buzz/tests/factories/sponsorship_enquiry_factory.py index cce6f6c3..7f669081 100644 --- a/buzz/tests/factories/sponsorship_enquiry_factory.py +++ b/buzz/tests/factories/sponsorship_enquiry_factory.py @@ -15,7 +15,7 @@ class SponsorshipEnquiryFactory(BaseFactory[SponsorshipEnquiry]): @property def default_attributes(self) -> dict[str, Any]: - from buzz.tests.factories.buzz_event_factory import BuzzEventFactory + from buzz.tests.factories import BuzzEventFactory return { "event": self.overrides.get("event") or BuzzEventFactory.create().name, diff --git a/buzz/tests/factories/sponsorship_tier_factory.py b/buzz/tests/factories/sponsorship_tier_factory.py new file mode 100644 index 00000000..bc0f953a --- /dev/null +++ b/buzz/tests/factories/sponsorship_tier_factory.py @@ -0,0 +1,22 @@ +from typing import Any + +import frappe +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.sponsorship_tier.sponsorship_tier import SponsorshipTier + + +class SponsorshipTierFactory(BaseFactory[SponsorshipTier]): + """One INR price row: `validate_prices` refuses a tier without one.""" + + doctype = "Sponsorship Tier" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzEventFactory + + return { + "event": self.overrides.get("event") or BuzzEventFactory.create().name, + "title": f"Tier {frappe.generate_hash(length=6)}", + "prices": [{"currency": "INR", "price": 5000}], + } diff --git a/buzz/tests/factories/talk_proposal_factory.py b/buzz/tests/factories/talk_proposal_factory.py new file mode 100644 index 00000000..93912517 --- /dev/null +++ b/buzz/tests/factories/talk_proposal_factory.py @@ -0,0 +1,43 @@ +from typing import Any + +from faker import Faker +from frappe.tests.classes.context_managers import set_user +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.proposals.doctype.talk_proposal.talk_proposal import TalkProposal + +_fake = Faker() + + +class TalkProposalFactory(BaseFactory[TalkProposal]): + """`validate` fills an empty `submitted_by` with the session user.""" + + doctype = "Talk Proposal" + + @classmethod + def create_as_guest(cls, event: str, speaker_email: str, **overrides: Any) -> TalkProposal: + """The public form's submission: `owner` and `submitted_by` are both Guest.""" + speakers = [{"first_name": "Speaker", "email": speaker_email}] + with set_user("Guest"): + return cls.create( + "guest_submitted", + event=event, + speakers=speakers, + flags={"ignore_permissions": True}, + **overrides, + ) + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzEventFactory + + return { + "event": self.overrides.get("event") or BuzzEventFactory.create().name, + "title": _fake.sentence(nb_words=4).rstrip("."), + "speakers": [{"first_name": _fake.first_name(), "email": _fake.email()}], + } + + @property + def guest_submitted(self) -> dict[str, Any]: + """`insert` still sets `owner` to the session user.""" + return {"submitted_by": "Guest"} diff --git a/buzz/tests/factories/ticket_add_on_factory.py b/buzz/tests/factories/ticket_add_on_factory.py index 3b218120..1a1ae27b 100644 --- a/buzz/tests/factories/ticket_add_on_factory.py +++ b/buzz/tests/factories/ticket_add_on_factory.py @@ -13,7 +13,7 @@ class TicketAddOnFactory(BaseFactory[TicketAddon]): @property def default_attributes(self) -> dict[str, Any]: - from buzz.tests.factories.buzz_event_factory import BuzzEventFactory + from buzz.tests.factories import BuzzEventFactory return { "event": self.overrides.get("event") or BuzzEventFactory.create().name, diff --git a/buzz/tests/factories/ticket_cancellation_request_factory.py b/buzz/tests/factories/ticket_cancellation_request_factory.py new file mode 100644 index 00000000..28cc5a43 --- /dev/null +++ b/buzz/tests/factories/ticket_cancellation_request_factory.py @@ -0,0 +1,19 @@ +from typing import Any + +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.ticketing.doctype.ticket_cancellation_request.ticket_cancellation_request import ( + TicketCancellationRequest, +) + + +class TicketCancellationRequestFactory(BaseFactory[TicketCancellationRequest]): + """In review, naming no tickets. Pass `tickets` rows or `cancel_full_booking` to pick the scope.""" + + doctype = "Ticket Cancellation Request" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import EventBookingFactory + + return {"booking": self.overrides.get("booking") or EventBookingFactory.create().name} diff --git a/buzz/tests/factories/user_factory.py b/buzz/tests/factories/user_factory.py index 84d61e09..777b0e31 100644 --- a/buzz/tests/factories/user_factory.py +++ b/buzz/tests/factories/user_factory.py @@ -16,7 +16,8 @@ def create_once(cls, email: str) -> User: """Reuse an existing user: test users leak, and User creation is throttled at 60 an hour.""" if frappe.db.exists("User", email): return frappe.get_doc("User", email) - return cls.create(email=email) + # A fixed identity is a fixture, so it must not depend on who the session user is. + return cls.create(email=email, flags={"ignore_permissions": True}) @property def default_attributes(self) -> dict[str, Any]: diff --git a/buzz/tests/factories/user_invitation_factory.py b/buzz/tests/factories/user_invitation_factory.py new file mode 100644 index 00000000..284dc762 --- /dev/null +++ b/buzz/tests/factories/user_invitation_factory.py @@ -0,0 +1,21 @@ +from typing import Any + +import frappe +from frappe.core.doctype.user_invitation.user_invitation import UserInvitation +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + + +class UserInvitationFactory(BaseFactory[UserInvitation]): + """A pending buzz invitation that names no team. `insert` mails it, so patch `frappe.sendmail`.""" + + doctype = "User Invitation" + + @property + def default_attributes(self) -> dict[str, Any]: + # Core allows one pending invitation per address per app, so every address is new. + return { + "email": f"invitee-{frappe.generate_hash(length=8)}@example.com", + "roles": [{"role": "Buzz User"}], + "app_name": "buzz", + "redirect_to_path": "/b/manage", + } diff --git a/buzz/tests/test_factories.py b/buzz/tests/test_factories.py new file mode 100644 index 00000000..1fb793ce --- /dev/null +++ b/buzz/tests/test_factories.py @@ -0,0 +1,119 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.tests import factories +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventBookingFactory, + EventTicketFactory, + EventTicketTypeFactory, + EventVenueFactory, + SponsorshipTierFactory, + TalkProposalFactory, + UserFactory, +) + + +class TestFactoryDefaults(IntegrationTestCase): + @patch("frappe.sendmail") # User Invitation mails on insert, and CI has no outgoing account. + def test_every_factory_saves_with_its_defaults(self, sendmail): + for name in factories.__all__: + factory = getattr(factories, name) + with self.subTest(factory=factory.__name__): + doc = factory.create() + self.assertTrue(frappe.db.exists(factory.doctype, doc.name)) + + def test_create_once_reuses_the_user(self): + first = UserFactory.create_once("factory-create-once@example.com") + second = UserFactory.create_once("factory-create-once@example.com") + + self.assertEqual(first.name, second.name) + + def test_create_once_works_under_a_non_admin_session(self): + member = UserFactory.create_once("factory-create-once-member@example.com").name + + with self.set_user(member): + created = UserFactory.create_once("factory-create-once-other@example.com") + + self.assertTrue(frappe.db.exists("User", created.name)) + + def test_owned_team_has_the_given_owner(self): + owner = UserFactory.create_once("factory-team-owner-check@example.com").name + team = BuzzTeamFactory.create_owned_by(owner) + + role = frappe.db.get_value("Buzz Team Membership", {"team": team.name, "user": owner}, "team_role") + self.assertEqual(role, "Owner") + + def test_set_settings_writes_team_settings(self): + team = BuzzTeamFactory.create_owned_by().name + + BuzzTeamFactory.set_settings(team, {"legal_name": "Factory Pvt Ltd"}) + + self.assertEqual(frappe.get_cached_doc("Buzz Team Settings", team).legal_name, "Factory Pvt Ltd") + + +class TestEventFactoryTraits(IntegrationTestCase): + def test_unpublished(self): + self.assertEqual(BuzzEventFactory.create("unpublished").is_published, 0) + + def test_in_person_has_a_venue_on_the_event_team(self): + event = BuzzEventFactory.create("in_person") + + self.assertEqual(event.medium, "In Person") + self.assertEqual(frappe.db.get_value("Event Venue", event.venue, "team"), event.team) + + def test_in_person_keeps_a_given_venue(self): + team = BuzzTeamFactory.create_owned_by().name + venue = EventVenueFactory.create(team=team).name + + self.assertEqual(BuzzEventFactory.create("in_person", team=team, venue=venue).venue, venue) + + def test_with_tax(self): + event = BuzzEventFactory.create("with_tax") + + self.assertEqual((event.apply_tax, event.tax_label, event.tax_percentage), (1, "GST", 18)) + + +class TestTicketingFactoryTraits(IntegrationTestCase): + def test_paid_ticket_type(self): + ticket_type = EventTicketTypeFactory.create("paid") + + self.assertEqual([(row.currency, row.price) for row in ticket_type.prices], [("INR", 500)]) + + def test_unpublished_ticket_type(self): + self.assertEqual(EventTicketTypeFactory.create("unpublished").is_published, 0) + + def test_submitted_ticket(self): + self.assertEqual(EventTicketFactory.create("submitted").docstatus, 1) + + def test_booking_attendee_uses_a_ticket_type_of_the_event(self): + booking = EventBookingFactory.create() + + ticket_type_event = frappe.db.get_value( + "Event Ticket Type", booking.attendees[0].ticket_type, "event" + ) + self.assertEqual(ticket_type_event, str(booking.event)) + + def test_membership_takes_a_role(self): + self.assertEqual(BuzzTeamMembershipFactory.create(team_role="Viewer").team_role, "Viewer") + + +class TestProposalFactoryTraits(IntegrationTestCase): + def test_guest_submitted_talk_proposal(self): + self.assertEqual(TalkProposalFactory.create("guest_submitted").submitted_by, "Guest") + + def test_create_as_guest_is_owned_by_guest(self): + proposal = TalkProposalFactory.create_as_guest( + BuzzEventFactory.create().name, "guest-speaker@example.com" + ) + + self.assertEqual((proposal.owner, proposal.submitted_by), ("Guest", "Guest")) + + def test_sponsorship_tier_has_an_inr_price(self): + tier = SponsorshipTierFactory.create() + + self.assertEqual([row.currency for row in tier.prices], ["INR"]) diff --git a/buzz/tests/test_tenant_isolation.py b/buzz/tests/test_tenant_isolation.py new file mode 100644 index 00000000..b2d555e0 --- /dev/null +++ b/buzz/tests/test_tenant_isolation.py @@ -0,0 +1,191 @@ +import frappe +from frappe.desk.search import search_link + +from buzz.api.checkin import validate_ticket_for_checkin +from buzz.api.checkin.exceptions import TicketNotFound +from buzz.api.exceptions import NotPermitted +from buzz.permissions import team_query_conditions +from buzz.tests.base_test_cases import TeamPermissionTestCase +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamMembershipFactory, + EventTicketFactory, + EventTicketTypeFactory, + SponsorshipTierFactory, + TalkProposalFactory, + UserFactory, +) + +# Buzz Team Membership carries a team column but is scoped by its own pair of hooks. +SELF_SCOPED_DOCTYPES = frozenset({"Buzz Team Membership"}) + + +class TestCrossTeamIsolation(TeamPermissionTestCase): + def test_team_direct_lists_exclude_other_teams(self): + for doctype in linked_to("Buzz Team") - SELF_SCOPED_DOCTYPES: + with self.subTest(doctype=doctype): + self.assertNotIn(self.team_b, self.list_as(self.alice, doctype, pluck="team")) + + def test_event_derived_lists_exclude_other_teams(self): + ticket_type = EventTicketTypeFactory.create(event=self.event_b) + + self.assertNotIn(ticket_type.name, self.list_as(self.alice, "Event Ticket Type")) + + def test_team_lists_exclude_other_teams(self): + teams = self.list_as(self.alice, "Buzz Team") + + self.assertIn(self.team_a, teams) + self.assertNotIn(self.team_b, teams) + + def test_membership_lists_exclude_other_teams(self): + self.assertNotIn(self.team_b, self.list_as(self.alice, "Buzz Team Membership", pluck="team")) + + def test_every_tenant_doctype_is_wired_to_both_hooks(self): + # Catches a new team-owned doctype that nobody registered in hooks.py. + query_conditions = frappe.get_hooks("permission_query_conditions") + has_permission = frappe.get_hooks("has_permission") + + for doctype in tenant_doctypes(): + with self.subTest(doctype=doctype): + self.assertTrue(query_conditions.get(doctype)) + self.assertTrue(has_permission.get(doctype)) + + def test_every_query_condition_is_valid_sql(self): + # Hook output is stringified into the WHERE clause, so pypika's ANSI quoting would reach MariaDB. + hooks = frappe.get_hooks("permission_query_conditions", app_name="buzz") + + for doctype, methods in hooks.items(): + for method in methods: + with self.subTest(doctype=doctype): + condition = frappe.call(frappe.get_attr(method), self.alice, doctype=doctype) + self.assertIsInstance(condition, str) + frappe.db.sql(f"SELECT `name` FROM `tab{doctype}` WHERE {condition} LIMIT 1") + + def test_opening_another_teams_event_is_denied(self): + with self.set_user(self.alice), self.assertRaises(frappe.PermissionError): + frappe.get_doc("Buzz Event", self.event_b).check_permission("read") + + def test_opening_another_teams_derived_doc_is_denied(self): + ticket_type = EventTicketTypeFactory.create(event=self.event_b) + + with self.set_user(self.alice), self.assertRaises(frappe.PermissionError): + frappe.get_doc("Event Ticket Type", ticket_type.name).check_permission("read") + + def test_system_manager_is_unrestricted(self): + self.assertIn(self.event_b, frappe.get_list("Buzz Event", pluck="name")) + + def test_unstamped_rows_stay_readable_by_system_managers_only(self): + orphan = BuzzEventFactory.create("unpublished", team=self.team_a).name + frappe.db.set_value("Buzz Event", orphan, "team", None, update_modified=False) + + self.assertIn(orphan, frappe.get_list("Buzz Event", pluck="name")) + self.assertNotIn(orphan, self.list_as(self.alice, "Buzz Event")) + # has_permission tolerates the missing team so a half-migrated site does not hard-break. + with self.set_user(self.alice): + frappe.get_doc("Buzz Event", orphan).check_permission("read") + + +class TestPublicVisibility(TeamPermissionTestCase): + def test_published_events_stay_visible_to_non_members(self): + published = BuzzEventFactory.create(team=self.team_b).name + + events = self.list_as(self.outsider, "Buzz Event") + + self.assertIn(published, events) + self.assertNotIn(self.event_b, events) + + def test_published_sponsorship_tier_is_visible_to_non_members(self): + published = BuzzEventFactory.create(team=self.team_b).name + visible = SponsorshipTierFactory.create(event=published).name + hidden = SponsorshipTierFactory.create(event=self.event_b).name + + tiers = self.list_as(self.outsider, "Sponsorship Tier") + + self.assertIn(visible, tiers) + self.assertNotIn(hidden, tiers) + + def test_guest_event_reads_are_never_narrowed(self): + self.assertIsNone(team_query_conditions(user="Guest", doctype="Buzz Event")) + + def test_attendee_gets_no_carve_out_on_payments(self): + with self.set_user(self.outsider), self.assertRaises(frappe.PermissionError): + frappe.get_list("Event Payment", pluck="name") + + +class TestTeamLinkQuery(TeamPermissionTestCase): + def test_link_search_offers_only_the_users_teams(self): + teams = self.search_teams_as(self.alice) + + self.assertIn(self.team_a, teams) + self.assertNotIn(self.team_b, teams) + + def test_link_search_matches_the_team_name(self): + self.assertEqual(self.search_teams_as(self.alice, "Perm Team A"), [self.team_a]) + self.assertEqual(self.search_teams_as(self.alice, "Perm Team B"), []) + + def test_system_manager_is_narrowed_here_despite_reading_every_team(self): + # Administrator is on neither team, but the permission hooks let it list both. + self.assertIn(self.team_b, frappe.get_list("Buzz Team", pluck="name")) + + self.assertNotIn(self.team_b, self.search_teams_as("Administrator")) + + def test_non_member_gets_nothing(self): + self.assertEqual(self.search_teams_as(self.outsider), []) + + def search_teams_as(self, user: str, txt: str = "") -> list[str]: + with self.set_user(user): + results = search_link("Buzz Team", txt, reference_doctype="Buzz Event") + return [result["value"] for result in results] + + +class TestTalkProposalComposition(TeamPermissionTestCase): + def test_speaker_sees_own_proposal_without_a_membership(self): + mine = self.create_proposal(self.event_b) + + self.assertIn(mine, self.list_as(self.outsider, "Talk Proposal")) + + def test_team_member_sees_their_teams_proposals_only(self): + ours = self.create_proposal(self.event_a) + theirs = self.create_proposal(self.event_b) + + proposals = self.list_as(self.alice, "Talk Proposal") + + self.assertIn(ours, proposals) + self.assertNotIn(theirs, proposals) + + def create_proposal(self, event: str) -> str: + return TalkProposalFactory.create(event=event, submitted_by=self.outsider).name + + +class TestCheckinIsolation(TeamPermissionTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.frontdesk = UserFactory.create_once("perm-frontdesk@example.com").name + BuzzTeamMembershipFactory.create(team=cls.team_a, user=cls.frontdesk, team_role="Frontdesk") + + def test_frontdesk_cannot_check_in_another_teams_ticket(self): + ticket = EventTicketFactory.create(event=self.event_b).name + + with self.set_user(self.frontdesk), self.assertRaises(NotPermitted): + validate_ticket_for_checkin(ticket) + + def test_unknown_ticket_still_raises_not_found(self): + with self.set_user(self.frontdesk), self.assertRaises(TicketNotFound): + validate_ticket_for_checkin("no-such-ticket") + + +def linked_to(doctype: str) -> set[str]: + """Doctypes with a Link field named after `doctype`'s short name, e.g. `team` -> Buzz Team.""" + fieldname = doctype.replace("Buzz ", "").lower() + parents = frappe.get_all( + "DocField", + filters={"fieldname": fieldname, "fieldtype": "Link", "options": doctype}, + pluck="parent", + ) + return {parent for parent in parents if not frappe.get_meta(parent).istable} + + +def tenant_doctypes() -> set[str]: + """Derived from the schema, so a new team-owned doctype cannot be missed.""" + return (linked_to("Buzz Team") | linked_to("Buzz Event")) - SELF_SCOPED_DOCTYPES diff --git a/buzz/tests/test_ticket_access.py b/buzz/tests/test_ticket_access.py new file mode 100644 index 00000000..bb670a88 --- /dev/null +++ b/buzz/tests/test_ticket_access.py @@ -0,0 +1,184 @@ +import frappe + +from buzz.tests.base_test_cases import TeamPermissionTestCase +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamMembershipFactory, + EventBookingFactory, + EventTicketFactory, + UserFactory, +) + + +class TicketAccessTestCase(TeamPermissionTestCase): + def create_ticket(self, owner: str, *traits: str, **overrides) -> str: + """On event B, held by `owner` unless `attendee_email` says otherwise.""" + ticket = EventTicketFactory.create( + *traits, **{"event": self.event_b, "attendee_email": owner, **overrides} + ) + frappe.db.set_value("Event Ticket", ticket.name, "owner", owner, update_modified=False) + return ticket.name + + def create_booking(self, user: str, owner: str | None = None) -> str: + booking = EventBookingFactory.create(event=self.event_b, user=user) + frappe.db.set_value("Event Booking", booking.name, "owner", owner or user, update_modified=False) + return booking.name + + +class TestNonMemberCarveOuts(TicketAccessTestCase): + def test_attendee_lists_own_ticket_without_a_membership(self): + mine = self.create_ticket(self.outsider) + theirs = self.create_ticket(self.bob) + + tickets = self.list_as(self.outsider, "Event Ticket") + + self.assertIn(mine, tickets) + self.assertNotIn(theirs, tickets) + + def test_attendee_lists_a_ticket_someone_else_created(self): + mine = self.create_ticket(self.bob, attendee_email=self.outsider) + + self.assertIn(mine, self.list_as(self.outsider, "Event Ticket")) + + def test_booker_lists_a_ticket_held_by_someone_else(self): + booking = self.create_booking(self.outsider) + theirs = self.create_ticket(self.bob, attendee_email="perm-guest@example.com", booking=booking) + + self.assertIn(theirs, self.list_as(self.outsider, "Event Ticket")) + + def test_an_unrelated_user_sees_neither_the_ticket_nor_the_booking(self): + booking = self.create_booking(self.bob) + by_attendee = self.create_ticket(self.bob, attendee_email=self.alice) + by_booking = self.create_ticket(self.bob, attendee_email=self.alice, booking=booking) + + tickets = self.list_as(self.outsider, "Event Ticket") + + self.assertNotIn(by_attendee, tickets) + self.assertNotIn(by_booking, tickets) + self.assertNotIn(booking, self.list_as(self.outsider, "Event Booking")) + + def test_an_unrelated_user_cannot_open_someone_elses_ticket(self): + theirs = self.create_ticket(self.bob, attendee_email=self.alice) + + with self.set_user(self.outsider), self.assertRaises(frappe.PermissionError): + frappe.get_doc("Event Ticket", theirs).check_permission("read") + + def test_an_unstamped_event_does_not_open_its_tickets_to_a_stranger(self): + orphan = BuzzEventFactory.create("unpublished", team=self.team_b).name + theirs = self.create_ticket(self.bob, event=orphan, attendee_email=self.alice) + frappe.db.set_value("Buzz Event", orphan, "team", None, update_modified=False) + + self.assertNotIn(theirs, self.list_as(self.outsider, "Event Ticket")) + with self.set_user(self.outsider), self.assertRaises(frappe.PermissionError): + frappe.get_doc("Event Ticket", theirs).check_permission("read") + + def test_attendee_reads_but_cannot_write_their_ticket(self): + mine = self.create_ticket(self.bob, attendee_email=self.outsider) + + self.assertTrue(self.has_permission_as(self.outsider, "read", "Event Ticket", mine)) + self.assertFalse(self.has_permission_as(self.outsider, "write", "Event Ticket", mine)) + + def test_buyer_lists_a_guest_checkout_booking(self): + # Guest checkout runs as Administrator, so `owner` never names the buyer. + booking = self.create_booking(self.outsider, owner="Administrator") + + self.assertIn(booking, self.list_as(self.outsider, "Event Booking")) + + def test_buyer_reads_their_own_booking_but_writes_nothing(self): + # Only the service flow and organisers write bookings; the portal only reads them. + mine = self.create_booking(self.outsider, owner="Administrator") + theirs = self.create_booking(self.bob) + + self.assertTrue(self.has_permission_as(self.outsider, "read", "Event Booking", mine)) + self.assertFalse(self.has_permission_as(self.outsider, "write", "Event Booking", mine)) + self.assertFalse(self.has_permission_as(self.outsider, "write", "Event Booking", theirs)) + + def test_nobody_deletes_a_booking_from_the_portal(self): + # Bookings are cancelled, never deleted, so even the buyer has no delete. + mine = self.create_booking(self.outsider) + guest_checkout = self.create_booking(self.outsider, owner="Guest") + theirs = self.create_booking(self.bob) + + for booking in (mine, guest_checkout, theirs): + with self.subTest(booking=booking): + self.assertFalse(self.has_permission_as(self.outsider, "delete", "Event Booking", booking)) + with self.set_user(self.outsider), self.assertRaises(frappe.PermissionError): + frappe.delete_doc("Event Booking", booking) + + +class TicketHolderTestCase(TicketAccessTestCase): + """A ticket is owned by whoever booked it, which is rarely the attendee holding it.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.booker = UserFactory.create_once("perm-booker@example.com").name + cls.holder = UserFactory.create_once("perm-holder@example.com").name + cls.stranger = UserFactory.create_once("perm-stranger@example.com").name + cls.viewer = UserFactory.create_once("perm-viewer@example.com").name + BuzzTeamMembershipFactory.create(team=cls.team_b, user=cls.viewer, team_role="Viewer") + + def create_held_ticket(self, owner: str | None = None) -> str: + return self.create_ticket(owner or self.booker, "submitted", attendee_email=self.holder) + + +class TestTicketHolderVisibility(TicketHolderTestCase): + def test_holder_sees_a_guest_booked_ticket(self): + ticket = self.create_held_ticket(owner="Guest") + + self.assertIn(ticket, self.list_as(self.holder, "Event Ticket")) + + def test_booker_still_sees_the_tickets_they_created(self): + ticket = self.create_held_ticket() + + self.assertIn(ticket, self.list_as(self.booker, "Event Ticket")) + + def test_team_member_reads_every_ticket_for_their_event(self): + ticket = self.create_held_ticket() + + self.assertTrue(self.has_permission_as(self.bob, "read", "Event Ticket", ticket)) + + def test_viewer_sees_their_teams_tickets(self): + ticket = self.create_held_ticket() + + self.assertIn(ticket, self.list_as(self.viewer, "Event Ticket")) + + +class TestTicketImmutability(TicketHolderTestCase): + """Reading a ticket you hold is a carve-out. Changing one is not.""" + + def test_holder_cannot_cancel_or_delete_their_ticket(self): + ticket = self.create_held_ticket() + + self.assertFalse(self.has_permission_as(self.holder, "cancel", "Event Ticket", ticket)) + self.assertFalse(self.has_permission_as(self.holder, "delete", "Event Ticket", ticket)) + + def test_holder_saving_their_ticket_is_refused(self): + ticket = self.create_ticket(self.booker, attendee_email=self.holder) + + with self.set_user(self.holder): + doc = frappe.get_doc("Event Ticket", ticket) + doc.attendee_name = "Renamed" + + with self.assertRaises(frappe.PermissionError): + doc.save() + + def test_stranger_cannot_write_another_persons_ticket(self): + ticket = self.create_held_ticket() + + self.assertFalse(self.has_permission_as(self.stranger, "write", "Event Ticket", ticket)) + + def test_submitted_ticket_is_frozen_even_for_administrator(self): + doc = frappe.get_doc("Event Ticket", self.create_held_ticket()) + doc.event = self.event_a + + with self.assertRaises(frappe.UpdateAfterSubmitError): + doc.save() + + def test_only_the_transfer_fields_stay_editable_after_submit(self): + editable = { + field.fieldname for field in frappe.get_meta("Event Ticket").fields if field.allow_on_submit + } + + # The ticket transfer flow needs these four and nothing else. + self.assertEqual(editable, {"first_name", "last_name", "attendee_name", "attendee_email"}) diff --git a/buzz/tests/utils.py b/buzz/tests/utils.py new file mode 100644 index 00000000..ae12cb57 --- /dev/null +++ b/buzz/tests/utils.py @@ -0,0 +1,62 @@ +from contextlib import contextmanager +from email import message_from_string +from unittest.mock import patch + +import frappe + +from buzz.events.doctype.event_venue.map_link import MAP_LINK_CACHE_PREFIX + + +def clear_map_link_cache(): + frappe.cache.delete_keys(MAP_LINK_CACHE_PREFIX) + + +def queued_emails(communication: str) -> list: + return frappe.get_all( + "Email Queue", + filters={"reference_doctype": "Event Communication", "reference_name": communication}, + fields=["name", "send_after", "message"], + ) + + +def queued_recipients(communication: str) -> set[str]: + queue_names = [row.name for row in queued_emails(communication)] + if not queue_names: + return set() + return set( + frappe.get_all("Email Queue Recipient", filters={"parent": ["in", queue_names]}, pluck="recipient") + ) + + +def html_part(raw_message: str) -> str: + """The HTML body, decoded: the queue stores it quoted-printable, wrapped at 76 columns.""" + for part in message_from_string(raw_message).walk(): + if part.get_content_type() == "text/html": + return part.get_payload(decode=True).decode() + return "" + + +@contextmanager +def capturing(): + """Telemetry on, and whatever was captured sent as if the transaction committed.""" + with ( + patch("buzz.telemetry.is_enabled", return_value=True), + patch("buzz.telemetry.frappe_capture") as mock_capture, + ): + yield mock_capture + frappe.db.after_commit.run() + + +def captured(mock_capture) -> list[tuple[str, dict]]: + return [(call.args[0], call.kwargs.get("properties", {})) for call in mock_capture.call_args_list] + + +def captured_names(mock_capture) -> list[str]: + return [name for name, _properties in captured(mock_capture)] + + +def properties_of(mock_capture, event: str) -> dict: + matches = [properties for name, properties in captured(mock_capture) if name == event] + if len(matches) != 1: + raise AssertionError(f"expected one {event}, got {captured_names(mock_capture)}") + return matches[0] 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..6bebc39a 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.base_test_cases import CouponTestCase +from buzz.tests.factories import BuzzCouponCodeFactory, UserFactory -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..970ec897 --- /dev/null +++ b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_discounts.py @@ -0,0 +1,54 @@ +import frappe + +from buzz.tests.base_test_cases import CouponTestCase +from buzz.tests.factories import BuzzCouponCodeFactory + + +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..d42c491b --- /dev/null +++ b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_free_tickets.py @@ -0,0 +1,58 @@ +import frappe + +from buzz.tests.base_test_cases import CouponTestCase +from buzz.tests.factories import BuzzCouponCodeFactory + + +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..7cbf1959 --- /dev/null +++ b/buzz/ticketing/doctype/buzz_coupon_code/test_coupon_scope.py @@ -0,0 +1,65 @@ +import frappe + +from buzz.tests.base_test_cases import ( + CouponTestCase, + attendee_rows, + create_paid_ticket_type, +) +from buzz.tests.factories import BuzzCouponCodeFactory, BuzzEventFactory, EventBookingFactory + + +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_booking/test_booking_confirmation.py b/buzz/ticketing/doctype/event_booking/test_booking_confirmation.py new file mode 100644 index 00000000..3edb72b5 --- /dev/null +++ b/buzz/ticketing/doctype/event_booking/test_booking_confirmation.py @@ -0,0 +1,66 @@ +import frappe + +from buzz.api.booking import get_booking_confirmation, process_booking +from buzz.api.booking.services import get_booking_access_token, verify_booking_access_token +from buzz.tests.base_test_cases import BookingTestCase +from buzz.tests.factories import EventBookingFactory, EventTicketTypeFactory + + +class TestBookingConfirmation(BookingTestCase): + def test_access_token_roundtrip(self): + token = get_booking_access_token("B-TEST-001") + + self.assertTrue(verify_booking_access_token("B-TEST-001", token)) + for wrong_token in ("deadbeef", "", None): + self.assertFalse(verify_booking_access_token("B-TEST-001", wrong_token)) + self.assertFalse(verify_booking_access_token("B-OTHER-002", token)) + + def test_free_booking_redirects_to_the_token_gated_success_page(self): + payload = process_booking(self.booking_request()) + + path, token = payload.redirect_to.split("?token=") + self.assertEqual(path, f"/booking-success/{payload.booking_name}") + self.assertTrue(verify_booking_access_token(payload.booking_name, token)) + + def test_free_booking_confirms_on_submit(self): + booking = self.create_booking(self.free_ticket_type.name) + + booking.submit() + + self.assertEqual( + (booking.status, booking.payment_status, booking.total_amount), ("Confirmed", "Paid", 0) + ) + + def test_guest_with_a_valid_token_sees_the_booking(self): + booking = self.create_submitted_booking() + token = get_booking_access_token(booking.name) + + with self.set_user("Guest"): + result = get_booking_confirmation(booking.name, token=token) + + self.assertEqual(result.booking.name, booking.name) + self.assertEqual([ticket.attendee_name for ticket in result.tickets], ["Conf"]) + self.assertEqual(result.event.title, self.event.title) + + def test_guest_with_a_bad_token_is_refused(self): + booking = self.create_submitted_booking() + + with self.set_user("Guest"): + for token in ("wrong-token", ""): + with self.subTest(token=token), self.assertRaises(frappe.PermissionError): + get_booking_confirmation(booking.name, token=token) + + def test_a_user_with_access_needs_no_token(self): + booking = self.create_submitted_booking() + + self.assertEqual(get_booking_confirmation(booking.name).booking.name, booking.name) + + def create_submitted_booking(self): + paid_ticket_type = EventTicketTypeFactory.create("paid", event=self.event.name).name + booking = self.create_booking(paid_ticket_type) + booking.submit() + return booking + + def create_booking(self, ticket_type: str): + attendees = [{"ticket_type": ticket_type, "first_name": "Conf", "email": "conf@example.com"}] + return EventBookingFactory.create(event=self.event.name, attendees=attendees) diff --git a/buzz/ticketing/doctype/event_booking/test_booking_emails.py b/buzz/ticketing/doctype/event_booking/test_booking_emails.py new file mode 100644 index 00000000..80bf379a --- /dev/null +++ b/buzz/ticketing/doctype/event_booking/test_booking_emails.py @@ -0,0 +1,197 @@ +from unittest.mock import patch + +import frappe + +from buzz.tests.base_test_cases import BookingTestCase +from buzz.tests.factories import ( + BuzzTeamFactory, + EmailTemplateFactory, + EventBookingFactory, + EventTicketTypeFactory, + UserFactory, +) + +OFFLINE_METHOD = "Bank Transfer" + + +class BookingEmailTestCase(BookingTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.email_booker = UserFactory.create_once("booking-email-booker@example.com").name + cls.ticket_type = EventTicketTypeFactory.create("paid", event=cls.event.name, title="Email Ticket") + + def setUp(self): + super().setUp() + # Ticket emails stay off, so `frappe.sendmail` only sees the booking email. + self.set_event( + { + "send_ticket_email": 0, + "send_booking_confirmation_email": 1, + "booking_confirmation_email_template": None, + "offline_acknowledgement_email_template": None, + } + ) + self.set_team_template(None) + + def create_booking(self, user: str, **overrides): + attendees = [ + {"ticket_type": self.ticket_type.name, "first_name": "John", "email": "john@example.com"} + ] + return EventBookingFactory.create(event=self.event.name, user=user, attendees=attendees, **overrides) + + def create_template(self, subject_prefix: str) -> str: + return EmailTemplateFactory.create(subject=f"{subject_prefix} - {{{{ event_title }}}}").name + + def set_team_template(self, template: str | None): + BuzzTeamFactory.set_settings( + self.event.team, {"default_booking_confirmation_email_template": template} + ) + + +@patch("frappe.sendmail") +class TestBookingConfirmationEmail(BookingEmailTestCase): + def test_sends_confirmation_to_booker(self, sendmail): + booking = self.submit_booking() + + sendmail.assert_called_once() + self.assertIn(self.email_booker, sendmail.call_args[1]["recipients"]) + self.assertEqual(sendmail.call_args[1]["reference_doctype"], "Event Booking") + self.assertEqual(sendmail.call_args[1]["reference_name"], booking.name) + + def test_uses_inline_template_when_none_configured(self, sendmail): + self.submit_booking() + + self.assertEqual(sendmail.call_args[1]["template"], "booking_confirmation") + + def test_skips_administrator(self, sendmail): + self.submit_booking("Administrator") + + sendmail.assert_not_called() + + def test_skips_guest(self, sendmail): + self.submit_booking("Guest") + + sendmail.assert_not_called() + + def test_respects_event_toggle_off(self, sendmail): + self.set_event({"send_booking_confirmation_email": 0}) + + self.submit_booking() + + sendmail.assert_not_called() + + def test_uses_event_template_when_set(self, sendmail): + self.set_event({"booking_confirmation_email_template": self.create_template("EVENT")}) + + self.submit_booking() + + self.assertIn("EVENT", sendmail.call_args[1]["subject"]) + + def test_falls_back_to_the_teams_default_template(self, sendmail): + self.set_team_template(self.create_template("TEAM")) + + self.submit_booking() + + self.assertIn("TEAM", sendmail.call_args[1]["subject"]) + + def test_support_email_comes_from_the_teams_settings(self, sendmail): + BuzzTeamFactory.set_settings(self.event.team, {"support_email": "booking-support@example.com"}) + + self.submit_booking() + + self.assertEqual(sendmail.call_args[1]["args"]["support_email"], "booking-support@example.com") + + def test_event_template_takes_precedence_over_the_team_default(self, sendmail): + self.set_event({"booking_confirmation_email_template": self.create_template("EVENT")}) + self.set_team_template(self.create_template("TEAM")) + + self.submit_booking() + + sendmail.assert_called_once() + self.assertIn("EVENT", sendmail.call_args[1]["subject"]) + self.assertNotIn("TEAM", sendmail.call_args[1]["subject"]) + + def submit_booking(self, user: str | None = None): + booking = self.create_booking(user or self.email_booker) + booking.submit() + return booking + + +@patch("frappe.sendmail") +class TestOfflineAcknowledgementEmail(BookingEmailTestCase): + def test_sends_acknowledgement_to_booker(self, sendmail): + booking = self.send_acknowledgement() + + sendmail.assert_called_once() + self.assertIn(self.email_booker, sendmail.call_args[1]["recipients"]) + self.assertEqual(sendmail.call_args[1]["reference_doctype"], "Event Booking") + self.assertEqual(sendmail.call_args[1]["reference_name"], booking.name) + + def test_uses_inline_template_when_none_configured(self, sendmail): + self.send_acknowledgement() + + self.assertEqual(sendmail.call_args[1]["template"], "offline_booking_acknowledgement") + + def test_carries_the_booking_summary(self, sendmail): + booking = self.send_acknowledgement() + + args = sendmail.call_args[1]["args"] + self.assertEqual(args["doc"].name, booking.name) + self.assertEqual(args["event_title"], self.event.title) + # Ticket types autoname to integers and arrive off the row as strings. + self.assertEqual([row["ticket_type_title"] for row in args["attendee_rows"]], ["Email Ticket"]) + + def test_builtin_template_renders(self, sendmail): + # Every other test mocks the send, so only this one catches a broken Jinja tag. + booking = self.create_offline_booking(self.email_booker) + + html = frappe.render_template( + "templates/emails/offline_booking_acknowledgement.html", booking.get_booking_email_args() + ) + + for text in ("Payment verification pending", booking.name, OFFLINE_METHOD, "Email Ticket"): + self.assertIn(text, html) + + def test_uses_event_template_when_set(self, sendmail): + self.set_event({"offline_acknowledgement_email_template": self.create_template("OFFLINE")}) + + self.send_acknowledgement() + + self.assertIn("OFFLINE", sendmail.call_args[1]["subject"]) + + def test_ignores_the_confirmation_template(self, sendmail): + self.set_event({"booking_confirmation_email_template": self.create_template("EVENT")}) + self.set_team_template(self.create_template("TEAM")) + + self.send_acknowledgement() + + self.assertEqual(sendmail.call_args[1]["template"], "offline_booking_acknowledgement") + + def test_respects_event_toggle_off(self, sendmail): + self.set_event({"send_booking_confirmation_email": 0}) + + self.send_acknowledgement() + + sendmail.assert_not_called() + + def test_skips_system_users(self, sendmail): + for user in ("Administrator", "Guest"): + with self.subTest(user=user): + self.send_acknowledgement(user) + + sendmail.assert_not_called() + + def create_offline_booking(self, user: str): + return self.create_booking( + user, + payment_method="Offline", + offline_payment_method=OFFLINE_METHOD, + status="Approval Pending", + payment_status="Verification Pending", + ) + + def send_acknowledgement(self, user: str | None = None): + booking = self.create_offline_booking(user or self.email_booker) + booking.send_offline_acknowledgement_email() + return booking diff --git a/buzz/ticketing/doctype/event_booking/test_booking_totals.py b/buzz/ticketing/doctype/event_booking/test_booking_totals.py new file mode 100644 index 00000000..daec2107 --- /dev/null +++ b/buzz/ticketing/doctype/event_booking/test_booking_totals.py @@ -0,0 +1,60 @@ +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 = 100 + + +class TestBookingTotals(IntegrationTestCase): + def test_total_without_taxes_includes_add_ons(self): + event = BuzzEventFactory.create().name + booking = self.create_booking(event, TICKET_PRICE, attendees=2) + self.assertEqual(booking.total_amount, 1000) + + add_on = TicketAddOnFactory.create(event=event, price=ADD_ON_PRICE).name + booking.attendees[0].add_ons = create_add_on_doc("John", [{"add_on": add_on, "value": "XL"}]).name + booking.save() + + self.assertEqual(booking.attendees[0].number_of_add_ons, 1) + self.assertEqual(booking.attendees[0].add_on_total, ADD_ON_PRICE) + self.assertEqual((booking.net_amount, booking.total_amount), (1100, 1100)) + + def test_exclusive_tax_is_added_on_top(self): + event = BuzzEventFactory.create("with_tax", tax_label="GST", tax_percentage=18).name + + booking = self.create_booking(event, TICKET_PRICE, attendees=2) + + self.assertEqual((booking.tax_label, booking.tax_percentage), ("GST", 18)) + self.assertEqual((booking.net_amount, booking.tax_amount, booking.total_amount), (1000, 180, 1180)) + + def test_custom_tax_label(self): + event = BuzzEventFactory.create("with_tax", tax_label="VAT", tax_percentage=20).name + + booking = self.create_booking(event, 100) + + self.assertEqual((booking.tax_label, booking.tax_percentage), ("VAT", 20)) + self.assertEqual((booking.tax_amount, booking.total_amount), (20, 120)) + + def test_inclusive_tax_is_back_calculated_without_raising_the_total(self): + event = BuzzEventFactory.create("with_tax", tax_inclusive=1, tax_percentage=18).name + + booking = self.create_booking(event, TICKET_PRICE, attendees=2) + + self.assertEqual(booking.net_amount, 1000) + self.assertAlmostEqual(booking.tax_amount, round(1000 * 18 / 118, 2), places=2) + self.assertEqual(booking.total_amount, 1000) + + def create_booking(self, event: str, price: float, attendees: int = 1): + ticket_type = EventTicketTypeFactory.create(event=event, prices=[{"currency": "INR", "price": price}]) + rows = [ + {"ticket_type": ticket_type.name, "first_name": name, "email": f"{name.lower()}@example.com"} + for name in ("John", "Jenny")[:attendees] + ] + return EventBookingFactory.create(event=event, attendees=rows) diff --git a/buzz/ticketing/doctype/event_booking/test_event_booking.py b/buzz/ticketing/doctype/event_booking/test_event_booking.py index feae4165..c9a98c2e 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_booking.py +++ b/buzz/ticketing/doctype/event_booking/test_event_booking.py @@ -1,1587 +1,74 @@ # Copyright (c) 2025, BWH Studios and Contributors # See license.txt -from unittest.mock import patch - import frappe -from frappe.tests import IntegrationTestCase - -from buzz.api.booking import get_booking_confirmation as get_booking_confirmation_endpoint -from buzz.api.booking import process_booking as process_booking_endpoint -from buzz.api.booking.schemas import BookingRequest -from buzz.events.doctype.buzz_team_settings.test_buzz_team_settings import set_team_settings - - -def process_booking(**kwargs): - return process_booking_endpoint(BookingRequest(**kwargs)).__json__() - - -def get_booking_confirmation(booking_id, token=None): - return get_booking_confirmation_endpoint(booking_id, token=token).__json__() - - -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] - -TEST_ADD_ON_PRICE = 100 -TEST_VIP_TICKET_TYPE_PRICE = 500 - - -class IntegrationTestEventBooking(IntegrationTestCase): - """ - Integration tests for EventBooking. - Use this class for testing interactions between multiple components. - """ - - def test_total_calculation_without_taxes(self): - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - # Disable tax at event level - test_event.apply_tax = False - test_event.save() - - test_ticket_add_on = frappe.get_doc( - { - "doctype": "Ticket Add-on", - "event": test_event.name, - "title": "T-Shirt", - "price": TEST_ADD_ON_PRICE, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "VIP", - "prices": [{"currency": "INR", "price": TEST_VIP_TICKET_TYPE_PRICE}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"}, - ], - } - ).insert() - - # without add ons - self.assertEqual(test_booking.total_amount, 1000) - - test_attendee_add_on = frappe.get_doc( - { - "doctype": "Attendee Ticket Add-on", - "add_ons": [{"add_on": test_ticket_add_on.name, "value": "XL"}], - } - ).insert() - - test_booking.attendees[0].add_ons = test_attendee_add_on.name - test_booking.save() - - # with one add-on - self.assertEqual(test_booking.attendees[0].number_of_add_ons, 1) - self.assertEqual(test_booking.attendees[0].add_on_total, TEST_ADD_ON_PRICE) - self.assertEqual(test_booking.net_amount, 1100) - self.assertEqual(test_booking.total_amount, 1100) - - def test_total_calculation_with_taxes(self): - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - # Enable tax at event level (exclusive) - test_event.apply_tax = True - test_event.tax_inclusive = False - test_event.tax_label = "GST" - test_event.tax_percentage = 18 - test_event.save() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "VIP", - "prices": [{"currency": "INR", "price": TEST_VIP_TICKET_TYPE_PRICE}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"}, - ], - } - ).insert() - - self.assertEqual(test_booking.net_amount, 1000) - self.assertEqual(test_booking.tax_label, "GST") - self.assertEqual(test_booking.tax_percentage, 18) - self.assertEqual(test_booking.tax_amount, 180) - self.assertEqual(test_booking.total_amount, 1180) - - def test_total_calculation_with_custom_tax_label(self): - """Test that custom tax labels (e.g., VAT) work correctly.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - # Enable tax with custom VAT label (exclusive) - test_event.apply_tax = True - test_event.tax_inclusive = False - test_event.tax_label = "VAT" - test_event.tax_percentage = 20 - test_event.save() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Standard", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - ], - } - ).insert() - - self.assertEqual(test_booking.tax_label, "VAT") - self.assertEqual(test_booking.tax_percentage, 20) - self.assertEqual(test_booking.tax_amount, 20) - self.assertEqual(test_booking.total_amount, 120) - - def test_tax_inclusive_calculation(self): - """Test that tax-inclusive prices back-calculate tax without increasing total.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - # Enable tax-inclusive pricing - test_event.apply_tax = True - test_event.tax_inclusive = True - test_event.tax_label = "GST" - test_event.tax_percentage = 18 - test_event.save() +from buzz.api.booking import process_booking +from buzz.tests.base_test_cases import BookingTestCase +from buzz.tests.factories import EventBookingFactory, EventTicketFactory, EventTicketTypeFactory - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "VIP", - "prices": [{"currency": "INR", "price": TEST_VIP_TICKET_TYPE_PRICE}], - } - ).insert() +UTM_PARAMETERS = { + "utm_source": "facebook", + "utm_medium": "social", + "utm_campaign": "winter_promo", + "utm_content": "banner_ad", + "utm_term": "event tickets", +} - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"}, - ], - } - ).insert() - - # net_amount = 2 * 500 = 1000 - self.assertEqual(test_booking.net_amount, 1000) - self.assertEqual(test_booking.tax_label, "GST") - self.assertEqual(test_booking.tax_percentage, 18) - # tax_amount = 1000 * 18 / 118 = 152.54 (rounded to 2 decimals) - self.assertAlmostEqual(test_booking.tax_amount, round(1000 * 18 / 118, 2), places=2) - # total stays the same — tax is included in the price - self.assertEqual(test_booking.total_amount, 1000) - - def test_tax_inclusive_with_single_ticket(self): - """Test tax-inclusive with a single ticket at a round price.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - test_event.apply_tax = True - test_event.tax_inclusive = True - test_event.tax_label = "VAT" - test_event.tax_percentage = 20 - test_event.save() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Standard", - "prices": [{"currency": "INR", "price": 120}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - ], - } - ).insert() - - # Price is 120 inclusive of 20% VAT - # tax_amount = 120 * 20 / 120 = 20 - self.assertEqual(test_booking.net_amount, 120) - self.assertEqual(test_booking.tax_amount, 20) - self.assertEqual(test_booking.total_amount, 120) - - def test_tax_exclusive_still_works(self): - """Ensure that when tax_inclusive is False, tax is still added on top.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - test_event.apply_tax = True - test_event.tax_inclusive = False - test_event.tax_label = "GST" - test_event.tax_percentage = 18 - test_event.save() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "VIP", - "prices": [{"currency": "INR", "price": TEST_VIP_TICKET_TYPE_PRICE}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"}, - ], - } - ).insert() - - # Exclusive: tax added on top - self.assertEqual(test_booking.net_amount, 1000) - self.assertEqual(test_booking.tax_amount, 180) - self.assertEqual(test_booking.total_amount, 1180) +class TestTicketAvailability(BookingTestCase): def test_prevents_booking_if_tickets_unavailable(self): - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_vip_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "VIP", - "prices": [{"currency": "INR", "price": 500}], - "is_published": True, - "max_tickets_available": 2, - } - ).insert() - - test_normal_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Normal", - "prices": [{"currency": "INR", "price": 500}], - "is_published": True, - } - ).insert() + vip = EventTicketTypeFactory.create(event=self.event.name, max_tickets_available=2).name + normal = self.free_ticket_type.name + EventTicketFactory.create("submitted", event=self.event.name, ticket_type=vip) + self.create_booking(vip, normal).submit() - # VIP Ticket 1 - frappe.get_doc( - { - "doctype": "Event Ticket", - "ticket_type": test_vip_ticket_type.name, - "attendee_name": "John Doe", - "attendee_email": "john@email.com", - } - ).insert().submit() - - # VIP Ticket 2 with Normal Ticket 1 - frappe.get_doc( - { - "doctype": "Event Booking", - "user": frappe.session.user, - "event": test_event.name, - "attendees": [ - { - "first_name": "John", - "last_name": "Doe", - "ticket_type": test_vip_ticket_type.name, - "email": "john@email.com", - }, - { - "first_name": "Jenny", - "last_name": "Doe", - "ticket_type": test_normal_ticket_type.name, - "email": "jenny@email.com", - }, - ], - } - ).insert().submit() - - # VIP Ticket 3 with Normal Ticket 2 with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Event Booking", - "user": frappe.session.user, - "event": test_event.name, - "attendees": [ - { - "first_name": "John", - "last_name": "Doe", - "ticket_type": test_vip_ticket_type.name, - "email": "john@email.com", - }, - { - "first_name": "John", - "last_name": "Doe", - "ticket_type": test_normal_ticket_type.name, - "email": "john@email.com", - }, - ], - } - ).insert() - - # Unpublish normal ticket type - test_normal_ticket_type.is_published = False - test_normal_ticket_type.save() + self.create_booking(vip, normal) - # Booking with unpublished ticket type + frappe.db.set_value("Event Ticket Type", normal, "is_published", 0) + frappe.clear_document_cache("Event Ticket Type", normal) with self.assertRaises(frappe.ValidationError): - frappe.get_doc( - { - "doctype": "Event Booking", - "user": frappe.session.user, - "event": test_event.name, - "attendees": [ - { - "first_name": "John", - "last_name": "Doe", - "ticket_type": test_normal_ticket_type.name, - "email": "john@email.com", - } - ], - } - ).insert() - - def test_utm_parameters_are_saved(self): - """Test that UTM parameters are correctly saved with bookings.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Standard", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - ], - "utm_parameters": [ - {"utm_name": "utm_source", "value": "google"}, - {"utm_name": "utm_medium", "value": "cpc"}, - {"utm_name": "utm_campaign", "value": "summer_sale"}, - ], - } - ).insert() - - self.assertEqual(len(test_booking.utm_parameters), 3) - self.assertEqual(test_booking.utm_parameters[0].utm_name, "utm_source") - self.assertEqual(test_booking.utm_parameters[0].value, "google") - self.assertEqual(test_booking.utm_parameters[1].utm_name, "utm_medium") - self.assertEqual(test_booking.utm_parameters[1].value, "cpc") - self.assertEqual(test_booking.utm_parameters[2].utm_name, "utm_campaign") - self.assertEqual(test_booking.utm_parameters[2].value, "summer_sale") - - def test_booking_without_utm_parameters(self): - """Test that bookings work correctly without UTM parameters.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Standard", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - ], - } - ).insert() - - self.assertEqual(len(test_booking.utm_parameters), 0) - - def test_custom_utm_parameters(self): - """Test that custom UTM parameters (beyond standard ones) are saved.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Standard", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() - - test_booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"}, - ], - "utm_parameters": [ - {"utm_name": "utm_source", "value": "newsletter"}, - {"utm_name": "utm_custom_param", "value": "special_offer"}, - ], - } - ).insert() - - self.assertEqual(len(test_booking.utm_parameters), 2) - # Check custom utm parameter is saved - custom_param = next( - (p for p in test_booking.utm_parameters if p.utm_name == "utm_custom_param"), None - ) - self.assertIsNotNone(custom_param) - self.assertEqual(custom_param.value, "special_offer") - - -class TestProcessBookingAPI(IntegrationTestCase): - """Test the process_booking API endpoint for UTM parameter handling.""" - - def test_process_booking_with_utm_parameters(self): - """Test that process_booking API correctly saves UTM parameters.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "API Test Ticket", - "is_published": True, - } - ).insert() - - # Disable tax at event level - test_event.apply_tax = False - test_event.save() - - attendees = [ - { - "first_name": "API Test User", - "email": "apitest@email.com", - "ticket_type": str(test_ticket_type.name), - "add_ons": [], - } - ] - - utm_parameters = [ - {"utm_name": "utm_source", "value": "facebook"}, - {"utm_name": "utm_medium", "value": "social"}, - {"utm_name": "utm_campaign", "value": "winter_promo"}, - {"utm_name": "utm_content", "value": "banner_ad"}, - {"utm_name": "utm_term", "value": "event tickets"}, - ] - - result = process_booking( - attendees=attendees, - event=str(test_event.name), - utm_parameters=utm_parameters, - ) - - # Verify booking was created - self.assertIn("booking_name", result) - - # Fetch the booking and verify UTM parameters - booking = frappe.get_doc("Event Booking", result["booking_name"]) - self.assertEqual(len(booking.utm_parameters), 5) - - # Verify each UTM parameter - utm_dict = {p.utm_name: p.value for p in booking.utm_parameters} - self.assertEqual(utm_dict["utm_source"], "facebook") - self.assertEqual(utm_dict["utm_medium"], "social") - self.assertEqual(utm_dict["utm_campaign"], "winter_promo") - self.assertEqual(utm_dict["utm_content"], "banner_ad") - self.assertEqual(utm_dict["utm_term"], "event tickets") - - # ==================== Offline Payment Tests ==================== - - def _cleanup_offline_methods(self, event_name): - """Remove all Offline Payment Method records for the given event.""" - frappe.db.delete("Offline Payment Method", {"event": event_name}) - - def test_offline_booking_cannot_be_submitted_directly(self): - """Test that offline bookings cannot be submitted directly — must use approve/reject.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self._cleanup_offline_methods(test_event.name) - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Offline Ticket", - "prices": [{"currency": "INR", "price": 500}], - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"} - ], - "payment_method": "Offline", - } - ).insert() - - with self.assertRaises(frappe.ValidationError): - booking.submit() - - def test_approve_offline_booking(self): - """Test approving an offline booking submits it and generates tickets.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self._cleanup_offline_methods(test_event.name) - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Approval Test Ticket", - "prices": [{"currency": "INR", "price": 500}], - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"} - ], - "payment_method": "Offline", - "status": "Approval Pending", - "payment_status": "Verification Pending", - } - ).insert() - - # Booking should be in draft with no tickets - self.assertEqual(booking.docstatus, 0) - tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) - self.assertEqual(len(tickets), 0) - - booking.approve_booking() - booking.reload() - - # After approval, booking should be submitted with tickets generated - self.assertEqual(booking.docstatus, 1) - self.assertEqual(booking.status, "Approved") - self.assertEqual(booking.payment_status, "Paid") - - tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) - self.assertEqual(len(tickets), 1) - - def test_reject_offline_booking(self): - """Test rejecting an offline booking keeps it in draft with no tickets.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self._cleanup_offline_methods(test_event.name) - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Rejection Test Ticket", - "prices": [{"currency": "INR", "price": 500}], - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"} - ], - "payment_method": "Offline", - "status": "Approval Pending", - "payment_status": "Verification Pending", - } - ).insert() - - self.assertEqual(booking.docstatus, 0) - - booking.reject_booking() - booking.reload() - - # Discarded (docstatus=2) and marked as Rejected - self.assertEqual(booking.docstatus, 2) - self.assertEqual(booking.status, "Rejected") - - # No tickets should be generated - tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) - self.assertEqual(len(tickets), 0) - - def test_offline_with_coupon_code(self): - """Test offline payment with coupon code discount.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self._cleanup_offline_methods(test_event.name) - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Coupon Test Ticket", - "prices": [{"currency": "INR", "price": 500}], - } - ).insert() - - coupon = frappe.get_doc( - { - "doctype": "Buzz Coupon Code", - "code": f"OFFLINE10-{frappe.generate_hash(length=4)}", - "coupon_type": "Discount", - "discount_type": "Percentage", - "discount_value": 10, - "is_active": True, - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "coupon_code": coupon.name, - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"} - ], - "payment_method": "Offline", - } - ).insert() - - self.assertEqual(booking.net_amount, 500) - self.assertEqual(booking.discount_amount, 50) - self.assertEqual(booking.total_amount, 450) - - def test_offline_with_tax(self): - """Test offline payment with tax calculation.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_event.apply_tax = True - test_event.tax_percentage = 18 - test_event.tax_label = "GST" - test_event.save() - self._cleanup_offline_methods(test_event.name) - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Tax Test Ticket", - "prices": [{"currency": "INR", "price": 500}], - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"} - ], - "payment_method": "Offline", - } - ).insert() - - self.assertEqual(booking.net_amount, 500) - self.assertEqual(booking.tax_percentage, 18) - self.assertEqual(booking.tax_amount, 90) - self.assertEqual(booking.total_amount, 590) - - def test_offline_booking_requires_payment_method_field(self): - """Test that offline booking requires payment_method field.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self._cleanup_offline_methods(test_event.name) - - frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Payment Method Test", - "prices": [{"currency": "INR", "price": 500}], - } - ).insert() - - # Booking without payment_method field should default to normal flow - booking_without_method = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"} - ], - } - ).insert() - - booking_without_method.submit() - # Without payment_method field, it should go to normal payment flow - self.assertEqual(booking_without_method.payment_status, "Unpaid") - - # Booking with payment_method = "Offline" should block direct submission - booking_with_method = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": test_ticket_type.name, "first_name": "Test2", "email": "test2@test.com"} - ], - "payment_method": "Offline", - } - ).insert() - - with self.assertRaises(frappe.ValidationError): - booking_with_method.submit() - - def test_process_booking_offline_stays_in_draft(self): - """Test that offline bookings via process_booking stay in draft with no tickets.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_event.apply_tax = False - test_event.is_published = True - test_event.save() - self._cleanup_offline_methods(test_event.name) - - offline_method = frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Offline Draft Test", - "prices": [{"currency": "INR", "price": 500}], - "is_published": True, - } - ).insert() - - result = process_booking( - attendees=[ - { - "first_name": "Offline User", - "email": "offline@email.com", - "ticket_type": str(test_ticket_type.name), - "add_ons": [], - } - ], - event=str(test_event.name), - is_offline=True, - offline_payment_method=str(offline_method.name), - ) - - self.assertIn("booking_name", result) - self.assertTrue(result.get("offline_payment")) - - booking = frappe.get_doc("Event Booking", result["booking_name"]) - - # Booking must be in draft (not submitted) - self.assertEqual(booking.docstatus, 0) - self.assertEqual(booking.status, "Approval Pending") - self.assertEqual(booking.payment_status, "Verification Pending") - - # No tickets should exist - tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) - self.assertEqual(len(tickets), 0) - - def test_process_booking_offline_generates_tickets_on_approval(self): - """Test that approving an offline booking created via API generates tickets.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_event.apply_tax = False - test_event.is_published = True - test_event.save() - self._cleanup_offline_methods(test_event.name) - - offline_method = frappe.get_doc( - { - "doctype": "Offline Payment Method", - "title": "Bank Transfer", - "event": test_event.name, - "enabled": 1, - } - ).insert() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Offline Approval Test", - "prices": [{"currency": "INR", "price": 500}], - "is_published": True, - } - ).insert() - - result = process_booking( - attendees=[ - { - "first_name": "Approval User", - "email": "approval@email.com", - "ticket_type": str(test_ticket_type.name), - "add_ons": [], - } - ], - event=str(test_event.name), - is_offline=True, - offline_payment_method=str(offline_method.name), - ) - - booking = frappe.get_doc("Event Booking", result["booking_name"]) - - # No tickets before approval - tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) - self.assertEqual(len(tickets), 0) - - # Approve the booking - booking.approve_booking() - booking.reload() - - # After approval: submitted, approved, tickets generated - self.assertEqual(booking.docstatus, 1) - self.assertEqual(booking.status, "Approved") - self.assertEqual(booking.payment_status, "Paid") - - tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name}) - self.assertEqual(len(tickets), 1) - - def test_process_booking_free_event_returns_redirect_to(self): - """Free bookings (total_amount == 0) must get a redirect_to the new - token-gated booking-success screen, same as the paid-gateway branch.""" - from buzz.api.booking.services import verify_booking_access_token - - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_event.apply_tax = False - test_event.is_published = True - test_event.save() - - free_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Free Redirect Test Ticket", - "is_published": True, - } - ).insert() - - result = process_booking( - attendees=[ - { - "first_name": "Redirect Test User", - "email": "redirecttest@email.com", - "ticket_type": str(free_ticket_type.name), - "add_ons": [], - } - ], - event=str(test_event.name), - ) - - self.assertIn("booking_name", result) - self.assertIn("redirect_to", result) - self.assertTrue(result["redirect_to"].startswith(f"/booking-success/{result['booking_name']}?token=")) - - token = result["redirect_to"].split("token=")[1] - self.assertTrue(verify_booking_access_token(result["booking_name"], token)) - - def test_process_booking_without_utm_parameters(self): - """Test that process_booking API works without UTM parameters.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_event.is_published = True - test_event.save() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "API Test Ticket No UTM", - "is_published": True, - } - ).insert() - - # Disable tax at event level - test_event.apply_tax = False - test_event.save() + self.create_booking(normal) + def create_booking(self, *ticket_types: str): attendees = [ - { - "first_name": "No UTM User", - "email": "noutm@email.com", - "ticket_type": str(test_ticket_type.name), - "add_ons": [], - } + {"first_name": "John", "ticket_type": ticket_type, "email": "john@example.com"} + for ticket_type in ticket_types ] + return EventBookingFactory.create(event=self.event.name, attendees=attendees) - result = process_booking( - attendees=attendees, - event=str(test_event.name), - utm_parameters=None, - ) - - self.assertIn("booking_name", result) - - booking = frappe.get_doc("Event Booking", result["booking_name"]) - self.assertEqual(len(booking.utm_parameters), 0) - - def test_process_booking_with_empty_utm_parameters(self): - """Test that process_booking API handles empty UTM list.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "API Test Ticket Empty UTM", - "is_published": True, - } - ).insert() - - # Disable tax at event level - test_event.apply_tax = False - test_event.is_published = True - test_event.save() - - attendees = [ - { - "first_name": "Empty UTM User", - "email": "emptyutm@email.com", - "ticket_type": str(test_ticket_type.name), - "add_ons": [], - } - ] - - result = process_booking( - attendees=attendees, - event=str(test_event.name), - utm_parameters=[], - ) - - self.assertIn("booking_name", result) - - booking = frappe.get_doc("Event Booking", result["booking_name"]) - self.assertEqual(len(booking.utm_parameters), 0) - - def test_process_booking_failed_for_unpublished_event(self): - """Booking must fail when Buzz Event is not published.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_event.is_published = 0 - test_event.save() - - test_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Unpublished Test Ticket", - "is_published": True, - } - ).insert() - - attendees = [ - { - "first_name": "Failed User", - "email": "failed@email.com", - "ticket_type": str(test_ticket_type.name), - "add_ons": [], - } - ] - - with self.assertRaises(frappe.ValidationError) as ctx: - process_booking( - attendees=attendees, - event=str(test_event.name), - ) - - self.assertIn("Event is not live", str(ctx.exception)) - - def test_free_event_booking_auto_confirms(self): - """Test that free event bookings (total_amount = 0) automatically confirm on submit.""" - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - - free_ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Free Ticket", - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": frappe.session.user, - "attendees": [ - { - "first_name": "Free User", - "email": "free@email.com", - "ticket_type": free_ticket_type.name, - } - ], - } - ).insert() - - booking.submit() - - self.assertEqual(booking.status, "Confirmed", "Free booking should auto-confirm") - self.assertEqual(booking.payment_status, "Paid", "Free booking should be marked as Paid") - self.assertEqual(booking.total_amount, 0, "Free booking should have zero total") - - -class TestBookingConfirmation(IntegrationTestCase): - """Test token-gated guest booking confirmation (issue #167).""" - - def _make_submitted_booking(self): - test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - test_event.apply_tax = False - test_event.save() - - ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": test_event.name, - "title": "Confirmation Test Ticket", - "prices": [{"currency": "INR", "price": 500}], - } - ).insert() - - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": test_event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": ticket_type.name, "first_name": "Conf", "email": "conf@email.com"} - ], - } - ).insert() - booking.submit() - return booking - - def test_access_token_roundtrip(self): - from buzz.api.booking.services import get_booking_access_token, verify_booking_access_token - - token = get_booking_access_token("B-TEST-001") - self.assertTrue(verify_booking_access_token("B-TEST-001", token)) - # wrong token rejected - self.assertFalse(verify_booking_access_token("B-TEST-001", "deadbeef")) - # empty token rejected - self.assertFalse(verify_booking_access_token("B-TEST-001", "")) - self.assertFalse(verify_booking_access_token("B-TEST-001", None)) - # token is booking-specific - self.assertFalse(verify_booking_access_token("B-OTHER-002", token)) - - def test_get_booking_confirmation_valid_token_as_guest(self): - from buzz.api.booking.services import get_booking_access_token - - booking = self._make_submitted_booking() - token = get_booking_access_token(booking.name) - - frappe.set_user("Guest") - try: - result = get_booking_confirmation(booking.name, token=token) - finally: - frappe.set_user("Administrator") - - self.assertEqual(result["booking"]["name"], booking.name) - self.assertEqual(len(result["tickets"]), 1) - self.assertEqual(result["tickets"][0]["attendee_name"], "Conf") - self.assertTrue(result["event"]["title"]) - - def test_get_booking_confirmation_bad_token_as_guest_raises(self): - booking = self._make_submitted_booking() - - frappe.set_user("Guest") - try: - with self.assertRaises(frappe.PermissionError): - get_booking_confirmation(booking.name, token="wrong-token") - with self.assertRaises(frappe.PermissionError): - get_booking_confirmation(booking.name, token="") - finally: - frappe.set_user("Administrator") - - def test_get_booking_confirmation_owner_without_token(self): - booking = self._make_submitted_booking() - - # Administrator owns/has perm — no token needed - result = get_booking_confirmation(booking.name) - self.assertEqual(result["booking"]["name"], booking.name) - - -class TestBookingConfirmationEmail(IntegrationTestCase): - """Confirmation email sent to the booker with a booking summary (issue #56).""" - - BOOKER_EMAIL = "booker-56@example.com" - def setUp(self): - # Configure the shared event and create fixtures per test (not in - # setUpClass) so every mutation lives inside the test's own transaction: - # nothing leaks into other test classes, and the booker User is - # guaranteed to exist when the booking's user link is validated. - self.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.test_event.apply_tax = False - self.test_event.send_booking_confirmation_email = 1 - self.test_event.booking_confirmation_email_template = None - # Keep per-attendee ticket emails off so frappe.sendmail is only invoked - # by the booking confirmation logic. - self.test_event.send_ticket_email = 0 - self.test_event.save() +class TestProcessBooking(BookingTestCase): + def test_saves_utm_parameters(self): + utm_parameters = [{"utm_name": name, "value": value} for name, value in UTM_PARAMETERS.items()] - set_team_settings(self.test_event.team, default_booking_confirmation_email_template=None) - # The rollback restores the settings row but not its cached copy. - self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.test_event.team) + payload = process_booking(self.booking_request(utm_parameters=utm_parameters)) - # A real (non-system) booker whose User email is a valid recipient. - if not frappe.db.exists("User", self.BOOKER_EMAIL): - frappe.get_doc( - { - "doctype": "User", - "email": self.BOOKER_EMAIL, - "first_name": "Booker", - "enabled": 1, - "user_type": "Website User", - "send_welcome_email": 0, - } - ).insert(ignore_permissions=True) + booking = frappe.get_doc("Event Booking", payload.booking_name) + self.assertEqual({row.utm_name: row.value for row in booking.utm_parameters}, UTM_PARAMETERS) - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": "Confirmation Email Ticket", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() + def test_refuses_an_unpublished_event(self): + self.set_event({"is_published": 0}) - def tearDown(self): - frappe.delete_doc("Event Ticket Type", self.ticket_type.name, force=True) + with self.assertRaises(frappe.ValidationError) as raised: + process_booking(self.booking_request()) - def _make_booking(self, user): - return frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": user, - "attendees": [ - { - "ticket_type": self.ticket_type.name, - "first_name": "John", - "email": "john-56@example.com", - } - ], - } - ).insert() + self.assertIn("Event is not live", str(raised.exception)) - 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() - - @patch("frappe.sendmail") - def test_sends_confirmation_to_booker(self, mock_sendmail): - booking = self._make_booking(self.BOOKER_EMAIL) - booking.submit() - - mock_sendmail.assert_called_once() - self.assertIn(self.BOOKER_EMAIL, mock_sendmail.call_args[1]["recipients"]) - self.assertEqual(mock_sendmail.call_args[1]["reference_doctype"], "Event Booking") - self.assertEqual(mock_sendmail.call_args[1]["reference_name"], booking.name) - - @patch("frappe.sendmail") - def test_uses_inline_template_when_none_configured(self, mock_sendmail): - booking = self._make_booking(self.BOOKER_EMAIL) - booking.submit() - - mock_sendmail.assert_called_once() - self.assertEqual(mock_sendmail.call_args[1]["template"], "booking_confirmation") - - @patch("frappe.sendmail") - def test_skips_administrator(self, mock_sendmail): - booking = self._make_booking("Administrator") - booking.submit() - - mock_sendmail.assert_not_called() - - @patch("frappe.sendmail") - def test_skips_guest(self, mock_sendmail): - booking = self._make_booking("Guest") - booking.submit() - - mock_sendmail.assert_not_called() - - @patch("frappe.sendmail") - def test_respects_event_toggle_off(self, mock_sendmail): - self.test_event.send_booking_confirmation_email = 0 - self.test_event.save() - - booking = self._make_booking(self.BOOKER_EMAIL) - booking.submit() - - mock_sendmail.assert_not_called() - - @patch("frappe.sendmail") - def test_uses_event_template_when_set(self, mock_sendmail): - template = self._create_template("Booking Confirmation Event Template", "EVENT") - self.test_event.booking_confirmation_email_template = template.name - self.test_event.save() - - booking = self._make_booking(self.BOOKER_EMAIL) - booking.submit() - - mock_sendmail.assert_called_once() - self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"]) - - @patch("frappe.sendmail") - def test_falls_back_to_the_teams_default_template(self, mock_sendmail): - template = self._create_template("Booking Confirmation Team Template", "TEAM") - set_team_settings(self.test_event.team, default_booking_confirmation_email_template=template.name) - - booking = self._make_booking(self.BOOKER_EMAIL) - booking.submit() - - mock_sendmail.assert_called_once() - self.assertIn("TEAM", mock_sendmail.call_args[1]["subject"]) - - @patch("frappe.sendmail") - def test_support_email_comes_from_the_teams_settings(self, mock_sendmail): - set_team_settings(self.test_event.team, support_email="booking-support@example.com") - - booking = self._make_booking(self.BOOKER_EMAIL) - booking.submit() - - self.assertEqual(mock_sendmail.call_args[1]["args"]["support_email"], "booking-support@example.com") - - @patch("frappe.sendmail") - def test_event_template_takes_precedence_over_the_team_default(self, mock_sendmail): - event_template = self._create_template("Booking Event Precedence Template", "EVENT") - team_template = self._create_template("Booking Team Precedence Template", "TEAM") - self.test_event.booking_confirmation_email_template = event_template.name - self.test_event.save() - - set_team_settings( - self.test_event.team, default_booking_confirmation_email_template=team_template.name - ) - - booking = self._make_booking(self.BOOKER_EMAIL) - booking.submit() - - mock_sendmail.assert_called_once() - self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"]) - self.assertNotIn("TEAM", mock_sendmail.call_args[1]["subject"]) - - -class TestOfflineAcknowledgementEmail(IntegrationTestCase): - """Acknowledgement sent when an offline booking is created, before verification.""" - - BOOKER_EMAIL = "offline-booker@example.com" - - def setUp(self): - self.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.test_event.apply_tax = False - self.test_event.send_booking_confirmation_email = 1 - self.test_event.booking_confirmation_email_template = None - self.test_event.offline_acknowledgement_email_template = None - self.test_event.send_ticket_email = 0 - self.test_event.save() - - set_team_settings(self.test_event.team, default_booking_confirmation_email_template=None) - self.addCleanup(frappe.clear_document_cache, "Buzz Team Settings", self.test_event.team) - - if not frappe.db.exists("User", self.BOOKER_EMAIL): - frappe.get_doc( - { - "doctype": "User", - "email": self.BOOKER_EMAIL, - "first_name": "Offline", - "enabled": 1, - "user_type": "Website User", - "send_welcome_email": 0, - } - ).insert(ignore_permissions=True) - - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": "Offline Acknowledgement Ticket", - "prices": [{"currency": "INR", "price": 100}], - } - ).insert() - - def tearDown(self): - frappe.delete_doc("Event Ticket Type", self.ticket_type.name, force=True) - - def _make_offline_booking(self, user): - """A booking in the state offline_booking_response leaves behind: a draft - awaiting verification, with no tickets.""" - booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.test_event.name, - "user": user, - "payment_method": "Offline", - "offline_payment_method": "Bank Transfer", - "status": "Approval Pending", - "payment_status": "Verification Pending", - "attendees": [ - { - "ticket_type": self.ticket_type.name, - "first_name": "John", - "email": "john-offline@example.com", - } - ], - } - ).insert() - return booking - - 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() - - @patch("frappe.sendmail") - def test_sends_acknowledgement_to_booker(self, mock_sendmail): - booking = self._make_offline_booking(self.BOOKER_EMAIL) - booking.send_offline_acknowledgement_email() - - mock_sendmail.assert_called_once() - self.assertIn(self.BOOKER_EMAIL, mock_sendmail.call_args[1]["recipients"]) - self.assertEqual(mock_sendmail.call_args[1]["reference_doctype"], "Event Booking") - self.assertEqual(mock_sendmail.call_args[1]["reference_name"], booking.name) - - @patch("frappe.sendmail") - def test_uses_inline_template_when_none_configured(self, mock_sendmail): - self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() - - self.assertEqual(mock_sendmail.call_args[1]["template"], "offline_booking_acknowledgement") - - @patch("frappe.sendmail") - def test_carries_the_booking_summary(self, mock_sendmail): - booking = self._make_offline_booking(self.BOOKER_EMAIL) - booking.send_offline_acknowledgement_email() - - args = mock_sendmail.call_args[1]["args"] - self.assertEqual(args["doc"].name, booking.name) - self.assertEqual(args["event_title"], self.test_event.title) - self.assertEqual(len(args["attendee_rows"]), 1) - # Ticket types autoname to integers and arrive off the row as strings, so a - # title lookup keyed on the raw value silently prints the id instead. - self.assertEqual(args["attendee_rows"][0]["ticket_type_title"], self.ticket_type.title) - - def test_builtin_template_renders(self): - """The template is only exercised end-to-end here: every other test mocks the - send, so a broken Jinja tag would otherwise reach production silently.""" - booking = self._make_offline_booking(self.BOOKER_EMAIL) - - html = frappe.render_template( - "templates/emails/offline_booking_acknowledgement.html", booking.get_booking_email_args() - ) - - self.assertIn("Payment verification pending", html) - self.assertIn(booking.name, html) - self.assertIn(booking.offline_payment_method, html) - self.assertIn(self.ticket_type.title, html) - - @patch("frappe.sendmail") - def test_uses_event_template_when_set(self, mock_sendmail): - template = self._create_template("Offline Acknowledgement Event Template", "OFFLINE") - self.test_event.offline_acknowledgement_email_template = template.name - self.test_event.save() - - self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() - - mock_sendmail.assert_called_once() - self.assertIn("OFFLINE", mock_sendmail.call_args[1]["subject"]) - - @patch("frappe.sendmail") - def test_ignores_the_confirmation_template(self, mock_sendmail): - """The acknowledgement has its own template field; the confirmation's, event-level - or team-level, must not leak into it.""" - event_template = self._create_template("Offline Acknowledgement Confirmation Template", "EVENT") - team_template = self._create_template("Offline Acknowledgement Team Template", "TEAM") - self.test_event.booking_confirmation_email_template = event_template.name - self.test_event.save() - set_team_settings( - self.test_event.team, default_booking_confirmation_email_template=team_template.name - ) - - self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() - - self.assertEqual(mock_sendmail.call_args[1]["template"], "offline_booking_acknowledgement") - - @patch("frappe.sendmail") - def test_respects_event_toggle_off(self, mock_sendmail): - self.test_event.send_booking_confirmation_email = 0 - self.test_event.save() - - self._make_offline_booking(self.BOOKER_EMAIL).send_offline_acknowledgement_email() - - mock_sendmail.assert_not_called() - - @patch("frappe.sendmail") - def test_skips_system_users(self, mock_sendmail): - for user in ("Administrator", "Guest"): - with self.subTest(user=user): - self._make_offline_booking(user).send_offline_acknowledgement_email() - - mock_sendmail.assert_not_called() - - -class TestZoomBackedCategoryBooking(IntegrationTestCase): - """Zoom needs a last name on every registrant, for meetings as much as webinars.""" - - def setUp(self): - super().setUp() - 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": "Zoom Category Ticket", - "is_published": True, - } - ).insert() - - def tearDown(self): - frappe.db.rollback() - - def _book_without_last_name(self, category): - self.event.db_set("category", category) - return process_booking( - attendees=[ - { - "first_name": "Nolast", - "email": "nolast@example.com", - "ticket_type": str(self.ticket_type.name), - "add_ons": [], - } - ], - event=str(self.event.name), - ) +class TestZoomBackedCategoryBooking(BookingTestCase): def test_last_name_required_for_webinar_category(self): - self.assertRaises(frappe.ValidationError, self._book_without_last_name, "Webinars") + self.assertRaises(frappe.ValidationError, self.book_without_last_name, "Webinars") def test_last_name_required_for_zoom_meeting_category(self): - self.assertRaises(frappe.ValidationError, self._book_without_last_name, "Zoom Meeting") + self.assertRaises(frappe.ValidationError, self.book_without_last_name, "Zoom Meeting") def test_last_name_not_required_for_other_categories(self): - result = self._book_without_last_name("Conferences") + payload = self.book_without_last_name(self.event.category) + + self.assertTrue(frappe.db.exists("Event Booking", payload.booking_name)) - self.assertIn("booking_name", result) + def book_without_last_name(self, category: str): + self.set_event({"category": category}) + return process_booking(self.booking_request()) diff --git a/buzz/ticketing/doctype/event_booking/test_event_booking_refund.py b/buzz/ticketing/doctype/event_booking/test_event_booking_refund.py index f4216f0d..1a4590a6 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_booking_refund.py +++ b/buzz/ticketing/doctype/event_booking/test_event_booking_refund.py @@ -1,147 +1,45 @@ # Copyright (c) 2026, BWH Studios and Contributors # See license.txt -from unittest.mock import MagicMock, patch +from unittest.mock import patch import frappe -from frappe.tests import IntegrationTestCase -from frappe.utils import today -from pydantic import ValidationError -from buzz.api.checkin import validate_ticket_for_checkin +from buzz.api.checkin import checkin_ticket, validate_ticket_for_checkin from buzz.api.exceptions import Conflict -from buzz.payments import handle_refund_notification - -TICKET_PRICE = 500 -TAX_PERCENTAGE = 10 -CHARGED_PER_TICKET = 550.0 - - -def make_payment_gateway(gateway: str) -> None: - if not frappe.db.exists("Payment Gateway", gateway): - frappe.get_doc({"doctype": "Payment Gateway", "gateway": gateway}).insert() - - -class BookingRefundTestCase(IntegrationTestCase): - def setUp(self): - self.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.event.apply_tax = 1 - self.event.tax_inclusive = 0 - self.event.tax_percentage = TAX_PERCENTAGE - self.event.tax_label = "GST" - self.event.save() - - self.ticket_type = frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.event.name, - "title": "Refundable", - "prices": [{"currency": "INR", "price": TICKET_PRICE}], - "is_published": True, - } - ).insert() - - self.booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.event.name, - "user": "Administrator", - "attendees": [ - {"ticket_type": self.ticket_type.name, "first_name": "John", "email": "john@example.com"}, - { - "ticket_type": self.ticket_type.name, - "first_name": "Jenny", - "email": "jenny@example.com", - }, - ], - } - ).insert() - self.booking.payment_status = "Paid" - self.booking.submit() - - def refund_id(self, suffix: str = "1") -> str: - """Refund ids are unique in the database, so keep them unique per test too.""" - return f"rfnd_{self._testMethodName}_{suffix}" - - def refunds(self) -> list: - return [ - frappe.get_doc("Event Booking Refund", refund.name) - for refund in frappe.get_all( - "Event Booking Refund", filters={"booking": self.booking.name}, order_by="creation asc" - ) - ] - - def check_in(self, ticket: str) -> None: - frappe.get_doc({"doctype": "Event Check In", "ticket": ticket, "date": today()}).insert().submit() - - def make_payment(self, gateway: str = "Razorpay", payment_id: str = "pay_123") -> None: - make_payment_gateway(gateway) - frappe.get_doc( - { - "doctype": "Event Payment", - "user": "Administrator", - "amount": self.booking.total_amount, - "currency": self.booking.currency, - "reference_doctype": "Event Booking", - "reference_docname": self.booking.name, - "payment_gateway": gateway, - "payment_received": 1, - "payment_id": payment_id, - } - ).insert() +from buzz.tests.base_test_cases import CHARGED_PER_TICKET, BookingRefundTestCase +from buzz.tests.factories import BuzzEventFactory, PaymentGatewayFactory class TestRefundSummary(BookingRefundTestCase): def test_each_ticket_carries_its_share_of_the_charged_total(self): - # 500 + 500 booked, 10% tax on top, so each ticket cost the buyer 550. summary = self.booking.get_refund_summary() - self.assertEqual(len(summary["tickets"]), 2) self.assertEqual([ticket["amount"] for ticket in summary["tickets"]], [550.0, 550.0]) def test_tickets_are_named_and_linked_to_their_attendee(self): summary = self.booking.get_refund_summary() self.assertTrue(all(ticket["ticket"] for ticket in summary["tickets"])) - self.assertEqual( - sorted(ticket["attendee"] for ticket in summary["tickets"]), - ["Jenny", "John"], - ) + self.assertEqual(sorted(ticket["attendee"] for ticket in summary["tickets"]), ["Jenny", "John"]) def test_the_whole_total_is_refundable_before_any_refund(self): summary = self.booking.get_refund_summary() self.assertEqual(summary["committed"], 0) - self.assertEqual(summary["remaining"], self.booking.total_amount) + self.assertEqual(summary["remaining"], 1100) def test_a_checked_in_ticket_is_not_offered(self): - # The attendee has been through the door, so the ticket has been used. - used = self.booking.get_refund_summary()["tickets"][0]["ticket"] - self.check_in(used) - - summary = self.booking.get_refund_summary() + checked_in_ticket = self.refundable_tickets()[0] + checkin_ticket(checked_in_ticket) - self.assertNotIn(used, [ticket["ticket"] for ticket in summary["tickets"]]) - self.assertEqual(len(summary["tickets"]), 1) + self.assertEqual(len(self.refundable_tickets()), 1) + self.assertNotIn(checked_in_ticket, self.refundable_tickets()) class TestBookingRefund(BookingRefundTestCase): - def refund(self, **kwargs): - client = MagicMock() - client.refund_payment.return_value = { - "id": kwargs.pop("refund_id", self.refund_id()), - "status": "pending", - "amount": int(kwargs.get("amount", CHARGED_PER_TICKET) * 100), - } - - with patch("buzz.ticketing.doctype.event_booking.event_booking.get_controller", return_value=client): - result = self.booking.refund(**kwargs) - - self.booking.reload() - return result, client - def test_refund_is_refused_when_the_gateway_is_not_razorpay(self): - self.make_payment(gateway="PayPal") + self.make_payment(gateway=PaymentGatewayFactory.create().name) with self.assertRaises(frappe.ValidationError) as raised: self.booking.refund(amount=100) @@ -153,619 +51,141 @@ def test_refund_is_refused_without_a_received_payment(self): def test_refunding_tickets_records_the_refund_and_marks_it_initiated(self): self.make_payment() - tickets = self.booking.get_refund_summary()["tickets"] - _, client = self.refund(amount=CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) + client = self.initiate_refund(CHARGED_PER_TICKET, tickets=self.refundable_tickets()[:1]) - client.refund_payment.assert_called_once_with("pay_123", CHARGED_PER_TICKET) + client.refund_payment.assert_called_once_with(self.payment.payment_id, CHARGED_PER_TICKET) self.assertEqual(self.booking.refund_status, "Refund Initiated") - self.assertEqual(len(self.refunds()), 1) - self.assertEqual(self.refunds()[0].refund_id, self.refund_id()) - self.assertEqual(self.refunds()[0].status, "Initiated") - self.assertEqual(self.refunds()[0].amount, CHARGED_PER_TICKET) + refund = self.refunds()[0] + self.assertEqual( + (refund.refund_id, refund.status, refund.amount), (self.refund_id(), "Initiated", 550) + ) def test_an_unsettled_refund_cancels_nothing_yet(self): - # Razorpay can take days. Nothing may cancel tickets before it answers, - # or a refund that fails leaves the customer with neither money nor seat. + # A refund can still fail, so tickets are only cancelled once it settles. self.make_payment() - tickets = self.booking.get_refund_summary()["tickets"] + ticket = self.refundable_tickets()[0] - self.refund(amount=CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) + self.initiate_refund(CHARGED_PER_TICKET, tickets=[ticket]) self.assertIsNone(self.refunds()[0].cancellation_request) - self.assertFalse(frappe.db.exists("Ticket Cancellation Request", {"booking": self.booking.name})) - self.assertEqual([row.ticket for row in self.refunds()[0].tickets], [tickets[0]["ticket"]]) + self.assertEqual(self.cancellation_requests(), []) + self.assertEqual([row.ticket for row in self.refunds()[0].tickets], [ticket]) def test_a_ticket_from_another_booking_is_refused(self): self.make_payment() - stranger = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.event.name, - "user": "Administrator", - "attendees": [ - { - "ticket_type": self.ticket_type.name, - "first_name": "Stranger", - "email": "stranger@example.com", - } - ], - } - ).insert() - stranger.payment_status = "Paid" - stranger.submit() - foreign_ticket = frappe.db.get_value("Event Ticket", {"booking": stranger.name}, "name") - - # Patch the gateway so nothing but the ownership check can refuse this. + other_booking = self.create_paid_booking(("Stranger",), self.ticket_types[:1]) + foreign_ticket = frappe.db.get_value("Event Ticket", {"booking": other_booking.name}, "name") + + # The gateway is patched, so only the ownership check can refuse this. with self.assertRaises(frappe.ValidationError): - self.refund(amount=CHARGED_PER_TICKET, tickets=[foreign_ticket]) + self.initiate_refund(CHARGED_PER_TICKET, tickets=[foreign_ticket]) - self.assertFalse(frappe.db.exists("Ticket Cancellation Request", {"booking": self.booking.name})) + self.assertEqual(self.cancellation_requests(), []) self.assertEqual(frappe.db.get_value("Event Ticket", foreign_ticket, "docstatus"), 1) def test_a_ticket_that_has_been_checked_in_is_refused(self): self.make_payment() - tickets = self.booking.get_refund_summary()["tickets"] - self.check_in(tickets[0]["ticket"]) + ticket = self.refundable_tickets()[0] + checkin_ticket(ticket) with self.assertRaises(frappe.ValidationError) as raised: - self.refund(amount=CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) + self.initiate_refund(CHARGED_PER_TICKET, tickets=[ticket]) self.assertIn("checked in", str(raised.exception)) - self.assertFalse(self.refunds()) + self.assertEqual(self.refunds(), []) def test_a_ticket_a_refund_holds_cannot_be_checked_in(self): - # The refund has not settled, so the ticket is still live, but the money - # is already on its way back and the door must not let it through. self.make_payment() - tickets = self.booking.get_refund_summary()["tickets"] - - self.refund(amount=CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) + ticket = self.refundable_tickets()[0] + self.initiate_refund(CHARGED_PER_TICKET, tickets=[ticket]) with self.assertRaises(Conflict): - validate_ticket_for_checkin(tickets[0]["ticket"]) + validate_ticket_for_checkin(str(ticket)) self.assertEqual(frappe.local.message_log[-1]["title"], "Ticket Refunded") def test_a_custom_amount_refund_cancels_no_tickets(self): self.make_payment() - self.refund(amount=100) + self.initiate_refund(100) self.assertIsNone(self.refunds()[0].cancellation_request) - self.assertFalse(frappe.db.exists("Ticket Cancellation Request", {"booking": self.booking.name})) - - -class TestRefundNotification(BookingRefundTestCase): - def setUp(self): - super().setUp() - self.make_payment() - - def make_log(self, payload: dict): - return frappe.get_doc( - { - "doctype": "Integration Request", - "integration_request_service": "Razorpay", - "request_description": "Refund Notification", - "is_remote_request": 1, - "status": "Queued", - "data": frappe.as_json(payload), - } - ).insert(ignore_permissions=True) - - def notify(self, refund_id: str, status: str, amount: float) -> None: - log = self.make_log( - { - "event": f"refund.{status}", - "payload": { - "refund": { - "entity": { - "id": refund_id, - "status": status, - "amount": int(amount * 100), - "payment_id": "pay_123", - } - } - }, - } - ) - - # Cancelling a ticket emails the attendee, and the test site has no - # outgoing account. Delivery is not what these tests are about. - with patch("frappe.sendmail"): - handle_refund_notification("Integration Request", log.name) - - self.booking.reload() - return log - - def initiate(self, refund_id: str, amount: float, tickets: list[str] | None = None) -> None: - client = MagicMock() - client.refund_payment.return_value = { - "id": refund_id, - "status": "pending", - "amount": int(amount * 100), - } - - with patch("buzz.ticketing.doctype.event_booking.event_booking.get_controller", return_value=client): - self.booking.refund(amount=amount, tickets=tickets) - - self.booking.reload() - - def test_a_processed_refund_covering_the_total_marks_the_booking_refunded(self): - self.initiate(self.refund_id("1"), self.booking.total_amount) - - self.notify(self.refund_id("1"), "processed", self.booking.total_amount) - - self.assertEqual(self.booking.refund_status, "Refunded") - self.assertEqual(self.booking.refunded_amount, self.booking.total_amount) - self.assertEqual(self.refunds()[0].status, "Processed") - - def test_a_processed_refund_below_the_total_marks_the_booking_partially_refunded(self): - self.initiate(self.refund_id("1"), CHARGED_PER_TICKET) - - self.notify(self.refund_id("1"), "processed", CHARGED_PER_TICKET) - - self.assertEqual(self.booking.refund_status, "Partially Refunded") - self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) - - def test_a_full_refund_buzz_never_raised_cancels_every_remaining_ticket(self): - # Raised on the Razorpay dashboard: the webhook is the first Buzz hears of - # it, and it carries no tickets. - self.notify(self.refund_id("1"), "processed", self.booking.total_amount) - - self.assertEqual(self.booking.refund_status, "Refunded") - self.assertEqual(len(self.refunds()[0].tickets), 2) - self.assertEqual( - frappe.db.get_value( - "Ticket Cancellation Request", self.refunds()[0].cancellation_request, "docstatus" - ), - 1, - ) - - def test_a_partial_refund_buzz_never_raised_cancels_nothing(self): - self.notify(self.refund_id("1"), "processed", CHARGED_PER_TICKET) - - self.assertEqual(self.refunds()[0].tickets, []) - self.assertFalse(frappe.db.exists("Ticket Cancellation Request", {"booking": self.booking.name})) - - def test_the_same_event_arriving_twice_is_not_counted_twice(self): - self.initiate(self.refund_id("1"), CHARGED_PER_TICKET) - - self.notify(self.refund_id("1"), "processed", CHARGED_PER_TICKET) - self.notify(self.refund_id("1"), "processed", CHARGED_PER_TICKET) - - self.assertEqual(len(self.refunds()), 1) - self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) - - def test_a_failed_refund_is_not_counted_and_cancels_nothing(self): - tickets = self.booking.get_refund_summary()["tickets"] - self.initiate(self.refund_id("1"), CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) - - self.notify(self.refund_id("1"), "failed", CHARGED_PER_TICKET) - - self.assertEqual(self.refunds()[0].status, "Failed") - self.assertEqual(self.booking.refunded_amount, 0) - self.assertFalse(frappe.db.exists("Ticket Cancellation Request", {"booking": self.booking.name})) - self.assertEqual(frappe.db.get_value("Event Ticket", tickets[0]["ticket"], "docstatus"), 1) - - def test_a_webhook_processed_as_guest_still_updates_the_booking(self): - # The refund webhook is an allow_guest endpoint, so the job it enqueues - # runs as Guest — who has no write permission on Event Booking. - tickets = self.booking.get_refund_summary()["tickets"] - self.initiate(self.refund_id("1"), CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) - frappe.set_user("Guest") - self.addCleanup(frappe.set_user, "Administrator") - - self.notify(self.refund_id("1"), "processed", CHARGED_PER_TICKET) - - self.assertEqual(self.refunds()[0].status, "Processed") - self.assertEqual(self.booking.refund_status, "Partially Refunded") - self.assertEqual( - frappe.db.get_value( - "Ticket Cancellation Request", self.refunds()[0].cancellation_request, "docstatus" - ), - 1, - ) - - def test_a_processed_refund_raises_and_submits_the_cancellation(self): - tickets = self.booking.get_refund_summary()["tickets"] - self.initiate(self.refund_id("1"), CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) - - self.notify(self.refund_id("1"), "processed", CHARGED_PER_TICKET) - - request = frappe.get_doc("Ticket Cancellation Request", self.refunds()[0].cancellation_request) - self.assertEqual(request.status, "Accepted") - self.assertEqual(request.docstatus, 1) - self.assertEqual(frappe.db.get_value("Event Ticket", tickets[0]["ticket"], "docstatus"), 2) - - def test_a_cancellation_that_cannot_go_through_leaves_the_refund_recorded(self): - tickets = self.booking.get_refund_summary()["tickets"] - self.initiate(self.refund_id("1"), CHARGED_PER_TICKET, tickets=[tickets[0]["ticket"]]) - refund = self.refunds()[0] - - with patch("frappe.sendmail", side_effect=Exception("no outgoing email account")): - refund.apply_gateway_status("processed", CHARGED_PER_TICKET) - - self.booking.reload() - self.assertEqual(self.refunds()[0].status, "Processed") - self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) - - request = frappe.get_doc("Ticket Cancellation Request", self.refunds()[0].cancellation_request) - self.assertEqual(request.docstatus, 0) - self.assertEqual(request.status, "In Review") - self.assertEqual(frappe.db.get_value("Event Ticket", tickets[0]["ticket"], "docstatus"), 1) - - def test_the_integration_request_is_linked_back_to_the_booking(self): - self.initiate(self.refund_id("1"), CHARGED_PER_TICKET) - - log = self.notify(self.refund_id("1"), "processed", CHARGED_PER_TICKET) - - self.assertEqual( - frappe.db.get_value("Integration Request", log.name, "reference_docname"), self.booking.name - ) - - def test_a_refund_for_an_unknown_payment_is_ignored(self): - log = self.make_log( - { - "event": "refund.processed", - "payload": { - "refund": { - "entity": { - "id": self.refund_id("x"), - "payment_id": "pay_nope", - "status": "processed", - "amount": 55000, - } - } - }, - } - ) - - self.assertIsNone(handle_refund_notification("Integration Request", log.name)) - self.booking.reload() - self.assertFalse(self.refunds()) - - def test_a_payload_without_a_refund_is_refused(self): - # Only refund events reach this handler, so one carrying no refund is a - # defect for the caller to log, not an event to pass over quietly. - log = self.make_log({"event": "refund.processed", "payload": {}}) + self.assertEqual(self.cancellation_requests(), []) - self.assertRaises(ValidationError, handle_refund_notification, "Integration Request", log.name) +class TestRefundCeiling(BookingRefundTestCase): + ticket_prices = (2000, 3000) + attendee_names = ("Cheap", "Pricey") -class TestRefundCeiling(IntegrationTestCase): - """A booking of 2000 + 3000 must never give back more than 5000.""" + @classmethod + def create_event(cls) -> str: + return BuzzEventFactory.create().name def setUp(self): - self.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.event.apply_tax = 0 - self.event.save() - - self.ticket_types = [ - frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.event.name, - "title": f"Tier {price}", - "prices": [{"currency": "INR", "price": price}], - "is_published": True, - } - ).insert() - for price in (2000, 3000) - ] - - self.booking = frappe.get_doc( - { - "doctype": "Event Booking", - "event": self.event.name, - "user": "Administrator", - "attendees": [ - { - "ticket_type": self.ticket_types[0].name, - "first_name": "Cheap", - "email": "cheap@example.com", - }, - { - "ticket_type": self.ticket_types[1].name, - "first_name": "Pricey", - "email": "pricey@example.com", - }, - ], - } - ).insert() - self.booking.payment_status = "Paid" - self.booking.submit() - - make_payment_gateway("Razorpay") - frappe.get_doc( - { - "doctype": "Event Payment", - "user": "Administrator", - "amount": self.booking.total_amount, - "currency": self.booking.currency, - "reference_doctype": "Event Booking", - "reference_docname": self.booking.name, - "payment_gateway": "Razorpay", - "payment_received": 1, - "payment_id": "pay_123", - } - ).insert() - - def refund_id(self, suffix: str = "1") -> str: - """Refund ids are unique in the database, so keep them unique per test too.""" - return f"rfnd_{self._testMethodName}_{suffix}" - - def refund(self, amount: float, refund_id: str | None = None, tickets: list[str] | None = None): - refund_id = refund_id or self.refund_id() - client = MagicMock() - client.refund_payment.return_value = { - "id": refund_id, - "status": "pending", - "amount": int(amount * 100), - } - - with patch("buzz.ticketing.doctype.event_booking.event_booking.get_controller", return_value=client): - return self.booking.refund(amount=amount, tickets=tickets) - - def settle(self, refund_id: str, amount: float) -> None: - refund = frappe.get_doc("Event Booking Refund", {"refund_id": refund_id}) - with patch("frappe.sendmail"): - refund.apply_gateway_status("processed", amount) - - self.booking.reload() + super().setUp() + self.make_payment() def test_the_booking_total_is_the_ceiling(self): self.assertEqual(self.booking.total_amount, 5000) def test_a_second_refund_cannot_exceed_what_is_left_after_a_settled_one(self): - self.refund(3000, refund_id=self.refund_id("1")) - self.settle(self.refund_id("1"), 3000) + self.initiate_refund(3000) + self.settle_refund(self.refund_id(), 3000) with self.assertRaises(frappe.ValidationError) as raised: - self.refund(3000, refund_id=self.refund_id("2")) + self.initiate_refund(3000, refund_id=self.refund_id("2")) self.assertIn("2,000", str(raised.exception)) def test_a_second_refund_cannot_exceed_what_is_left_while_the_first_is_still_initiated(self): - # The gateway has not settled the first refund yet, so it cannot be relied - # on to reject the second one either. - self.refund(3000, refund_id=self.refund_id("1")) + self.initiate_refund(3000) - self.assertRaises(frappe.ValidationError, self.refund, 3000, self.refund_id("2")) + with self.assertRaises(frappe.ValidationError): + self.initiate_refund(3000, refund_id=self.refund_id("2")) def test_a_failed_refund_frees_its_amount_again(self): - self.refund(3000, refund_id=self.refund_id("1")) - frappe.get_doc("Event Booking Refund", {"refund_id": self.refund_id("1")}).apply_gateway_status( - "failed", 3000 - ) - self.booking.reload() + self.initiate_refund(3000) + self.fail_refund(self.refund_id(), 3000) - self.refund(3000, refund_id=self.refund_id("2")) + self.initiate_refund(3000, refund_id=self.refund_id("2")) - self.assertEqual(len(frappe.get_all("Event Booking Refund", {"booking": self.booking.name})), 2) + self.assertEqual(len(self.refunds()), 2) def test_refunding_exactly_what_is_left_is_allowed(self): - self.refund(3000, refund_id=self.refund_id("1")) - self.settle(self.refund_id("1"), 3000) + self.initiate_refund(3000) + self.settle_refund(self.refund_id(), 3000) - self.refund(2000, refund_id=self.refund_id("2")) - self.settle(self.refund_id("2"), 2000) + self.initiate_refund(2000, refund_id=self.refund_id("2")) + self.settle_refund(self.refund_id("2"), 2000) self.assertEqual(self.booking.refund_status, "Refunded") self.assertEqual(self.booking.refunded_amount, 5000) def test_a_ticket_already_refunded_is_not_offered_again(self): - tickets = self.booking.get_refund_summary()["tickets"] - self.refund(3000, refund_id=self.refund_id("1"), tickets=[tickets[1]["ticket"]]) - self.settle(self.refund_id("1"), 3000) + low_price_ticket, high_price_ticket = self.refundable_tickets() + self.initiate_refund(3000, tickets=[high_price_ticket]) + self.settle_refund(self.refund_id(), 3000) - remaining = self.booking.get_refund_summary() - - self.assertEqual([ticket["ticket"] for ticket in remaining["tickets"]], [tickets[0]["ticket"]]) - self.assertEqual(remaining["remaining"], 2000) + self.assertEqual(self.refundable_tickets(), [low_price_ticket]) + self.assertEqual(self.booking.get_refund_summary()["remaining"], 2000) def test_a_ticket_whose_refund_failed_is_offered_again(self): - tickets = self.booking.get_refund_summary()["tickets"] - self.refund(3000, refund_id=self.refund_id("1"), tickets=[tickets[1]["ticket"]]) - - frappe.get_doc("Event Booking Refund", {"refund_id": self.refund_id("1")}).apply_gateway_status( - "failed", 3000 - ) - self.booking.reload() - - self.assertEqual(len(self.booking.get_refund_summary()["tickets"]), 2) + high_price_ticket = self.refundable_tickets()[1] + self.initiate_refund(3000, tickets=[high_price_ticket]) + self.fail_refund(self.refund_id(), 3000) -class TestSyncRefunds(BookingRefundTestCase): - def setUp(self): - super().setUp() - self.make_payment() - - def gateway_refund(self, refund_id: str, status: str, amount: float) -> dict: - return {"id": refund_id, "status": status, "amount": int(amount * 100)} - - def sync(self, *gateway_refunds: dict) -> dict: - client = MagicMock() - client.fetch_refunds.return_value = list(gateway_refunds) - - with ( - patch("buzz.ticketing.doctype.event_booking.event_booking.get_controller", return_value=client), - patch("frappe.sendmail"), - ): - result = self.booking.sync_refunds() - - self.booking.reload() - return result - - def initiate(self, refund_id: str, amount: float, tickets: list[str] | None = None) -> None: - client = MagicMock() - client.refund_payment.return_value = { - "id": refund_id, - "status": "pending", - "amount": int(amount * 100), - } - - with patch("buzz.ticketing.doctype.event_booking.event_booking.get_controller", return_value=client): - self.booking.refund(amount=amount, tickets=tickets) - - self.booking.reload() - - def cancellation_requests(self) -> list: - return frappe.get_all( - "Ticket Cancellation Request", - filters={"booking": self.booking.name}, - fields=["name", "docstatus"], - ) - - def test_a_refund_raised_outside_buzz_is_recorded(self): - result = self.sync(self.gateway_refund(self.refund_id(), "processed", CHARGED_PER_TICKET)) - - self.assertEqual(result, {"refunds": 1, "created": 1}) - self.assertEqual(len(self.refunds()), 1) - self.assertEqual(self.refunds()[0].status, "Processed") - self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) - self.assertEqual(self.booking.refund_status, "Partially Refunded") - - def test_a_payment_with_no_refunds_records_nothing(self): - result = self.sync() - - self.assertEqual(result, {"refunds": 0, "created": 0}) - self.assertEqual(self.refunds(), []) - self.assertEqual(self.booking.refund_status, "") + self.assertEqual(len(self.refundable_tickets()), 2) - def test_a_full_refund_raised_outside_buzz_cancels_every_remaining_ticket(self): - self.sync(self.gateway_refund(self.refund_id(), "processed", self.booking.total_amount)) - - self.assertEqual(self.booking.refund_status, "Refunded") - self.assertEqual(len(self.refunds()[0].tickets), 2) - self.assertEqual([request.docstatus for request in self.cancellation_requests()], [1]) - - def test_a_partial_refund_raised_outside_buzz_cancels_nothing(self): - self.sync(self.gateway_refund(self.refund_id(), "processed", CHARGED_PER_TICKET)) - - self.assertEqual(self.refunds()[0].tickets, []) - self.assertEqual(self.cancellation_requests(), []) - - def test_a_refund_the_gateway_still_calls_pending_is_initiated(self): - self.sync(self.gateway_refund(self.refund_id(), "pending", self.booking.total_amount)) - - self.assertEqual(self.refunds()[0].status, "Initiated") - self.assertEqual(self.booking.refund_status, "Refund Initiated") - self.assertEqual(self.booking.refunded_amount, 0) - self.assertEqual(self.cancellation_requests(), []) - - def test_a_refund_buzz_already_holds_is_updated_not_duplicated(self): - ticket = self.booking.get_refund_summary()["tickets"][0]["ticket"] - self.initiate(self.refund_id(), CHARGED_PER_TICKET, tickets=[ticket]) - - result = self.sync(self.gateway_refund(self.refund_id(), "processed", CHARGED_PER_TICKET)) - - self.assertEqual(result, {"refunds": 1, "created": 0}) - self.assertEqual(len(self.refunds()), 1) - self.assertEqual(self.refunds()[0].status, "Processed") - self.assertEqual([row.ticket for row in self.refunds()[0].tickets], [ticket]) - self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) - - def test_a_settled_refund_is_not_knocked_back_by_a_stale_pending(self): - self.sync(self.gateway_refund(self.refund_id(), "processed", self.booking.total_amount)) - - self.sync(self.gateway_refund(self.refund_id(), "pending", self.booking.total_amount)) - - self.assertEqual(self.refunds()[0].status, "Processed") - self.assertEqual(self.booking.refund_status, "Refunded") - self.assertEqual(self.booking.refunded_amount, self.booking.total_amount) - self.assertEqual(len(self.cancellation_requests()), 1) - - def test_a_failed_refund_is_not_revived_by_a_later_sighting(self): - self.sync(self.gateway_refund(self.refund_id(), "failed", CHARGED_PER_TICKET)) - - self.sync(self.gateway_refund(self.refund_id(), "processed", CHARGED_PER_TICKET)) - - self.assertEqual(self.refunds()[0].status, "Failed") - self.assertEqual(self.booking.refunded_amount, 0) - - def test_a_refund_the_webhook_recorded_is_not_duplicated_by_a_sync(self): - log = frappe.get_doc( - { - "doctype": "Integration Request", - "integration_request_service": "Razorpay", - "request_description": "Refund Notification", - "is_remote_request": 1, - "status": "Queued", - "data": frappe.as_json( - { - "payload": { - "refund": { - "entity": { - "id": self.refund_id(), - "status": "processed", - "amount": int(CHARGED_PER_TICKET * 100), - "payment_id": "pay_123", - } - } - } - } - ), - } - ).insert(ignore_permissions=True) + def settle_refund(self, refund_id: str, amount: float) -> None: with patch("frappe.sendmail"): - handle_refund_notification("Integration Request", log.name) - - result = self.sync(self.gateway_refund(self.refund_id(), "processed", CHARGED_PER_TICKET)) - - self.assertEqual(result, {"refunds": 1, "created": 0}) - self.assertEqual(len(self.refunds()), 1) - self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + self.apply_gateway_status(refund_id, "processed", amount) - def test_syncing_twice_does_not_count_the_same_refund_twice(self): - refund = self.gateway_refund(self.refund_id(), "processed", CHARGED_PER_TICKET) + def fail_refund(self, refund_id: str, amount: float) -> None: + self.apply_gateway_status(refund_id, "failed", amount) - self.sync(refund) - self.sync(refund) - - self.assertEqual(len(self.refunds()), 1) - self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) - - def test_a_checked_in_ticket_is_never_cancelled_by_a_synced_refund(self): - tickets = self.booking.get_refund_summary()["tickets"] - self.check_in(tickets[0]["ticket"]) - - self.sync(self.gateway_refund(self.refund_id(), "processed", self.booking.total_amount)) - - self.assertEqual( - [row.ticket for row in self.refunds()[0].tickets], - [tickets[1]["ticket"]], - ) - - def test_two_refunds_in_one_batch_cancel_the_tickets_once_they_cover_the_total(self): - # Neither half covers the booking on its own; the second one, seeing the - # first already recorded, is what pays back the last of it. - self.sync( - self.gateway_refund(self.refund_id("1"), "processed", CHARGED_PER_TICKET), - self.gateway_refund(self.refund_id("2"), "processed", CHARGED_PER_TICKET), - ) - - refunds = self.refunds() - self.assertEqual(len(refunds), 2) - self.assertEqual(refunds[0].tickets, []) - self.assertEqual(len(refunds[1].tickets), 2) - self.assertEqual(self.booking.refund_status, "Refunded") - self.assertEqual([request.docstatus for request in self.cancellation_requests()], [1]) - - def test_sync_is_refused_when_the_gateway_is_not_razorpay(self): - frappe.db.set_value( - "Event Payment", - {"reference_docname": self.booking.name}, - "payment_gateway", - "PayPal", - ) - - with self.assertRaises(frappe.ValidationError) as raised: - self.booking.sync_refunds() - - self.assertIn("Razorpay", str(raised.exception)) - - def test_sync_is_refused_without_a_received_payment(self): - frappe.db.set_value("Event Payment", {"reference_docname": self.booking.name}, "payment_received", 0) - - self.assertRaises(frappe.ValidationError, self.booking.sync_refunds) + def apply_gateway_status(self, refund_id: str, status: str, amount: float) -> None: + refund = frappe.get_doc("Event Booking Refund", {"refund_id": refund_id}) + refund.apply_gateway_status(status, amount) + self.booking.reload() diff --git a/buzz/ticketing/doctype/event_booking/test_event_eligibility.py b/buzz/ticketing/doctype/event_booking/test_event_eligibility.py index 9b059324..6e585454 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_eligibility.py +++ b/buzz/ticketing/doctype/event_booking/test_event_eligibility.py @@ -1,91 +1,48 @@ # Copyright (c) 2026, BWH Studios and contributors # For license information, please see license.txt -"""Who may create an Event Booking, and for which events. - -Eligibility (event published, registrations open) used to live only in -`BookingService`, so the generic document API reached `Document.insert()` -without it. These pin both halves of the fix: ordinary users cannot write -bookings at all, and the ones who can are held to their own team's events. -""" +"""The generic document API must not skip the eligibility checks `BookingService` runs: +ordinary users cannot write bookings, and Event Managers only book their own team's events.""" import frappe -from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, now_datetime, today from buzz.api.booking import process_booking from buzz.api.booking.exceptions import RegistrationsClosed from buzz.api.booking.schemas import BookingRequest -from buzz.api.forms.test_forms import ensure_event_host, ensure_prompt_named_record -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user -from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership +from buzz.tests.base_test_cases import BookingTestCase +from buzz.tests.factories import ( + BuzzEventFactory, + BuzzTeamFactory, + BuzzTeamMembershipFactory, + EventBookingFactory, + EventTicketTypeFactory, + UserFactory, +) ATTENDEE = "eligibility-attendee@example.com" ORGANISER = "eligibility-organiser@example.com" OUTSIDER = "eligibility-outsider@example.com" -class EligibilityTestCase(IntegrationTestCase): +class EligibilityTestCase(BookingTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.category = ensure_prompt_named_record("Event Category", "Eligibility Category") - cls.host = ensure_event_host("Eligibility Host") - owner = create_user("eligibility-team-owner@example.com", "Eligibility") - cls.team = create_owned_team(f"Eligibility Team {frappe.generate_hash(length=6)}", owner) - cls.event = cls.make_event() - cls.event.reload() - - @classmethod - def make_event(cls, **overrides): - values = { - "doctype": "Buzz Event", - "title": f"Eligibility Event {frappe.generate_hash(length=6)}", - "team": cls.team, - "start_date": "2030-01-01", - "end_date": "2030-01-01", - "start_time": "10:00:00", - "end_time": "18:00:00", - "medium": "Online", - "category": cls.category, - "host": cls.host, - "is_published": 1, - } - values.update(overrides) - return frappe.get_doc(values).insert(ignore_permissions=True) + cls.attendee = UserFactory.create_once(ATTENDEE).name + cls.organiser = UserFactory.create_once(ORGANISER).name + BuzzTeamMembershipFactory.create(team=cls.event.team, user=cls.organiser) + # An Event Manager by role, but on a team that does not own `cls.event`. + cls.outsider = UserFactory.create_once(OUTSIDER).name + BuzzTeamFactory.create_owned_by(cls.outsider) def setUp(self): - frappe.set_user("Administrator") - frappe.clear_messages() - self.addCleanup(frappe.set_user, "Administrator") - self.addCleanup(frappe.clear_document_cache, "Buzz Event", self.event.name) - - self.attendee = create_user(ATTENDEE, "Attendee") - self.organiser = create_user(ORGANISER, "Organiser") - upsert_membership(self.team, self.organiser, "Manager") - self.set_event({"is_published": 1, "registrations_close_at": None, "allow_guest_booking": 0}) - - self.paid_ticket_type = self.make_ticket_type(price=5000) - - def make_ticket_type(self, event=None, price=0): - return frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": event or self.event.name, - "title": f"Eligibility {frappe.generate_hash(length=6)}", - "prices": [{"currency": "INR", "price": price}], - "is_published": 1, - } - ).insert(ignore_permissions=True) - - def outsider_managing_another_team(self) -> str: - """An Event Manager by role, but on a team that does not own `self.event`.""" - outsider = create_user(OUTSIDER, "Outsider") - other_team = create_owned_team(f"Eligibility Other {frappe.generate_hash(length=6)}", outsider) - upsert_membership(other_team, outsider, "Manager") - return outsider - - def set_event(self, values): - frappe.db.set_value("Buzz Event", self.event.name, values) - frappe.clear_document_cache("Buzz Event", self.event.name) + super().setUp() + self.paid_ticket_type = EventTicketTypeFactory.create( + event=self.event.name, prices=[{"currency": "INR", "price": 5000}] + ) + + def close_registrations(self): + self.set_event({"registrations_close_at": add_days(now_datetime(), -1)}) def attendee_row(self, **overrides) -> dict: row = { @@ -103,27 +60,14 @@ def booking_request(self, **overrides) -> BookingRequest: return BookingRequest(**values) def insert_as(self, session_user: str, **overrides): - """Insert under the caller's own permissions, like the generic document API — - no `ignore_permissions`, so role permissions and `validate` decide.""" - payload = { - "doctype": "Event Booking", - "event": str(self.event.name), - "user": session_user, - "currency": "INR", - "attendees": [self.attendee_row()], - } - payload.update(overrides) - - frappe.set_user(session_user) - try: - return frappe.get_doc(payload).insert() - finally: - frappe.set_user("Administrator") + """No `ignore_permissions`, like the generic document API.""" + values = {"user": session_user, "currency": "INR", "attendees": [self.attendee_row()], **overrides} + with self.set_user(session_user): + return EventBookingFactory.create(event=str(self.event.name), **values) class TestOrdinaryUsersCannotWriteBookings(EligibilityTestCase): - """Bookings are only ever written by the service flow, which runs with - `ignore_permissions`. Nothing user-facing needs create or write.""" + """Only the service flow writes bookings, and it runs with `ignore_permissions`.""" def test_every_new_user_is_granted_the_buzz_user_role(self): self.assertIn("Buzz User", frappe.get_roles(self.attendee)) @@ -146,38 +90,34 @@ def test_a_direct_insert_by_a_buzz_user_is_refused(self): class TestEventManagersAreHeldToTheirOwnTeam(EligibilityTestCase): - """`Event Manager` is granted by membership of any team, so the role alone must - not open another team's events.""" + """Membership of any team grants `Event Manager`, so the role alone must not open other teams' events.""" def test_an_outsider_may_not_book_an_unpublished_event(self): - outsider = self.outsider_managing_another_team() self.set_event({"is_published": 0}) with self.assertRaises(frappe.ValidationError): - self.insert_as(outsider, user=outsider) + self.insert_as(self.outsider) - self.assertIn("Event Manager", frappe.get_roles(outsider)) + self.assertIn("Event Manager", frappe.get_roles(self.outsider)) def test_an_outsider_may_not_book_after_registrations_close(self): - outsider = self.outsider_managing_another_team() - self.set_event({"registrations_close_at": "2020-01-01 00:00:00"}) + self.close_registrations() with self.assertRaises(RegistrationsClosed): - self.insert_as(outsider, user=outsider) + self.insert_as(self.outsider) def test_an_outsider_may_not_book_an_event_that_already_ended(self): # No explicit cutoff, so `are_registrations_closed` falls back to the end datetime. - outsider = self.outsider_managing_another_team() - self.set_event({"start_date": "2020-01-01", "end_date": "2020-01-01"}) - self.addCleanup(self.set_event, {"start_date": "2030-01-01", "end_date": "2030-01-01"}) + self.addCleanup( + self.set_event, {"start_date": self.event.start_date, "end_date": self.event.end_date} + ) + self.set_event({"start_date": add_days(today(), -10), "end_date": add_days(today(), -10)}) with self.assertRaises(RegistrationsClosed): - self.insert_as(outsider, user=outsider) + self.insert_as(self.outsider) def test_an_outsider_may_book_an_open_published_event(self): - outsider = self.outsider_managing_another_team() - - booking = self.insert_as(outsider, user=outsider) + booking = self.insert_as(self.outsider) self.assertTrue(frappe.db.exists("Event Booking", booking.name)) @@ -185,47 +125,42 @@ def test_an_outsider_may_book_an_open_published_event(self): class TestTheServiceFlowRefusesIneligibleEvents(EligibilityTestCase): def test_service_refuses_an_unpublished_event(self): self.set_event({"is_published": 0}) - frappe.set_user(self.attendee) - with self.assertRaises(frappe.ValidationError): + with self.set_user(self.attendee), self.assertRaises(frappe.ValidationError): process_booking(self.booking_request()) self.assertIn("Event is not live", frappe.local.message_log[-1]["message"]) def test_service_refuses_once_registrations_have_closed(self): - self.set_event({"registrations_close_at": "2020-01-01 00:00:00"}) - frappe.set_user(self.attendee) + self.close_registrations() - with self.assertRaises(RegistrationsClosed): + with self.set_user(self.attendee), self.assertRaises(RegistrationsClosed): process_booking(self.booking_request()) class TestLegitimateFlowsStillWork(EligibilityTestCase): def test_the_guard_does_not_apply_to_the_vetted_service_flow(self): # Free ticket keeps the flow off the payment gateway. - free_ticket_type = self.make_ticket_type(price=0) + free_ticket_type = str(self.free_ticket_type.name) + request = self.booking_request(attendees=[self.attendee_row(ticket_type=free_ticket_type)]) - frappe.set_user(self.attendee) - payload = process_booking( - self.booking_request(attendees=[self.attendee_row(ticket_type=str(free_ticket_type.name))]) - ).__json__() + with self.set_user(self.attendee): + payload = process_booking(request) - self.assertIn("booking_name", payload) - self.assertTrue(frappe.db.exists("Event Booking", payload["booking_name"])) + self.assertTrue(frappe.db.exists("Event Booking", payload.booking_name)) def test_an_event_organiser_may_book_their_own_closed_event(self): - # Organisers are exempt (comp tickets, pre-launch testing). - self.set_event({"is_published": 0, "registrations_close_at": "2020-01-01 00:00:00"}) + self.set_event({"is_published": 0}) + self.close_registrations() - booking = self.insert_as(self.organiser, user=self.organiser) + booking = self.insert_as(self.organiser) self.assertTrue(frappe.db.exists("Event Booking", booking.name)) def test_a_draft_booked_while_open_survives_registrations_closing(self): - # A draft made while open must stay writable via a trusted flow (payment - # authorisation, offline approval) after close, or a paid booking is stranded. - booking = self.insert_as(self.organiser, user=self.organiser) - self.set_event({"registrations_close_at": "2020-01-01 00:00:00"}) + # Payment authorisation and offline approval resave the draft after close. + booking = self.insert_as(self.organiser) + self.close_registrations() booking.reload() booking.flags.ignore_permissions = True @@ -235,48 +170,36 @@ def test_a_draft_booked_while_open_survives_registrations_closing(self): class TestTheGuardCannotBeSteppedAround(EligibilityTestCase): - """The guard runs on every write, so a draft cannot outlive its event's - eligibility by being edited or repointed after the fact.""" - def test_an_outsider_cannot_grow_a_draft_after_registrations_close(self): - free_type = self.make_ticket_type(price=0) - outsider = self.outsider_managing_another_team() - booking = self.insert_as( - outsider, - user=outsider, - attendees=[self.attendee_row(ticket_type=str(free_type.name))], - ) - self.set_event({"registrations_close_at": "2020-01-01 00:00:00"}) - - frappe.set_user(outsider) - booking.reload() - booking.append( - "attendees", - self.attendee_row(email="smuggled@example.com", ticket_type=str(free_type.name)), - ) - - with self.assertRaises(RegistrationsClosed): - booking.save() + free_type = str(self.free_ticket_type.name) + booking = self.insert_as(self.outsider, attendees=[self.attendee_row(ticket_type=free_type)]) + self.close_registrations() + + with self.set_user(self.outsider): + booking.reload() + booking.append( + "attendees", self.attendee_row(email="smuggled@example.com", ticket_type=free_type) + ) + with self.assertRaises(RegistrationsClosed): + booking.save() self.assertEqual(frappe.db.count("Event Booking Attendee", {"parent": booking.name}), 1) def test_a_draft_cannot_be_repointed_at_a_closed_event(self): - # The guard only runs on creation, so a draft must not be able to reach a - # closed event afterwards. Ticket types are what pin it down. - booking = self.insert_as(self.organiser, user=self.organiser) - closed_event = self.make_event(registrations_close_at="2020-01-01 00:00:00") + # Ticket types pin a draft to its event. + booking = self.insert_as(self.organiser) + closed_event = BuzzEventFactory.create( + team=self.event.team, registrations_close_at=add_days(now_datetime(), -1) + ) - frappe.set_user(self.organiser) - booking.event = closed_event.name - with self.assertRaises(frappe.ValidationError): + with self.set_user(self.organiser), self.assertRaises(frappe.ValidationError): + booking.event = closed_event.name booking.save() self.assertEqual(frappe.db.get_value("Event Booking", booking.name, "event"), str(self.event.name)) class TestWhatValidateAlreadyEnforced(EligibilityTestCase): - """Rules that predate the eligibility guard, kept as guardrails.""" - def test_prices_are_refetched_from_the_ticket_type(self): booking = self.insert_as(self.organiser, attendees=[self.attendee_row(amount=0)]) diff --git a/buzz/ticketing/doctype/event_booking/test_guest_booking.py b/buzz/ticketing/doctype/event_booking/test_guest_booking.py index dca08fea..eaee83a2 100644 --- a/buzz/ticketing/doctype/event_booking/test_guest_booking.py +++ b/buzz/ticketing/doctype/event_booking/test_guest_booking.py @@ -1,211 +1,100 @@ # Copyright (c) 2025, BWH Studios and Contributors # See license.txt -import uuid from base64 import b32encode import frappe import pyotp -from frappe.tests import IntegrationTestCase -from buzz.api.booking import process_booking as process_booking_endpoint -from buzz.api.booking.schemas import BookingRequest +from buzz.api.booking import process_booking +from buzz.tests.base_test_cases import BookingTestCase +OTP_SECRET = b32encode(b"TESTSECRET").decode("utf-8") +WRONG_OTP = "000000" -def process_booking(**kwargs): - return process_booking_endpoint(BookingRequest(**kwargs)).__json__() - -class TestGuestBooking(IntegrationTestCase): +class TestGuestBooking(BookingTestCase): def setUp(self): - frappe.set_user("Administrator") - self.test_event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - self.event_name = str(self.test_event.name) - self.original_allow_guest = self.test_event.allow_guest_booking - self.original_verification = self.test_event.guest_verification_method - self.test_event.allow_guest_booking = True - self.test_event.guest_verification_method = "None" - self.test_event.is_published = True - self.test_event.save() - self.ticket_type = str(self._get_or_create_free_ticket_type()) - - def tearDown(self): - frappe.set_user("Administrator") - self.test_event.reload() - self.test_event.allow_guest_booking = self.original_allow_guest - self.test_event.guest_verification_method = self.original_verification - self.test_event.is_published = False - self.test_event.save() - - # --- helpers --- - - def _generate_test_email(self): - return f"testguest-{uuid.uuid4().hex[:8]}@example.com" - - def _cleanup_test_user(self, email): - frappe.set_user("Administrator") - if frappe.db.exists("User", email): - frappe.delete_doc("User", email, force=True) - - def _get_or_create_free_ticket_type(self): - existing = frappe.db.get_value( - "Event Ticket Type", - {"event": self.test_event.name, "title": "Free (Test)"}, - "name", - ) - if existing: - return existing - return ( - frappe.get_doc( - { - "doctype": "Event Ticket Type", - "event": self.test_event.name, - "title": "Free (Test)", - } - ) - .insert() - .name - ) - - def _make_attendees(self, email): - return [{"ticket_type": self.ticket_type, "first_name": "Test", "last_name": "Guest", "email": email}] - - # --- tests --- - - def test_guest_booking_without_otp(self): - """Full happy path: guest books with verification='None', booking + user created.""" - email = self._generate_test_email() - try: - frappe.set_user("Guest") - result = process_booking( - attendees=self._make_attendees(email), - event=self.event_name, - guest_email=email, - guest_full_name="Test Guest", - ) - self.assertIn("booking_name", result) - self.assertTrue(frappe.db.exists("Event Booking", result["booking_name"])) - self.assertTrue(frappe.db.exists("User", email)) - self.assertIn("Buzz User", frappe.get_roles(email)) - finally: - self._cleanup_test_user(email) - - def test_guest_booking_with_otp(self): - """OTP happy path: cache OTP, pass it in, booking succeeds, OTP cache cleared.""" - email = self._generate_test_email() - self.test_event.guest_verification_method = "Email OTP" - self.test_event.save() - try: - # Simulate OTP generation (same as send_guest_booking_otp) - otp_secret = b32encode(b"TESTSECRET").decode("utf-8") - otp_code = pyotp.HOTP(otp_secret).at(0) - frappe.cache.set_value( - f"guest_booking_otp:email:{email.lower().strip()}", otp_secret, expires_in_sec=600 - ) - - frappe.set_user("Guest") - result = process_booking( - attendees=self._make_attendees(email), - event=self.event_name, - guest_email=email, - guest_full_name="Test Guest", - otp=str(otp_code), - ) - self.assertIn("booking_name", result) - # OTP cache should be cleared after successful verification - self.assertIsNone(frappe.cache.get_value(f"guest_booking_otp:email:{email.lower().strip()}")) - finally: - self._cleanup_test_user(email) + super().setUp() + self.set_event({"allow_guest_booking": 1, "guest_verification_method": "None"}) + self.email = f"testguest-{frappe.generate_hash(length=8)}@example.com" + # process_booking commits, so the guest's User outlives the rollback. + self.addCleanup(self.delete_guest_user) + + def test_guest_booking_without_otp_creates_the_booking_and_user(self): + with self.set_user("Guest"): + payload = self.book_as_guest() + + self.assertTrue(frappe.db.exists("Event Booking", payload.booking_name)) + self.assertIn("Buzz User", frappe.get_roles(self.email)) + + def test_guest_booking_with_otp_clears_the_code(self): + self.enable_email_otp() + self.store_otp() + + with self.set_user("Guest"): + payload = self.book_as_guest(otp=pyotp.HOTP(OTP_SECRET).at(0)) + + self.assertTrue(frappe.db.exists("Event Booking", payload.booking_name)) + self.assertIsNone(frappe.cache.get_value(self.otp_cache_key())) def test_guest_booking_rejected_when_disabled(self): - """Security gate: allow_guest_booking=False raises AuthenticationError.""" - self.test_event.allow_guest_booking = False - self.test_event.save() - - frappe.set_user("Guest") - with self.assertRaises(frappe.AuthenticationError): - process_booking( - attendees=self._make_attendees("nobody@example.com"), - event=self.event_name, - guest_email="nobody@example.com", - guest_full_name="Nobody", - ) + self.set_event({"allow_guest_booking": 0}) + + with self.set_user("Guest"), self.assertRaises(frappe.AuthenticationError): + self.book_as_guest() def test_invalid_otp_rejected(self): - """Wrong OTP code raises ValidationError.""" - email = self._generate_test_email() - self.test_event.guest_verification_method = "Email OTP" - self.test_event.save() - - otp_secret = b32encode(b"TESTSECRET").decode("utf-8") - frappe.cache.set_value( - f"guest_booking_otp:email:{email.lower().strip()}", otp_secret, expires_in_sec=600 - ) - - frappe.set_user("Guest") - with self.assertRaises(frappe.ValidationError): - process_booking( - attendees=self._make_attendees(email), - event=self.event_name, - guest_email=email, - guest_full_name="Test Guest", - otp="000000", - ) + self.enable_email_otp() + self.store_otp() + + with self.set_user("Guest"), self.assertRaises(frappe.ValidationError): + self.book_as_guest(otp=WRONG_OTP) def test_guest_booking_requires_email(self): - """Missing email raises ValidationError.""" - frappe.set_user("Guest") - with self.assertRaises(frappe.ValidationError): - process_booking( - attendees=[ - { - "ticket_type": self.ticket_type, - "first_name": "Test", - "last_name": "Guest", - "email": "t@e.com", - } - ], - event=self.event_name, - guest_email="", - guest_full_name="Test Guest", - ) + with self.set_user("Guest"), self.assertRaises(frappe.ValidationError): + self.book_as_guest(guest_email="") def test_brute_force_lockout(self): - """Repeated wrong OTPs locks out subsequent attempts. - - LoginAttemptTracker(max_consecutive_login_attempts=5) uses strict > - comparison, so lockout triggers after count exceeds the threshold. - """ - email = self._generate_test_email() - self.test_event.guest_verification_method = "Email OTP" - self.test_event.save() - - otp_secret = b32encode(b"TESTSECRET").decode("utf-8") - cache_key = f"guest_booking_otp:email:{email.lower().strip()}" - - frappe.set_user("Guest") - - # Exhaust allowed attempts (Frappe's tracker uses > comparison, - # so we need max_consecutive_login_attempts + 1 failures to trigger lockout) - for _ in range(6): - frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600) - with self.assertRaises(frappe.ValidationError): - process_booking( - attendees=self._make_attendees(email), - event=self.event_name, - guest_email=email, - guest_full_name="Test Guest", - otp="000000", - ) - - # Next attempt should hit "Too many failed attempts" - frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600) - with self.assertRaises(frappe.ValidationError) as ctx: - process_booking( - attendees=self._make_attendees(email), - event=self.event_name, - guest_email=email, - guest_full_name="Test Guest", - otp="000000", - ) - self.assertIn("Too many failed attempts", str(ctx.exception)) + # LoginAttemptTracker(max_consecutive_login_attempts=5) compares with `>`, + # so the lockout starts after the sixth failure. + self.enable_email_otp() + + with self.set_user("Guest"): + for _ in range(6): + self.store_otp() + with self.assertRaises(frappe.ValidationError): + self.book_as_guest(otp=WRONG_OTP) + + self.store_otp() + with self.assertRaises(frappe.ValidationError) as raised: + self.book_as_guest(otp=WRONG_OTP) + + self.assertIn("Too many failed attempts", str(raised.exception)) + + def book_as_guest(self, **overrides): + attendees = [ + { + "ticket_type": str(self.free_ticket_type.name), + "first_name": "Test", + "last_name": "Guest", + "email": self.email, + } + ] + values = {"guest_email": self.email, "guest_full_name": "Test Guest", **overrides} + return process_booking(self.booking_request(attendees=attendees, **values)) + + def enable_email_otp(self): + self.set_event({"guest_verification_method": "Email OTP"}) + + def store_otp(self): + """What `send_guest_booking_otp` leaves in the cache.""" + frappe.cache.set_value(self.otp_cache_key(), OTP_SECRET, expires_in_sec=600) + self.addCleanup(frappe.cache.delete_value, self.otp_cache_key()) + + def otp_cache_key(self) -> str: + return f"guest_booking_otp:email:{self.email}" + + def delete_guest_user(self): + if frappe.db.exists("User", self.email): + frappe.delete_doc("User", self.email, force=True, ignore_permissions=True) diff --git a/buzz/ticketing/doctype/event_booking/test_offline_booking.py b/buzz/ticketing/doctype/event_booking/test_offline_booking.py new file mode 100644 index 00000000..a5a4f681 --- /dev/null +++ b/buzz/ticketing/doctype/event_booking/test_offline_booking.py @@ -0,0 +1,111 @@ +import frappe + +from buzz.api.booking import process_booking +from buzz.tests.base_test_cases import BookingTestCase +from buzz.tests.factories import ( + BuzzCouponCodeFactory, + EventBookingFactory, + EventTicketTypeFactory, + OfflinePaymentMethodFactory, +) + +TICKET_PRICE = 500 +AWAITING_VERIFICATION = {"status": "Approval Pending", "payment_status": "Verification Pending"} + + +class TestOfflineBooking(BookingTestCase): + def setUp(self): + super().setUp() + self.set_event({"apply_tax": 0}) + self.paid_ticket_type = EventTicketTypeFactory.create( + event=self.event.name, prices=[{"currency": "INR", "price": TICKET_PRICE}] + ).name + + def test_offline_booking_cannot_be_submitted_directly(self): + booking = self.create_offline_booking() + + with self.assertRaises(frappe.ValidationError): + booking.submit() + + def test_booking_without_a_payment_method_takes_the_normal_flow(self): + booking = self.create_booking() + + booking.submit() + + self.assertEqual(booking.payment_status, "Unpaid") + + def test_approving_submits_the_booking_and_issues_tickets(self): + booking = self.create_offline_booking(**AWAITING_VERIFICATION) + self.assertEqual((booking.docstatus, self.ticket_count(booking.name)), (0, 0)) + + booking.approve_booking() + booking.reload() + + self.assertEqual((booking.docstatus, booking.status, booking.payment_status), (1, "Approved", "Paid")) + self.assertEqual(self.ticket_count(booking.name), 1) + + def test_rejecting_discards_the_booking_without_tickets(self): + booking = self.create_offline_booking(**AWAITING_VERIFICATION) + + booking.reject_booking() + booking.reload() + + self.assertEqual((booking.docstatus, booking.status), (2, "Rejected")) + self.assertEqual(self.ticket_count(booking.name), 0) + + def test_offline_booking_applies_a_coupon(self): + coupon = BuzzCouponCodeFactory.create(discount_type="Percentage", discount_value=10).name + + booking = self.create_offline_booking(coupon_code=coupon) + + self.assertEqual((booking.net_amount, booking.discount_amount, booking.total_amount), (500, 50, 450)) + + def test_offline_booking_applies_tax(self): + self.set_event({"apply_tax": 1, "tax_inclusive": 0, "tax_label": "GST", "tax_percentage": 18}) + + booking = self.create_offline_booking() + + self.assertEqual((booking.net_amount, booking.tax_percentage), (500, 18)) + self.assertEqual((booking.tax_amount, booking.total_amount), (90, 590)) + + def test_process_booking_leaves_an_offline_booking_in_draft(self): + payload = self.book_offline() + + self.assertTrue(payload.offline_payment) + booking = frappe.get_doc("Event Booking", payload.booking_name) + self.assertEqual(booking.docstatus, 0) + self.assertEqual((booking.status, booking.payment_status), tuple(AWAITING_VERIFICATION.values())) + self.assertEqual(self.ticket_count(booking.name), 0) + + def test_approving_a_booking_from_process_booking_issues_tickets(self): + booking = frappe.get_doc("Event Booking", self.book_offline().booking_name) + + booking.approve_booking() + booking.reload() + + self.assertEqual((booking.docstatus, booking.status, booking.payment_status), (1, "Approved", "Paid")) + self.assertEqual(self.ticket_count(booking.name), 1) + + def create_booking(self, **overrides): + attendees = [ + {"ticket_type": self.paid_ticket_type, "first_name": "Test", "email": "test@example.com"} + ] + return EventBookingFactory.create(event=self.event.name, attendees=attendees, **overrides) + + def create_offline_booking(self, **overrides): + return self.create_booking(payment_method="Offline", **overrides) + + def book_offline(self): + method = str(OfflinePaymentMethodFactory.create(event=self.event.name).name) + attendees = [ + { + "first_name": "Offline", + "email": "offline@example.com", + "ticket_type": str(self.paid_ticket_type), + } + ] + request = self.booking_request(attendees=attendees, is_offline=True, offline_payment_method=method) + return process_booking(request) + + def ticket_count(self, booking: str) -> int: + return frappe.db.count("Event Ticket", {"booking": booking}) diff --git a/buzz/ticketing/doctype/event_booking/test_refund_sync.py b/buzz/ticketing/doctype/event_booking/test_refund_sync.py new file mode 100644 index 00000000..20fd919b --- /dev/null +++ b/buzz/ticketing/doctype/event_booking/test_refund_sync.py @@ -0,0 +1,265 @@ +from unittest.mock import MagicMock, patch + +import frappe +from pydantic import ValidationError + +from buzz.api.checkin import checkin_ticket +from buzz.payments import handle_refund_notification +from buzz.tests.base_test_cases import ( + CHARGED_PER_TICKET, + GATEWAY_CONTROLLER, + BookingRefundTestCase, +) +from buzz.tests.factories import PaymentGatewayFactory + +BOOKING_TOTAL = 1100 + + +class TestRefundNotification(BookingRefundTestCase): + def setUp(self): + super().setUp() + self.make_payment() + + def test_a_processed_refund_covering_the_total_marks_the_booking_refunded(self): + self.initiate_refund(BOOKING_TOTAL) + + self.send_refund_webhook(self.refund_id(), "processed", BOOKING_TOTAL) + + self.assertEqual(self.booking.refund_status, "Refunded") + self.assertEqual(self.booking.refunded_amount, BOOKING_TOTAL) + self.assertEqual(self.refunds()[0].status, "Processed") + + def test_a_processed_refund_below_the_total_marks_the_booking_partially_refunded(self): + self.initiate_refund(CHARGED_PER_TICKET) + + self.send_refund_webhook(self.refund_id(), "processed", CHARGED_PER_TICKET) + + self.assertEqual(self.booking.refund_status, "Partially Refunded") + self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + + def test_a_full_refund_buzz_never_raised_cancels_every_remaining_ticket(self): + # Raised on the Razorpay dashboard: the webhook carries no tickets. + self.send_refund_webhook(self.refund_id(), "processed", BOOKING_TOTAL) + + self.assertEqual(self.booking.refund_status, "Refunded") + self.assertEqual(len(self.refunds()[0].tickets), 2) + self.assertEqual([request.docstatus for request in self.cancellation_requests()], [1]) + + def test_a_partial_refund_buzz_never_raised_cancels_nothing(self): + self.send_refund_webhook(self.refund_id(), "processed", CHARGED_PER_TICKET) + + self.assertEqual(self.refunds()[0].tickets, []) + self.assertEqual(self.cancellation_requests(), []) + + def test_the_same_event_arriving_twice_is_not_counted_twice(self): + self.initiate_refund(CHARGED_PER_TICKET) + + self.send_refund_webhook(self.refund_id(), "processed", CHARGED_PER_TICKET) + self.send_refund_webhook(self.refund_id(), "processed", CHARGED_PER_TICKET) + + self.assertEqual(len(self.refunds()), 1) + self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + + def test_a_failed_refund_is_not_counted_and_cancels_nothing(self): + ticket = self.refundable_tickets()[0] + self.initiate_refund(CHARGED_PER_TICKET, tickets=[ticket]) + + self.send_refund_webhook(self.refund_id(), "failed", CHARGED_PER_TICKET) + + self.assertEqual(self.refunds()[0].status, "Failed") + self.assertEqual(self.booking.refunded_amount, 0) + self.assertEqual(self.cancellation_requests(), []) + self.assertEqual(frappe.db.get_value("Event Ticket", ticket, "docstatus"), 1) + + def test_a_webhook_processed_as_guest_still_updates_the_booking(self): + # The webhook is an allow_guest endpoint, so its job runs as Guest. + self.initiate_refund(CHARGED_PER_TICKET, tickets=self.refundable_tickets()[:1]) + log = self.create_refund_log(self.refund_payload(self.refund_id(), "processed", CHARGED_PER_TICKET)) + + with self.set_user("Guest"): + self.handle_refund_log(log) + + self.assertEqual(self.refunds()[0].status, "Processed") + self.assertEqual(self.booking.refund_status, "Partially Refunded") + self.assertEqual([request.docstatus for request in self.cancellation_requests()], [1]) + + def test_a_processed_refund_raises_and_submits_the_cancellation(self): + ticket = self.refundable_tickets()[0] + self.initiate_refund(CHARGED_PER_TICKET, tickets=[ticket]) + + self.send_refund_webhook(self.refund_id(), "processed", CHARGED_PER_TICKET) + + request = frappe.get_doc("Ticket Cancellation Request", self.refunds()[0].cancellation_request) + self.assertEqual((request.status, request.docstatus), ("Accepted", 1)) + self.assertEqual(frappe.db.get_value("Event Ticket", ticket, "docstatus"), 2) + + def test_a_cancellation_that_cannot_go_through_leaves_the_refund_recorded(self): + ticket = self.refundable_tickets()[0] + self.initiate_refund(CHARGED_PER_TICKET, tickets=[ticket]) + + with patch("frappe.sendmail", side_effect=Exception("no outgoing email account")): + self.refunds()[0].apply_gateway_status("processed", CHARGED_PER_TICKET) + + self.booking.reload() + self.assertEqual(self.refunds()[0].status, "Processed") + self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + request = frappe.get_doc("Ticket Cancellation Request", self.refunds()[0].cancellation_request) + self.assertEqual((request.status, request.docstatus), ("In Review", 0)) + self.assertEqual(frappe.db.get_value("Event Ticket", ticket, "docstatus"), 1) + + def test_the_integration_request_is_linked_back_to_the_booking(self): + self.initiate_refund(CHARGED_PER_TICKET) + + log = self.send_refund_webhook(self.refund_id(), "processed", CHARGED_PER_TICKET) + + self.assertEqual( + frappe.db.get_value("Integration Request", log, "reference_docname"), self.booking.name + ) + + def test_a_refund_for_an_unknown_payment_is_ignored(self): + payload = self.refund_payload(self.refund_id(), "processed", CHARGED_PER_TICKET) + payload["payload"]["refund"]["entity"]["payment_id"] = "pay_unknown" + + self.assertIsNone(handle_refund_notification("Integration Request", self.create_refund_log(payload))) + self.assertEqual(self.refunds(), []) + + def test_a_payload_without_a_refund_is_refused(self): + log = self.create_refund_log({"event": "refund.processed", "payload": {}}) + + self.assertRaises(ValidationError, handle_refund_notification, "Integration Request", log) + + +class TestSyncRefunds(BookingRefundTestCase): + def setUp(self): + super().setUp() + self.make_payment() + + def test_a_refund_raised_outside_buzz_is_recorded(self): + result = self.sync(self.gateway_refund("processed", CHARGED_PER_TICKET)) + + self.assertEqual(result, {"refunds": 1, "created": 1}) + self.assertEqual([refund.status for refund in self.refunds()], ["Processed"]) + self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + self.assertEqual(self.booking.refund_status, "Partially Refunded") + + def test_a_payment_with_no_refunds_records_nothing(self): + result = self.sync() + + self.assertEqual(result, {"refunds": 0, "created": 0}) + self.assertEqual(self.refunds(), []) + self.assertEqual(self.booking.refund_status, "") + + def test_a_full_refund_raised_outside_buzz_cancels_every_remaining_ticket(self): + self.sync(self.gateway_refund("processed", BOOKING_TOTAL)) + + self.assertEqual(self.booking.refund_status, "Refunded") + self.assertEqual(len(self.refunds()[0].tickets), 2) + self.assertEqual([request.docstatus for request in self.cancellation_requests()], [1]) + + def test_a_partial_refund_raised_outside_buzz_cancels_nothing(self): + self.sync(self.gateway_refund("processed", CHARGED_PER_TICKET)) + + self.assertEqual(self.refunds()[0].tickets, []) + self.assertEqual(self.cancellation_requests(), []) + + def test_a_refund_the_gateway_still_calls_pending_is_initiated(self): + self.sync(self.gateway_refund("pending", BOOKING_TOTAL)) + + self.assertEqual(self.refunds()[0].status, "Initiated") + self.assertEqual(self.booking.refund_status, "Refund Initiated") + self.assertEqual(self.booking.refunded_amount, 0) + self.assertEqual(self.cancellation_requests(), []) + + def test_a_refund_buzz_already_holds_is_updated_not_duplicated(self): + ticket = self.refundable_tickets()[0] + self.initiate_refund(CHARGED_PER_TICKET, tickets=[ticket]) + + result = self.sync(self.gateway_refund("processed", CHARGED_PER_TICKET)) + + self.assertEqual(result, {"refunds": 1, "created": 0}) + self.assertEqual([refund.status for refund in self.refunds()], ["Processed"]) + self.assertEqual([row.ticket for row in self.refunds()[0].tickets], [ticket]) + self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + + def test_a_settled_refund_is_not_knocked_back_by_a_stale_pending(self): + self.sync(self.gateway_refund("processed", BOOKING_TOTAL)) + + self.sync(self.gateway_refund("pending", BOOKING_TOTAL)) + + self.assertEqual(self.refunds()[0].status, "Processed") + self.assertEqual(self.booking.refund_status, "Refunded") + self.assertEqual(self.booking.refunded_amount, BOOKING_TOTAL) + self.assertEqual(len(self.cancellation_requests()), 1) + + def test_a_failed_refund_is_not_revived_by_a_later_sighting(self): + self.sync(self.gateway_refund("failed", CHARGED_PER_TICKET)) + + self.sync(self.gateway_refund("processed", CHARGED_PER_TICKET)) + + self.assertEqual(self.refunds()[0].status, "Failed") + self.assertEqual(self.booking.refunded_amount, 0) + + def test_a_refund_the_webhook_recorded_is_not_duplicated_by_a_sync(self): + self.send_refund_webhook(self.refund_id(), "processed", CHARGED_PER_TICKET) + + result = self.sync(self.gateway_refund("processed", CHARGED_PER_TICKET)) + + self.assertEqual(result, {"refunds": 1, "created": 0}) + self.assertEqual(len(self.refunds()), 1) + self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + + def test_syncing_twice_does_not_count_the_same_refund_twice(self): + refund = self.gateway_refund("processed", CHARGED_PER_TICKET) + + self.sync(refund) + self.sync(refund) + + self.assertEqual(len(self.refunds()), 1) + self.assertEqual(self.booking.refunded_amount, CHARGED_PER_TICKET) + + def test_a_checked_in_ticket_is_never_cancelled_by_a_synced_refund(self): + checked_in_ticket, other_ticket = self.refundable_tickets() + checkin_ticket(checked_in_ticket) + + self.sync(self.gateway_refund("processed", BOOKING_TOTAL)) + + self.assertEqual([row.ticket for row in self.refunds()[0].tickets], [other_ticket]) + + def test_two_refunds_in_one_batch_cancel_the_tickets_once_they_cover_the_total(self): + # Only the second refund brings the total up to the booking amount. + self.sync( + self.gateway_refund("processed", CHARGED_PER_TICKET, suffix="1"), + self.gateway_refund("processed", CHARGED_PER_TICKET, suffix="2"), + ) + + first, second = self.refunds() + self.assertEqual((first.tickets, len(second.tickets)), ([], 2)) + self.assertEqual(self.booking.refund_status, "Refunded") + self.assertEqual([request.docstatus for request in self.cancellation_requests()], [1]) + + def test_sync_is_refused_when_the_gateway_is_not_razorpay(self): + other_gateway = PaymentGatewayFactory.create().name + frappe.db.set_value("Event Payment", self.payment.name, "payment_gateway", other_gateway) + + with self.assertRaises(frappe.ValidationError) as raised: + self.booking.sync_refunds() + + self.assertIn("Razorpay", str(raised.exception)) + + def test_sync_is_refused_without_a_received_payment(self): + frappe.db.set_value("Event Payment", self.payment.name, "payment_received", 0) + + self.assertRaises(frappe.ValidationError, self.booking.sync_refunds) + + def gateway_refund(self, status: str, amount: float, suffix: str = "1") -> dict: + return {"id": self.refund_id(suffix), "status": status, "amount": int(amount * 100)} + + def sync(self, *gateway_refunds: dict) -> dict: + client = MagicMock() + client.fetch_refunds.return_value = list(gateway_refunds) + + with patch(GATEWAY_CONTROLLER, return_value=client), patch("frappe.sendmail"): + result = self.booking.sync_refunds() + + self.booking.reload() + return result 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 49f9b231..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,20 +62,23 @@ 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_feature(self.event.team, PAID_EVENTS_FLAG, value) + BuzzTeamFactory.set_settings(self.event.team, {PAID_EVENTS_FLAG: value}) def test_free_ticket_type_saves_without_the_flag(self): self.set_paid_events(0) 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} diff --git a/buzz/www/event/test_event.py b/buzz/www/event/test_event.py index 43896ca1..2f735a14 100644 --- a/buzz/www/event/test_event.py +++ b/buzz/www/event/test_event.py @@ -1,144 +1,95 @@ -import json -import re -from unittest.mock import patch - import frappe from frappe.tests import IntegrationTestCase -from frappe.utils import set_request -from frappe.website.serve import get_response_content - -from buzz.api.events.test_events import create_event -from buzz.api.forms.test_forms import ensure_event_host -from buzz.emails import venue_map_url as email_venue_map_url -from buzz.events.doctype.buzz_team.test_buzz_team import create_owned_team, create_user +from frappe.utils import add_days, today + +from buzz.tests.factories import ( + AdditionalEventPageFactory, + BuzzEventFactory, + BuzzTeamFactory, + EventHostFactory, + EventTalkFactory, + SpeakerProfileFactory, + UserFactory, +) from buzz.www.event.index import EventPage -from buzz.www.event.venue_map import google_maps_url, open_street_map_url, venue_map_url -from buzz.www.site_header import DEFAULT_FAVICON - - -def render(route: str) -> str: - set_request(method="GET", path=f"/events/{route}") - # CI never runs bench build, so there is no assets.json for bundled_asset to read - with patch("frappe.utils.get_assets_json", return_value={}): - return get_response_content(f"/events/{route}") - - -def structured_data(html: str) -> dict: - match = re.search(r'', html, re.DOTALL) - return json.loads(match.group(1)) class TestEventPage(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - frappe.set_user("Administrator") - if not frappe.db.exists("Event Category", "Test Category"): - frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert() - owner = create_user("event-page-owner@example.com", "Owner") - cls.team = create_owned_team("Event Page Team", owner) - cls.event = create_event( - "Public Page", cls.team, route="public-page-event", is_published=1, about="

Welcome

" - ) - - def tearDown(self): - frappe.set_user("Administrator") + owner = UserFactory.create_once("event-page-owner@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(owner, team_name="Event Page Team").name + event = BuzzEventFactory.create(team=cls.team, about="

Welcome

") + cls.event, cls.route = str(event.name), event.route def test_published_event_context(self): - context = EventPage("public-page-event").as_context() + context = self.context() self.assertEqual(str(context["event"].name), self.event) self.assertEqual([host.label for host in context["hosts"]], ["Event Page Team"]) self.assertTrue(context["timezone_label"]) def test_unpublished_event_is_not_found(self): - create_event("Hidden Page", self.team, route="hidden-page-event", is_published=0) + route = self.create_event(is_published=0).route with self.assertRaises(frappe.PageDoesNotExistError): - EventPage("hidden-page-event") + EventPage(route) def test_unknown_route_is_not_found(self): with self.assertRaises(frappe.PageDoesNotExistError): EventPage("no-such-event-route") def test_guest_sees_same_context(self): - expected = EventPage("public-page-event").as_context() - frappe.set_user("Guest") - context = EventPage("public-page-event").as_context() + expected = self.context() + with self.set_user("Guest"): + context = self.context() self.assertEqual(context["hosts"], expected["hosts"]) self.assertEqual(context["event_date"], expected["event_date"]) def test_schedule_time_before_ten(self): - event = frappe.get_doc( - "Buzz Event", create_event("Schedule", self.team, route="schedule-page-event", is_published=1) - ) - event.append( - "schedule", - { - "type": "Break", - "description": "Coffee", - "date": event.start_date, - "start_time": "09:00:00", - "end_time": "09:30:00", - }, - ) - event.flags.ignore_mandatory = True - event.save() - row = EventPage("schedule-page-event").as_context()["schedule"][0]["rows"][0] + event = self.create_event(start_time="08:00:00") + schedule = { + "type": "Break", + "description": "Coffee", + "start_time": "09:00:00", + "end_time": "09:30:00", + } + save_schedule(event, schedule) + row = EventPage(event.route).as_context()["schedule"][0]["rows"][0] self.assertEqual(row["time"], "09:00 \u2013 09:30") self.assertEqual(row["title"], "Coffee") def test_schedule_talk_shows_title_and_speakers(self): - event = frappe.get_doc( - "Buzz Event", create_event("Talks", self.team, route="talks-page-event", is_published=1) + event = self.create_event() + speaker_user = UserFactory.create(first_name="Asha", last_name="Rao").name + speaker = SpeakerProfileFactory.create(user=speaker_user).name + talk = EventTalkFactory.create( + event=event.name, title="Scaling apps", speakers=[{"speaker": speaker}] ) - speaker = frappe.get_doc({"doctype": "Speaker Profile", "display_name": "Asha Rao"}) - speaker.insert(ignore_mandatory=True) - talk = frappe.get_doc( - { - "doctype": "Event Talk", - "title": "Scaling apps", - "event": event.name, - "submitted_by": "Administrator", - "speakers": [{"speaker": speaker.name}], - } - ).insert() - event.append("featured_speakers", {"speaker": speaker.name}) - event.append( - "schedule", - { - "type": "Talk", - "talk": talk.name, - "date": event.start_date, - "start_time": "10:00:00", - "end_time": "10:30:00", - }, + event.append("featured_speakers", {"speaker": speaker}) + save_schedule( + event, {"type": "Talk", "talk": talk.name, "start_time": "10:00:00", "end_time": "10:30:00"} ) - event.flags.ignore_mandatory = True - event.save() - context = EventPage("talks-page-event").as_context() + context = EventPage(event.route).as_context() row = context["schedule"][0]["rows"][0] self.assertEqual((row["title"], row["speakers"]), ("Scaling apps", "Asha Rao")) self.assertEqual(context["speakers"][0]["name"], "Asha Rao") def test_time_zone_falls_back_to_system(self): - frappe.db.set_value("Buzz Event", self.event, {"time_zone": "", "time_zone_label": ""}) - self.assertTrue(EventPage("public-page-event").timezone_label) + self.set_event({"time_zone": "", "time_zone_label": ""}) + self.assertTrue(EventPage(self.route).timezone_label) def test_team_hosts_first_then_co_hosts(self): - event = frappe.get_doc( - "Buzz Event", create_event("Hosts", self.team, route="hosts-page-event", is_published=1) - ) + event = self.create_event() for name in ("Acme Page Host", "Beta Page Host"): - event.append("co_hosts", {"host": ensure_event_host(name)}) + event.append("co_hosts", {"host": EventHostFactory.create(team=self.team, host_name=name).name}) event.save() - hosts = EventPage("hosts-page-event").as_context()["hosts"] + hosts = EventPage(event.route).as_context()["hosts"] self.assertEqual( [host.label for host in hosts], ["Event Page Team", "Acme Page Host", "Beta Page Host"] ) def test_links_keep_table_order_and_drop_unsafe_urls(self): - event = frappe.get_doc( - "Buzz Event", create_event("Links", self.team, route="links-page-event", is_published=1) - ) + event = self.create_event() event.append( "external_links", {"icon": "map-pin", "label": "Venue map", "url": "https://maps.example.com"} ) @@ -146,228 +97,71 @@ def test_links_keep_table_order_and_drop_unsafe_urls(self): event.save() frappe.db.set_value("Event External Link", event.external_links[1].name, "url", "javascript:alert(1)") - links = EventPage("links-page-event").as_context()["links"] + links = EventPage(event.route).as_context()["links"] self.assertEqual([link["label"] for link in links], ["Venue map"]) self.assertIn("M20 10c0", links[0]["icon_svg"]) - def test_links_render_with_a_fallback_icon(self): - event = frappe.get_doc( - "Buzz Event", create_event("Rendered Links", self.team, route="rendered-links", is_published=1) - ) - event.append("external_links", {"icon": "unknown", "label": "Join the chat", "url": "https://t.me/x"}) - event.save() - - html = render("rendered-links") - - self.assertIn('href="https://t.me/x"', html) - self.assertIn("Join the chat", html) - self.assertIn("M10 13a5", html) - - def test_title_is_escaped(self): - frappe.db.set_value("Buzz Event", self.event, "title", "") - html = render("public-page-event") - self.assertNotIn("", html) - self.assertIn("<script>alert(1)</script>", html) - - def test_about_markup_survives(self): - self.assertIn("Welcome", render("public-page-event")) - - def test_event_theme_overrides_the_default(self): - frappe.db.set_value("Buzz Event", self.event, "theme", "Paper") - html = render("public-page-event") - self.assertIn('defaultMode = "light"', html) - # The theme CSS sits in an autoescaped