From 47040e1548fed584ac0b41f7d1ac3ae08d0bc4c4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Nov 2026 19:08:09 -0600 Subject: [PATCH 1/3] test: backfill coverage across sponsors, manage, registration and conference - fix get_session_rating_analytics crash (mixed Float/Decimal in Coalesce) - drop unreachable ConferenceEditView.form_valid; success message now sent from the path that runs --- .../manage/reports_analytics.py | 4 +- src/django_program/manage/views.py | 13 +- tests/test_conference/test_models_coverage.py | 181 +++ .../test_bulk_purchases_coverage.py | 416 +++++++ .../test_letters_financial_coverage.py | 371 ++++++ .../test_reports_analytics_coverage.py | 413 +++++++ tests/test_manage/test_reports_coverage.py | 427 +++++++ tests/test_manage/test_views_coverage.py | 465 ++++++++ .../test_checkin_admin_coverage.py | 699 +++++++++++ .../test_stripe_client_coverage.py | 175 +++ .../test_terminal_coverage.py | 1038 +++++++++++++++++ .../test_webhooks_services_coverage.py | 525 +++++++++ .../test_admin_models_coverage.py | 116 ++ tests/test_sponsors/test_services_coverage.py | 434 +++++++ tests/test_sponsors/test_views_coverage.py | 326 ++++++ 15 files changed, 5589 insertions(+), 14 deletions(-) create mode 100644 tests/test_conference/test_models_coverage.py create mode 100644 tests/test_manage/test_bulk_purchases_coverage.py create mode 100644 tests/test_manage/test_letters_financial_coverage.py create mode 100644 tests/test_manage/test_reports_analytics_coverage.py create mode 100644 tests/test_manage/test_reports_coverage.py create mode 100644 tests/test_manage/test_views_coverage.py create mode 100644 tests/test_registration/test_checkin_admin_coverage.py create mode 100644 tests/test_registration/test_stripe_client_coverage.py create mode 100644 tests/test_registration/test_terminal_coverage.py create mode 100644 tests/test_registration/test_webhooks_services_coverage.py create mode 100644 tests/test_sponsors/test_admin_models_coverage.py create mode 100644 tests/test_sponsors/test_services_coverage.py create mode 100644 tests/test_sponsors/test_views_coverage.py diff --git a/src/django_program/manage/reports_analytics.py b/src/django_program/manage/reports_analytics.py index dcad99b..1594b42 100644 --- a/src/django_program/manage/reports_analytics.py +++ b/src/django_program/manage/reports_analytics.py @@ -7,7 +7,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Any -from django.db.models import Avg, Count, Q, Sum, Value +from django.db.models import Avg, Count, DecimalField, Q, Sum, Value from django.db.models.functions import Coalesce if TYPE_CHECKING: @@ -212,7 +212,7 @@ def get_session_rating_analytics(conference: Conference) -> dict[str, Any]: agg = ratings_qs.aggregate( total_ratings=Count("id"), - average_score=Coalesce(Avg("score"), Value(_ZERO)), + average_score=Coalesce(Avg("score", output_field=DecimalField()), Value(_ZERO)), ) total_ratings: int = agg["total_ratings"] average_score: Decimal = Decimal(str(agg["average_score"])) diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index 2ffd505..f651af1 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -915,19 +915,8 @@ def _forms_valid(self, form: ConferenceForm, kpi_form: KPITargetsForm) -> HttpRe kpi = kpi_form.save(commit=False) kpi.conference = self.conference kpi.save() - return response - - def form_valid(self, form: ConferenceForm) -> HttpResponse: - """Save the form and add a success message. - - Args: - form: The validated conference form. - - Returns: - A redirect response to the success URL. - """ messages.success(self.request, "Conference updated successfully.") - return super().form_valid(form) + return response class SectionListView(ManagePermissionMixin, ListView): diff --git a/tests/test_conference/test_models_coverage.py b/tests/test_conference/test_models_coverage.py new file mode 100644 index 0000000..fc583ba --- /dev/null +++ b/tests/test_conference/test_models_coverage.py @@ -0,0 +1,181 @@ +"""Coverage backfill tests for: + +- ``conference/management/commands/setup_groups.py`` (the "missing + permissions" warning branch) +- Simple ``__str__`` representations in ``conference/models.py``, + ``pretalx/models.py``, and ``programs/models.py`` that had no direct + coverage. +""" + +from datetime import date +from decimal import Decimal +from io import StringIO +from unittest.mock import patch + +import pytest +from django.contrib.auth import get_user_model +from django.core.management import call_command + +from django_program.conference.models import Conference, Expense, ExpenseCategory +from django_program.pretalx.models import SessionRating, Talk +from django_program.programs.models import Survey, SurveyResponse + +User = get_user_model() + + +# ============================================================================= +# setup_groups command — missing permissions warning +# ============================================================================= + + +@pytest.mark.django_db +def test_setup_groups_warns_when_permissions_are_missing(): + """When Permission.objects.filter returns no matches, every requested + permission is reported as missing and the warning line is printed.""" + out = StringIO() + + with patch("django_program.conference.management.commands.setup_groups.Permission.objects.filter") as mock_filter: + mock_filter.return_value = [] + call_command("setup_groups", stdout=out, verbosity=1) + + output = out.getvalue() + assert "missing permissions" in output + assert "Conference Organizer" in output + + +@pytest.mark.django_db +def test_setup_groups_silent_when_verbosity_zero(): + """Verbosity 0 suppresses the missing-permissions warning line entirely.""" + out = StringIO() + + with patch("django_program.conference.management.commands.setup_groups.Permission.objects.filter") as mock_filter: + mock_filter.return_value = [] + call_command("setup_groups", stdout=out, verbosity=0) + + assert out.getvalue() == "" + + +@pytest.mark.django_db +def test_setup_groups_creates_groups_without_warning_when_all_matched(): + out = StringIO() + call_command("setup_groups", stdout=out, verbosity=1) + output = out.getvalue() + + assert "missing permissions" not in output + assert "Done." in output + + +# ============================================================================= +# conference/models.py — __str__ methods +# ============================================================================= + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="ModelCoverageCon", + slug="model-coverage-con", + start_date=date(2027, 8, 1), + end_date=date(2027, 8, 3), + timezone="UTC", + ) + + +@pytest.mark.unit +@pytest.mark.django_db +class TestExpenseCategoryStr: + def test_str_includes_name_and_conference_slug(self, conference): + category = ExpenseCategory.objects.create( + conference=conference, + name="Venue", + slug="venue", + ) + assert str(category) == f"Venue ({conference.slug})" + + +@pytest.mark.unit +@pytest.mark.django_db +class TestExpenseStr: + def test_str_includes_description_and_amount(self, conference): + category = ExpenseCategory.objects.create( + conference=conference, + name="Catering", + slug="catering", + ) + expense = Expense.objects.create( + conference=conference, + category=category, + description="Lunch buffet", + amount=Decimal("1250.50"), + date=date(2027, 8, 2), + ) + assert str(expense) == "Lunch buffet ($1250.50)" + + +# ============================================================================= +# pretalx/models.py — SessionRating.__str__ +# ============================================================================= + + +@pytest.mark.unit +@pytest.mark.django_db +class TestSessionRatingStr: + def test_str_includes_score_talk_and_user(self, conference): + user = User.objects.create_user( + username="raterperson", + email="rater@example.com", + password="testpass123", + ) + talk = Talk.objects.create( + conference=conference, + pretalx_code="ABC123", + title="A Great Talk", + ) + rating = SessionRating.objects.create( + conference=conference, + talk=talk, + user=user, + score=4, + ) + expected = f"Rating 4/5 for {talk} by {user}" + assert str(rating) == expected + + +# ============================================================================= +# programs/models.py — Survey.__str__ / SurveyResponse.__str__ +# ============================================================================= + + +@pytest.mark.unit +@pytest.mark.django_db +class TestSurveyStr: + def test_str_includes_name_and_conference_slug(self, conference): + survey = Survey.objects.create( + conference=conference, + name="Post-Event Feedback", + slug="post-event-feedback", + ) + assert str(survey) == f"Post-Event Feedback ({conference.slug})" + + +@pytest.mark.unit +@pytest.mark.django_db +class TestSurveyResponseStr: + def test_str_includes_score_survey_and_user(self, conference): + user = User.objects.create_user( + username="surveyresponder", + email="responder@example.com", + password="testpass123", + ) + survey = Survey.objects.create( + conference=conference, + name="NPS Survey", + slug="nps-survey", + ) + response = SurveyResponse.objects.create( + survey=survey, + user=user, + score=9, + ) + expected = f"Response 9 to {survey} by {user}" + assert str(response) == expected diff --git a/tests/test_manage/test_bulk_purchases_coverage.py b/tests/test_manage/test_bulk_purchases_coverage.py new file mode 100644 index 0000000..7454c99 --- /dev/null +++ b/tests/test_manage/test_bulk_purchases_coverage.py @@ -0,0 +1,416 @@ +"""Coverage tests for bulk purchase management views and form.""" + +from datetime import date +from decimal import Decimal +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User +from django.test import Client +from django.urls import reverse + +from django_program.conference.models import Conference +from django_program.manage.forms_bulk_purchases import BulkPurchaseCreateForm +from django_program.registration.models import AddOn, TicketType +from django_program.sponsors.models import BulkPurchase, BulkPurchaseVoucher, Sponsor, SponsorLevel + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def superuser(db): + return User.objects.create_superuser(username="bpadmin", password="password", email="bpadmin@test.com") + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="BP Test Conf", + slug="bp-test-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + is_active=True, + ) + + +@pytest.fixture +def level(conference): + return SponsorLevel.objects.create(conference=conference, name="Gold", slug="gold", cost=Decimal("10000.00")) + + +@pytest.fixture +def sponsor(conference, level): + return Sponsor.objects.create(conference=conference, level=level, name="Acme Corp", slug="acme") + + +@pytest.fixture +def ticket_type(conference): + return TicketType.objects.create( + conference=conference, + name="General", + slug="general", + price=Decimal("100.00"), + bulk_enabled=True, + ) + + +@pytest.fixture +def addon(conference): + return AddOn.objects.create( + conference=conference, + name="T-Shirt", + slug="tshirt", + price=Decimal("25.00"), + bulk_enabled=True, + ) + + +@pytest.fixture +def client_logged_in_super(superuser): + c = Client() + c.login(username="bpadmin", password="password") + return c + + +def _list_url(conference): + return reverse("manage:bulk-purchase-list", kwargs={"conference_slug": conference.slug}) + + +def _detail_url(conference, pk): + return reverse("manage:bulk-purchase-detail", kwargs={"conference_slug": conference.slug, "pk": pk}) + + +def _add_url(conference): + return reverse("manage:bulk-purchase-add", kwargs={"conference_slug": conference.slug}) + + +def _approve_url(conference, pk): + return reverse("manage:bulk-purchase-approve", kwargs={"conference_slug": conference.slug, "pk": pk}) + + +def _fulfill_url(conference, pk): + return reverse("manage:bulk-purchase-fulfill", kwargs={"conference_slug": conference.slug, "pk": pk}) + + +def make_bp(conference, sponsor, **overrides) -> BulkPurchase: + defaults: dict[str, object] = { + "conference": conference, + "sponsor": sponsor, + "quantity": 5, + "unit_price": Decimal("50.00"), + "total_amount": Decimal("250.00"), + "payment_status": BulkPurchase.PaymentStatus.PENDING, + "voucher_config": {}, + } + defaults.update(overrides) + return BulkPurchase.objects.create(**defaults) + + +# --------------------------------------------------------------------------- +# BulkPurchaseListView +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBulkPurchaseListView: + def test_list_context_has_nav_and_status_choices(self, client_logged_in_super, conference, sponsor): + make_bp(conference, sponsor) + resp = client_logged_in_super.get(_list_url(conference)) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "bulk-purchases" + assert resp.context["status_choices"] == BulkPurchase.PaymentStatus.choices + assert resp.context["current_status"] == "" + + def test_list_filters_by_valid_status(self, client_logged_in_super, conference, sponsor): + pending = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PENDING) + approved = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.APPROVED) + resp = client_logged_in_super.get(_list_url(conference), {"status": "approved"}) + results = list(resp.context["bulk_purchases"]) + assert approved in results + assert pending not in results + assert resp.context["current_status"] == "approved" + + def test_list_ignores_invalid_status(self, client_logged_in_super, conference, sponsor): + pending = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PENDING) + resp = client_logged_in_super.get(_list_url(conference), {"status": "not-a-real-status"}) + results = list(resp.context["bulk_purchases"]) + assert pending in results + + +# --------------------------------------------------------------------------- +# BulkPurchaseDetailView +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBulkPurchaseDetailView: + def test_detail_shows_voucher_links_and_nav(self, client_logged_in_super, conference, sponsor): + from django_program.registration.models import Voucher + + bp = make_bp(conference, sponsor) + voucher = Voucher.objects.create( + conference=conference, + code="BULK-TEST-1", + voucher_type=Voucher.VoucherType.COMP, + discount_value=Decimal("0.00"), + max_uses=1, + ) + link = BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher) + + resp = client_logged_in_super.get(_detail_url(conference, bp.pk)) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "bulk-purchases" + assert link in list(resp.context["voucher_links"]) + assert resp.context["purchase"] == bp + + +# --------------------------------------------------------------------------- +# BulkPurchaseCreateView +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBulkPurchaseCreateView: + def test_get_create_form_context(self, client_logged_in_super, conference): + resp = client_logged_in_super.get(_add_url(conference)) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "bulk-purchases" + assert resp.context["is_create"] is True + + def test_get_scopes_field_querysets_to_conference( + self, client_logged_in_super, conference, sponsor, ticket_type, addon + ): + other_conf = Conference.objects.create( + name="Other Conf", + slug="other-conf", + start_date=date(2027, 7, 1), + end_date=date(2027, 7, 3), + timezone="UTC", + is_active=True, + ) + other_level = SponsorLevel.objects.create(conference=other_conf, name="Silver", cost=Decimal("1000.00")) + other_sponsor = Sponsor.objects.create(conference=other_conf, level=other_level, name="Other Corp") + other_ticket_type = TicketType.objects.create( + conference=other_conf, + name="Other General", + slug="other-general", + price=Decimal("10.00"), + bulk_enabled=True, + ) + other_addon = AddOn.objects.create( + conference=other_conf, name="Other Addon", slug="other-addon", price=Decimal("5.00"), bulk_enabled=True + ) + + resp = client_logged_in_super.get(_add_url(conference)) + form = resp.context["form"] + sponsor_qs = list(form.fields["sponsor"].queryset) + ticket_type_qs = list(form.fields["ticket_type"].queryset) + addon_qs = list(form.fields["addon"].queryset) + + assert sponsor in sponsor_qs + assert other_sponsor not in sponsor_qs + assert ticket_type in ticket_type_qs + assert other_ticket_type not in ticket_type_qs + assert addon in addon_qs + assert other_addon not in addon_qs + + def test_post_creates_bulk_purchase_and_redirects(self, client_logged_in_super, conference, sponsor, ticket_type): + resp = client_logged_in_super.post( + _add_url(conference), + { + "product_description": "Gold Sponsor Comp Tickets", + "ticket_type": ticket_type.pk, + "addon": "", + "quantity": 10, + "unit_price": "0.00", + "sponsor": sponsor.pk, + "voucher_type": "comp", + "discount_value": "0", + "max_uses_per_voucher": 1, + "valid_from": "", + "valid_until": "", + }, + ) + assert resp.status_code == 302 + assert resp.url == reverse("manage:bulk-purchase-list", kwargs={"conference_slug": conference.slug}) + + bp = BulkPurchase.objects.get(product_description="Gold Sponsor Comp Tickets") + assert bp.conference == conference + assert bp.requested_by is not None + + +# --------------------------------------------------------------------------- +# BulkPurchaseApproveView +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBulkPurchaseApproveView: + def test_approve_pending_purchase_succeeds(self, client_logged_in_super, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PENDING) + resp = client_logged_in_super.post(_approve_url(conference, bp.pk)) + assert resp.status_code == 302 + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.APPROVED + assert bp.approved_by is not None + + def test_approve_non_pending_purchase_errors(self, client_logged_in_super, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.APPROVED) + resp = client_logged_in_super.post(_approve_url(conference, bp.pk)) + assert resp.status_code == 302 + assert resp.url == _detail_url(conference, bp.pk) + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.APPROVED + + +# --------------------------------------------------------------------------- +# BulkPurchaseFulfillView +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBulkPurchaseFulfillView: + def test_fulfill_wrong_status_errors(self, client_logged_in_super, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PENDING) + resp = client_logged_in_super.post(_fulfill_url(conference, bp.pk)) + assert resp.status_code == 302 + assert resp.url == _detail_url(conference, bp.pk) + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.PENDING + + def test_fulfill_success_generates_vouchers(self, client_logged_in_super, conference, sponsor): + bp = make_bp( + conference, + sponsor, + quantity=3, + unit_price=Decimal("0.00"), + total_amount=Decimal("0.00"), + payment_status=BulkPurchase.PaymentStatus.APPROVED, + voucher_config={ + "voucher_type": "comp", + "discount_value": "0", + "max_uses": 1, + }, + ) + resp = client_logged_in_super.post(_fulfill_url(conference, bp.pk)) + assert resp.status_code == 302 + assert resp.url == _detail_url(conference, bp.pk) + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.PAID + assert bp.vouchers_generated == 3 + + def test_fulfill_already_fulfilled_shows_warning(self, client_logged_in_super, conference, sponsor): + bp = make_bp( + conference, + sponsor, + quantity=0, + payment_status=BulkPurchase.PaymentStatus.PAID, + voucher_config={"voucher_type": "comp", "discount_value": "0"}, + ) + resp = client_logged_in_super.post(_fulfill_url(conference, bp.pk)) + assert resp.status_code == 302 + detail_resp = client_logged_in_super.get(resp.url) + messages = [str(m) for m in detail_resp.context["messages"]] + assert any("already been fulfilled" in m for m in messages) + + def test_fulfill_service_error_shows_message(self, client_logged_in_super, conference, sponsor): + bp = make_bp( + conference, + sponsor, + unit_price=Decimal("50.00"), + total_amount=Decimal("250.00"), + payment_status=BulkPurchase.PaymentStatus.APPROVED, + voucher_config={}, + ) + resp = client_logged_in_super.post(_fulfill_url(conference, bp.pk)) + assert resp.status_code == 302 + detail_resp = client_logged_in_super.get(resp.url) + messages = [str(m) for m in detail_resp.context["messages"]] + assert any("payment must be completed first" in m for m in messages) + + def test_fulfill_unexpected_exception_shows_generic_error(self, client_logged_in_super, conference, sponsor): + bp = make_bp( + conference, + sponsor, + payment_status=BulkPurchase.PaymentStatus.APPROVED, + unit_price=Decimal("0.00"), + total_amount=Decimal("0.00"), + voucher_config={"voucher_type": "comp", "discount_value": "0"}, + ) + with patch( + "django_program.manage.views_bulk_purchases.BulkPurchaseService.fulfill_bulk_purchase", + side_effect=RuntimeError("boom"), + ): + resp = client_logged_in_super.post(_fulfill_url(conference, bp.pk)) + assert resp.status_code == 302 + detail_resp = client_logged_in_super.get(resp.url) + messages = [str(m) for m in detail_resp.context["messages"]] + assert any("Voucher generation failed" in m for m in messages) + + +# --------------------------------------------------------------------------- +# BulkPurchaseCreateForm +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBulkPurchaseCreateForm: + def _base_data(self, ticket_type, sponsor): + return { + "product_description": "Deal", + "ticket_type": ticket_type.pk, + "addon": "", + "quantity": 5, + "unit_price": "20.00", + "sponsor": sponsor.pk, + "voucher_type": "percentage", + "discount_value": "15.00", + "max_uses_per_voucher": 2, + "valid_from": "", + "valid_until": "", + } + + def test_clean_quantity_over_limit_raises(self, ticket_type, sponsor): + data = self._base_data(ticket_type, sponsor) + data["quantity"] = 501 + form = BulkPurchaseCreateForm(data=data) + assert not form.is_valid() + assert "quantity" in form.errors + assert "Cannot generate more than 500" in str(form.errors["quantity"]) + + def test_clean_computes_total_amount(self, ticket_type, sponsor): + data = self._base_data(ticket_type, sponsor) + form = BulkPurchaseCreateForm(data=data) + assert form.is_valid(), form.errors + assert form.cleaned_data["total_amount"] == Decimal("100.00") + + def test_save_populates_voucher_config_and_total(self, conference, ticket_type, sponsor): + data = self._base_data(ticket_type, sponsor) + data["valid_from"] = "2027-01-01T00:00" + data["valid_until"] = "2027-06-01T00:00" + form = BulkPurchaseCreateForm(data=data) + assert form.is_valid(), form.errors + instance = form.save(commit=False) + instance.conference = conference + instance.save() + + assert instance.total_amount == Decimal("100.00") + assert instance.voucher_config["voucher_type"] == "percentage" + assert instance.voucher_config["discount_value"] == "15.00" + assert instance.voucher_config["max_uses"] == 2 + assert instance.voucher_config["valid_from"] is not None + assert instance.voucher_config["valid_until"] is not None + + def test_save_without_validity_window_stores_none(self, conference, ticket_type, sponsor): + data = self._base_data(ticket_type, sponsor) + form = BulkPurchaseCreateForm(data=data) + assert form.is_valid(), form.errors + instance = form.save(commit=False) + instance.conference = conference + instance.save() + + assert instance.voucher_config["valid_from"] is None + assert instance.voucher_config["valid_until"] is None diff --git a/tests/test_manage/test_letters_financial_coverage.py b/tests/test_manage/test_letters_financial_coverage.py new file mode 100644 index 0000000..0ac5097 --- /dev/null +++ b/tests/test_manage/test_letters_financial_coverage.py @@ -0,0 +1,371 @@ +"""Coverage tests for the staff letter-review workflow and financial dashboard budget/PO branches.""" + +from datetime import date +from decimal import Decimal +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User +from django.test import Client +from django.urls import reverse + +from django_program.conference.models import Conference +from django_program.programs.models import TravelGrant +from django_program.registration.attendee import Attendee +from django_program.registration.letter import LetterRequest +from django_program.registration.models import TicketType +from django_program.registration.purchase_order import PurchaseOrderPayment +from django_program.registration.services.purchase_orders import create_purchase_order + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def superuser(db): + return User.objects.create_superuser(username="lfadmin", password="password", email="lfadmin@test.com") + + +@pytest.fixture +def requester(db): + return User.objects.create_user( + username="requester", password="password", email="requester@test.com", first_name="Jane", last_name="Doe" + ) + + +@pytest.fixture +def client_logged_in_super(superuser): + c = Client() + c.login(username="lfadmin", password="password") + return c + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="LF Test Conf", + slug="lf-test-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + is_active=True, + venue="Convention Center", + address="123 Main St, Portland, OR", + ) + + +@pytest.fixture +def letter_request(conference, requester): + return LetterRequest.objects.create( + conference=conference, + user=requester, + passport_name="Jane Doe", + passport_number="AB1234567", + nationality="United States", + date_of_birth=date(1990, 6, 15), + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 5), + destination_address="456 Hotel Ave, Portland, OR 97201", + embassy_name="US Embassy Berlin", + status=LetterRequest.Status.SUBMITTED, + ) + + +def _review_url(conference, lr): + return reverse("manage:letter-review", kwargs={"conference_slug": conference.slug, "pk": lr.pk}) + + +def _generate_url(conference, lr): + return reverse("manage:letter-generate", kwargs={"conference_slug": conference.slug, "pk": lr.pk}) + + +def _bulk_generate_url(conference): + return reverse("manage:letter-bulk-generate", kwargs={"conference_slug": conference.slug}) + + +def _send_url(conference, lr): + return reverse("manage:letter-send", kwargs={"conference_slug": conference.slug, "pk": lr.pk}) + + +def _list_url(conference): + return reverse("manage:letter-list", kwargs={"conference_slug": conference.slug}) + + +# --------------------------------------------------------------------------- +# LetterRequestReviewView: unrecognized action (views_letters.py 154-155) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestLetterRequestReviewUnrecognizedAction: + def test_unknown_action_shows_error_and_leaves_status_unchanged( + self, client_logged_in_super, conference, letter_request + ): + resp = client_logged_in_super.post(_review_url(conference, letter_request), {"action": "teleport"}) + assert resp.status_code == 302 + assert resp.url == _review_url(conference, letter_request) + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.SUBMITTED + + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("Invalid action or status transition." in m for m in messages) + + +# --------------------------------------------------------------------------- +# LetterRequestGenerateView: exception path (views_letters.py 225-227) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestLetterRequestGenerateViewException: + def test_generate_exception_shows_error_message(self, client_logged_in_super, conference, letter_request): + letter_request.status = LetterRequest.Status.APPROVED + letter_request.save(update_fields=["status"]) + + with patch( + "django_program.manage.views_letters.generate_invitation_letter", + side_effect=RuntimeError("pdf engine exploded"), + ): + resp = client_logged_in_super.post(_generate_url(conference, letter_request)) + + assert resp.status_code == 302 + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.APPROVED + assert not letter_request.generated_pdf + + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("Failed to generate the invitation letter PDF." in m for m in messages) + + +# --------------------------------------------------------------------------- +# LetterRequestBulkGenerateView (views_letters.py 253-271) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestLetterRequestBulkGenerateView: + def _make_approved(self, conference, email, **overrides): + user = User.objects.create_user(username=email, password="password", email=f"{email}@test.com") + defaults = { + "conference": conference, + "user": user, + "passport_name": "Traveler", + "passport_number": "X1", + "nationality": "Testland", + "travel_from": date(2027, 4, 28), + "travel_until": date(2027, 5, 5), + "destination_address": "456 Hotel Ave", + "status": LetterRequest.Status.APPROVED, + } + defaults.update(overrides) + return LetterRequest.objects.create(**defaults) + + def test_bulk_generate_success_for_all_approved(self, client_logged_in_super, conference): + lr = self._make_approved(conference, "approved1") + resp = client_logged_in_super.post(_bulk_generate_url(conference)) + assert resp.status_code == 302 + assert resp.url == _list_url(conference) + lr.refresh_from_db() + assert lr.status == LetterRequest.Status.GENERATED + assert lr.generated_pdf + + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("Generated 1 invitation letter(s)." in m for m in messages) + + def test_bulk_generate_reports_failures(self, client_logged_in_super, conference): + self._make_approved(conference, "approved2") + + with patch( + "django_program.manage.views_letters.generate_invitation_letter", + side_effect=RuntimeError("boom"), + ): + resp = client_logged_in_super.post(_bulk_generate_url(conference)) + + assert resp.status_code == 302 + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("Failed to generate 1 letter(s)." in m for m in messages) + + def test_bulk_generate_with_no_approved_requests(self, client_logged_in_super, conference): + resp = client_logged_in_super.post(_bulk_generate_url(conference)) + assert resp.status_code == 302 + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("No approved letter requests to generate." in m for m in messages) + + +# --------------------------------------------------------------------------- +# LetterRequestSendView (views_letters.py 294-311) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestLetterRequestSendView: + def test_send_success(self, client_logged_in_super, conference, letter_request): + letter_request.status = LetterRequest.Status.GENERATED + letter_request.save(update_fields=["status"]) + + with patch("django_program.manage.views_letters.send_invitation_letter") as mock_send: + resp = client_logged_in_super.post(_send_url(conference, letter_request)) + + mock_send.assert_called_once_with(letter_request) + assert resp.status_code == 302 + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("Invitation letter sent successfully." in m for m in messages) + + def test_send_rejects_non_generated_status(self, client_logged_in_super, conference, letter_request): + resp = client_logged_in_super.post(_send_url(conference, letter_request)) + assert resp.status_code == 302 + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("Only generated letters can be sent." in m for m in messages) + + def test_send_exception_shows_error(self, client_logged_in_super, conference, letter_request): + letter_request.status = LetterRequest.Status.GENERATED + letter_request.save(update_fields=["status"]) + + with patch( + "django_program.manage.views_letters.send_invitation_letter", + side_effect=RuntimeError("smtp down"), + ): + resp = client_logged_in_super.post(_send_url(conference, letter_request)) + + assert resp.status_code == 302 + follow = client_logged_in_super.get(resp.url) + messages = [str(m) for m in follow.context["messages"]] + assert any("Failed to send the invitation letter." in m for m in messages) + + +# --------------------------------------------------------------------------- +# FinancialDashboardView budget branches (views_financial.py 184-217, 403-404) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def budgeted_conference(db): + return Conference.objects.create( + name="Budgeted Fin Conf", + slug="budgeted-fin-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + is_active=True, + revenue_budget="10000.00", + target_attendance=100, + grant_budget="5000.00", + ) + + +def _dashboard_url(conference): + return reverse("manage:financial-dashboard", kwargs={"conference_slug": conference.slug}) + + +@pytest.mark.django_db +class TestFinancialDashboardBudgetBranches: + def test_budget_fields_populate_context(self, client_logged_in_super, budgeted_conference, superuser): + from django_program.registration.models import Order + + second_user = User.objects.create_user(username="second", password="password", email="second@test.com") + + Order.objects.create( + conference=budgeted_conference, + user=superuser, + status=Order.Status.PAID, + reference="ORD-FIN-BUDGET-1", + total="2500.00", + ) + Attendee.objects.create(user=superuser, conference=budgeted_conference) + TravelGrant.objects.create( + conference=budgeted_conference, + user=superuser, + status=TravelGrant.GrantStatus.ACCEPTED, + requested_amount="500.00", + approved_amount="400.00", + ) + TravelGrant.objects.create( + conference=budgeted_conference, + user=second_user, + status=TravelGrant.GrantStatus.DISBURSED, + requested_amount="300.00", + approved_amount="300.00", + disbursed_amount="300.00", + ) + + resp = client_logged_in_super.get(_dashboard_url(budgeted_conference)) + assert resp.status_code == 200 + + budget = resp.context["budget"] + assert budget["revenue_target"] == Decimal("10000.00") + assert budget["revenue_actual"] == Decimal("2500.00") + assert budget["revenue_pct"] == 25.0 + assert budget["attendance_target"] == 100 + assert budget["attendance_actual"] == 1 + assert budget["attendance_pct"] == 1.0 + assert budget["grant_target"] == Decimal("5000.00") + assert budget["grant_committed"] == Decimal("400.00") + assert budget["grant_disbursed"] == Decimal("300.00") + assert budget["grant_pct"] == 8.0 + assert "chart_budget_json" in resp.context + + def test_no_budget_fields_set_omits_budget_context(self, client_logged_in_super, conference): + resp = client_logged_in_super.get(_dashboard_url(conference)) + assert resp.status_code == 200 + assert "budget" not in resp.context + assert "chart_budget_json" not in resp.context + + +# --------------------------------------------------------------------------- +# FinancialDashboardView purchase order branches (views_financial.py 441, 492) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestFinancialDashboardPurchaseOrderBreakdown: + def test_purchase_order_status_and_payment_method_breakdowns(self, client_logged_in_super, conference, superuser): + ticket_type = TicketType.objects.create( + conference=conference, + name="Corporate", + slug="corporate", + price=Decimal("250.00"), + ) + po = create_purchase_order( + conference=conference, + organization_name="Acme Corp", + contact_email="billing@acme.com", + contact_name="Jane Doe", + billing_address="123 Corporate Ave", + line_items=[ + { + "description": "Corporate Ticket", + "quantity": 2, + "unit_price": Decimal("250.00"), + "ticket_type": ticket_type, + }, + ], + notes="Test PO", + created_by=superuser, + ) + PurchaseOrderPayment.objects.create( + purchase_order=po, + amount=Decimal("500.00"), + method=PurchaseOrderPayment.Method.WIRE, + payment_date=date(2027, 4, 1), + entered_by=superuser, + ) + + resp = client_logged_in_super.get(_dashboard_url(conference)) + assert resp.status_code == 200 + + po_by_status = resp.context["po_by_status"] + assert po_by_status[po.status]["count"] == 1 + assert po_by_status[po.status]["total"] == po.total + + po_payments_by_method = resp.context["po_payments_by_method"] + assert po_payments_by_method[PurchaseOrderPayment.Method.WIRE]["count"] == 1 + assert po_payments_by_method[PurchaseOrderPayment.Method.WIRE]["total"] == Decimal("500.00") + assert resp.context["po_collected"] == Decimal("500.00") diff --git a/tests/test_manage/test_reports_analytics_coverage.py b/tests/test_manage/test_reports_analytics_coverage.py new file mode 100644 index 0000000..07bdc8c --- /dev/null +++ b/tests/test_manage/test_reports_analytics_coverage.py @@ -0,0 +1,413 @@ +"""Coverage tests for django_program.manage.reports_analytics. + +Exercises report functions whose non-empty / data-bearing branches are not +hit by the existing test_analytics_reports.py suite: expense summary, +event ROI, registration attribution, session rating analytics, NPS score, +attendee lifetime value, sponsor renewal rate, speaker return rate, and the +int-valued zero-division branch in year-over-year growth. +""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.contrib.auth.models import User + +from django_program.conference.models import Conference, Expense, ExpenseCategory +from django_program.manage.reports_analytics import ( + get_attendee_lifetime_value, + get_event_roi, + get_expense_summary, + get_nps_score, + get_registration_attribution, + get_session_rating_analytics, + get_speaker_return_rate, + get_sponsor_renewal_rate, + get_yoy_growth, +) +from django_program.pretalx.models import SessionRating, Speaker, Talk +from django_program.programs.models import Survey, SurveyResponse +from django_program.registration.models import Attendee, Order +from django_program.sponsors.models import Sponsor, SponsorLevel + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="Test Conf", + slug="test-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + is_active=True, + ) + + +@pytest.fixture +def previous_conference(db): + return Conference.objects.create( + name="Previous Conf", + slug="prev-conf", + start_date=date(2026, 5, 1), + end_date=date(2026, 5, 3), + timezone="UTC", + is_active=False, + ) + + +@pytest.fixture +def user1(db): + return User.objects.create_user(username="u1", password="p", email="u1@test.com") + + +@pytest.fixture +def user2(db): + return User.objects.create_user(username="u2", password="p", email="u2@test.com") + + +# --------------------------------------------------------------------------- +# get_expense_summary +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExpenseSummary: + def test_categories_with_and_without_budget(self, conference): + budgeted = ExpenseCategory.objects.create( + conference=conference, + name="Venue", + slug="venue", + budget_amount=Decimal("1000.00"), + ) + unbudgeted = ExpenseCategory.objects.create( + conference=conference, + name="Swag", + slug="swag", + ) + Expense.objects.create( + conference=conference, + category=budgeted, + description="Hall rental", + amount=Decimal("800.00"), + date=date(2027, 4, 1), + ) + Expense.objects.create( + conference=conference, + category=unbudgeted, + description="T-shirts", + amount=Decimal("200.00"), + date=date(2027, 4, 2), + ) + + result = get_expense_summary(conference) + + assert result["total_expenses"] == Decimal("1000.00") + assert result["total_budget"] == Decimal("1000.00") + assert result["budget_variance"] == Decimal("0.00") + assert result["expense_count"] == 2 + by_name = {row["name"]: row for row in result["by_category"]} + assert by_name["Venue"]["budget"] == Decimal("1000.00") + assert by_name["Venue"]["variance"] == Decimal("200.00") + assert by_name["Swag"]["budget"] is None + assert by_name["Swag"]["variance"] is None + + +# --------------------------------------------------------------------------- +# get_event_roi +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestEventROI: + def test_computes_profit_and_per_attendee_metrics(self, conference, user1, user2): + Order.objects.create( + conference=conference, + user=user1, + status=Order.Status.PAID, + total=Decimal("1000.00"), + subtotal=Decimal("1000.00"), + reference="ORD-ROI-1", + ) + category = ExpenseCategory.objects.create(conference=conference, name="AV", slug="av") + Expense.objects.create( + conference=conference, + category=category, + description="Projector", + amount=Decimal("500.00"), + date=date(2027, 4, 1), + ) + Attendee.objects.create(conference=conference, user=user1) + Attendee.objects.create(conference=conference, user=user2) + + result = get_event_roi(conference) + + assert result["total_revenue"] == Decimal("1000.00") + assert result["total_expenses"] == Decimal("500.00") + assert result["gross_profit"] == Decimal("500.00") + assert result["gross_margin_pct"] == Decimal("50.00") + assert result["net_profit"] == Decimal("500.00") + assert result["roi_pct"] == Decimal("100.00") + assert result["cost_per_attendee"] == Decimal("250.00") + assert result["revenue_per_attendee"] == Decimal("500.00") + + +# --------------------------------------------------------------------------- +# get_registration_attribution +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestRegistrationAttribution: + def test_groups_paid_orders_by_utm_fields(self, conference, user1, user2): + Order.objects.create( + conference=conference, + user=user1, + status=Order.Status.PAID, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-ATTR-1", + utm_source="newsletter", + utm_medium="email", + utm_campaign="launch", + ) + Order.objects.create( + conference=conference, + user=user2, + status=Order.Status.PAID, + total=Decimal("50.00"), + subtotal=Decimal("50.00"), + reference="ORD-ATTR-2", + ) + + result = get_registration_attribution(conference) + + assert result["total_attributed"] == 1 + assert result["total_unattributed"] == 1 + assert result["by_source"] == [{"name": "newsletter", "count": 1, "revenue": Decimal("100.00")}] + assert result["by_medium"] == [{"name": "email", "count": 1, "revenue": Decimal("100.00")}] + assert result["by_campaign"] == [{"name": "launch", "count": 1, "revenue": Decimal("100.00")}] + + +# --------------------------------------------------------------------------- +# get_session_rating_analytics +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestSessionRatingAnalytics: + def test_computes_distribution_rankings_and_track_breakdown(self, conference, user1, user2): + rater3 = User.objects.create_user(username="u3", password="p", email="u3@test.com") + talk = Talk.objects.create( + conference=conference, + pretalx_code="T1", + title="Great Talk", + track="Web", + ) + + SessionRating.objects.create(conference=conference, talk=talk, user=user1, score=5) + SessionRating.objects.create(conference=conference, talk=talk, user=user2, score=4) + SessionRating.objects.create(conference=conference, talk=talk, user=rater3, score=3) + + result = get_session_rating_analytics(conference) + + assert result["total_ratings"] == 3 + assert result["average_score"] == Decimal(4) + assert result["score_distribution"][5] == 1 + assert result["score_distribution"][4] == 1 + assert result["score_distribution"][3] == 1 + assert len(result["top_sessions"]) == 1 + assert result["top_sessions"][0]["talk_title"] == "Great Talk" + assert len(result["bottom_sessions"]) == 1 + assert len(result["by_track"]) == 1 + assert result["by_track"][0]["track"] == "Web" + + +# --------------------------------------------------------------------------- +# get_nps_score +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestNPSScore: + def test_explicit_slug_with_responses_computes_full_metrics(self, conference, user1, user2): + rater3 = User.objects.create_user(username="u3nps", password="p", email="u3nps@test.com") + rater4 = User.objects.create_user(username="u4nps", password="p", email="u4nps@test.com") + survey = Survey.objects.create( + conference=conference, + name="Post Event", + slug="post-event", + survey_type=Survey.SurveyType.SATISFACTION, + ) + SurveyResponse.objects.create(survey=survey, user=user1, score=9) + SurveyResponse.objects.create(survey=survey, user=user2, score=9) + SurveyResponse.objects.create(survey=survey, user=rater3, score=7) + SurveyResponse.objects.create(survey=survey, user=rater4, score=3) + Attendee.objects.create(conference=conference, user=user1) + Attendee.objects.create(conference=conference, user=user2) + + result = get_nps_score(conference, survey_slug="post-event") + + assert result["total_responses"] == 4 + assert result["promoters"] == 2 + assert result["passives"] == 1 + assert result["detractors"] == 1 + assert result["nps_score"] == 25 + assert result["response_rate"] == Decimal("200.00") + + def test_no_matching_survey_returns_empty(self, conference): + result = get_nps_score(conference) + + assert result["nps_score"] == 0 + assert result["total_responses"] == 0 + + def test_default_lookup_survey_with_no_responses_returns_empty(self, conference): + Survey.objects.create( + conference=conference, + name="NPS Survey", + slug="nps-survey", + survey_type=Survey.SurveyType.NPS, + ) + + result = get_nps_score(conference) + + assert result["total_responses"] == 0 + assert result["nps_score"] == 0 + + +# --------------------------------------------------------------------------- +# get_attendee_lifetime_value +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestAttendeeLifetimeValue: + def test_computes_ltv_across_conferences(self, conference, previous_conference, user1, user2): + Order.objects.create( + conference=conference, + user=user1, + status=Order.Status.PAID, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-LTV-1", + ) + Order.objects.create( + conference=previous_conference, + user=user1, + status=Order.Status.PAID, + total=Decimal("50.00"), + subtotal=Decimal("50.00"), + reference="ORD-LTV-2", + ) + Order.objects.create( + conference=conference, + user=user2, + status=Order.Status.PAID, + total=Decimal("200.00"), + subtotal=Decimal("200.00"), + reference="ORD-LTV-3", + ) + + result = get_attendee_lifetime_value(conference) + + assert result["total_users_with_orders"] == 2 + assert result["max_ltv"] == Decimal("200.00") + assert result["min_ltv"] == Decimal("150.00") + assert result["avg_ltv"] == Decimal("175.00") + usernames = {row["username"] for row in result["top_users"]} + assert usernames == {"u1", "u2"} + + +# --------------------------------------------------------------------------- +# get_sponsor_renewal_rate +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestSponsorRenewalRate: + def test_by_level_breakdown_with_returning_and_new_sponsors(self, conference, previous_conference): + prev_level = SponsorLevel.objects.create(conference=previous_conference, name="Gold", cost=Decimal("5000.00")) + Sponsor.objects.create(conference=previous_conference, level=prev_level, name="Acme Corp") + + level = SponsorLevel.objects.create(conference=conference, name="Gold", cost=Decimal("5000.00")) + Sponsor.objects.create(conference=conference, level=level, name="Acme Corp") + Sponsor.objects.create(conference=conference, level=level, name="New Sponsor Inc") + + result = get_sponsor_renewal_rate(conference) + + assert result["current_sponsor_count"] == 2 + assert result["returning_count"] == 1 + assert result["new_count"] == 1 + level_row = result["by_level"][0] + assert level_row["level_name"] == "Gold" + assert level_row["total"] == 2 + assert level_row["returning"] == 1 + assert level_row["new"] == 1 + + +# --------------------------------------------------------------------------- +# get_speaker_return_rate +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestSpeakerReturnRate: + def test_matches_returning_speakers_by_user_and_by_name_email(self, conference, previous_conference, user1): + Speaker.objects.create( + conference=previous_conference, + pretalx_code="PS1", + name="Returning By User", + user=user1, + ) + Speaker.objects.create( + conference=previous_conference, + pretalx_code="PS2", + name="Returning By Name", + email="byname@test.com", + ) + + Speaker.objects.create( + conference=conference, + pretalx_code="CS1", + name="Returning By User", + user=user1, + ) + Speaker.objects.create( + conference=conference, + pretalx_code="CS2", + name="Returning By Name", + email="byname@test.com", + ) + Speaker.objects.create( + conference=conference, + pretalx_code="CS3", + name="Brand New Speaker", + email="new@test.com", + ) + + result = get_speaker_return_rate(conference) + + assert result["current_speaker_count"] == 3 + assert result["returning_count"] == 2 + assert result["new_count"] == 1 + assert round(result["return_rate"], 2) == Decimal("66.67") + + +# --------------------------------------------------------------------------- +# get_yoy_growth +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestYoyGrowthIntZeroBranch: + def test_zero_previous_attendance_takes_int_zero_branch(self, conference, previous_conference): + """previous_conference has zero attendees: the non-Decimal 'attendance' + metric hits the falsy-prev_val branch (pct = _ZERO) instead of the + Decimal or nonzero-int division branches.""" + result = get_yoy_growth(conference) + + assert result["history"][0]["attendance"] == 0 + assert result["current"]["attendance_growth_pct"] == Decimal("0.00") diff --git a/tests/test_manage/test_reports_coverage.py b/tests/test_manage/test_reports_coverage.py new file mode 100644 index 0000000..7a3309b --- /dev/null +++ b/tests/test_manage/test_reports_coverage.py @@ -0,0 +1,427 @@ +"""Coverage tests for django_program.manage.reports and views_reports. + +Exercises branches and functions not hit by the existing +test_report_views.py suite: the invalid-ticket-type-id fallback, the +date_until upper-bound filters, sponsor revenue in the revenue breakdown, +early-bird/capacity branches in ticket sales ratio, check-in throughput +bucketing, schedule-slot-type and letter-status aggregation loops, the +reports dashboard's budget-vs-actuals context, and the visa letter CSV +export view. +""" + +from datetime import date, timedelta +from decimal import Decimal + +import pytest +from django.contrib.auth.models import Group, Permission, User +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from django_program.conference.models import Conference +from django_program.manage.reports import ( + get_aov_by_date, + get_attendee_manifest, + get_checkin_throughput, + get_content_analytics, + get_letter_request_summary, + get_revenue_breakdown, + get_revenue_by_ticket_type, + get_ticket_sales_ratio, +) +from django_program.pretalx.models import ScheduleSlot, Talk +from django_program.programs.models import TravelGrant +from django_program.registration.letter import LetterRequest +from django_program.registration.models import Attendee, Order, OrderLineItem, TicketType +from django_program.sponsors.models import Sponsor, SponsorLevel + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def superuser(db): + return User.objects.create_superuser(username="admin", password="password", email="admin@test.com") + + +@pytest.fixture +def reports_user(db): + """A user with view_reports and export_reports permissions via the Reports Viewer group.""" + user = User.objects.create_user(username="reporter", password="password", email="reporter@test.com") + group, _created = Group.objects.get_or_create(name="Reports Viewer") + for codename in ("view_reports", "export_reports"): + perm = Permission.objects.get(content_type__app_label="program_conference", codename=codename) + group.permissions.add(perm) + user.groups.add(group) + return user + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="Test Conf", + slug="test-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + is_active=True, + ) + + +@pytest.fixture +def budgeted_conference(db): + return Conference.objects.create( + name="Budgeted Conf", + slug="budgeted-conf", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + is_active=True, + revenue_budget=Decimal("10000.00"), + target_attendance=100, + grant_budget=Decimal("5000.00"), + ) + + +@pytest.fixture +def client_logged_in_super(superuser): + c = Client() + c.login(username="admin", password="password") + return c + + +@pytest.fixture +def client_logged_in_reports(reports_user): + c = Client() + c.login(username="reporter", password="password") + return c + + +def _url(name: str, conference: Conference) -> str: + return reverse(f"manage:{name}", kwargs={"conference_slug": conference.slug}) + + +# --------------------------------------------------------------------------- +# get_attendee_manifest — invalid ticket_type_id fallback +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestAttendeeManifestInvalidTicketTypeId: + def test_non_numeric_ticket_type_id_is_ignored(self, conference, superuser): + Attendee.objects.create(conference=conference, user=superuser) + + qs = get_attendee_manifest(conference, ticket_type_id="not-a-number") + + assert qs.count() == 1 + + +# --------------------------------------------------------------------------- +# get_aov_by_date / get_revenue_by_ticket_type — date_until upper bound +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestDateUntilFilters: + def test_aov_by_date_respects_date_until(self, conference, superuser): + Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-AOV-1", + ) + + yesterday = timezone.now().date() - timedelta(days=1) + tomorrow = timezone.now().date() + timedelta(days=1) + result = get_aov_by_date(conference, date_from=yesterday, date_until=tomorrow) + + assert len(result) == 1 + assert result[0]["count"] == 1 + + def test_revenue_by_ticket_type_respects_date_until(self, conference, superuser): + ticket = TicketType.objects.create( + conference=conference, + name="General", + slug="general", + price=Decimal("100.00"), + total_quantity=100, + ) + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("100.00"), + subtotal=Decimal("100.00"), + reference="ORD-RBT-1", + ) + OrderLineItem.objects.create( + order=order, + description="General", + quantity=1, + unit_price=Decimal("100.00"), + line_total=Decimal("100.00"), + ticket_type=ticket, + ) + + yesterday = timezone.now().date() - timedelta(days=1) + tomorrow = timezone.now().date() + timedelta(days=1) + result = get_revenue_by_ticket_type(conference, date_from=yesterday, date_until=tomorrow) + + assert len(result) == 1 + assert result[0]["ticket_type"] == "General" + assert result[0]["revenue"] == Decimal("100.00") + + +# --------------------------------------------------------------------------- +# get_revenue_breakdown — sponsor revenue loop +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestRevenueBreakdownSponsorRevenue: + def test_includes_sponsor_revenue_from_active_sponsors(self, conference): + level = SponsorLevel.objects.create(conference=conference, name="Gold", cost=Decimal("5000.00")) + Sponsor.objects.create(conference=conference, level=level, name="Acme Corp", is_active=True) + Sponsor.objects.create(conference=conference, level=level, name="Inactive Co", is_active=False) + + result = get_revenue_breakdown(conference) + + assert result["sponsor_revenue"] == Decimal("5000.00") + assert result["total_revenue"] == Decimal("5000.00") + assert result["sponsor_pct"] == Decimal("100.00") + + +# --------------------------------------------------------------------------- +# get_ticket_sales_ratio — early-bird window and capacity/fill-rate +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestTicketSalesRatio: + def test_early_bird_window_and_fill_rate(self, conference, superuser): + ticket = TicketType.objects.create( + conference=conference, + name="Early Bird", + slug="early-bird", + price=Decimal("80.00"), + total_quantity=10, + available_from=timezone.now() - timedelta(days=5), + available_until=timezone.now() + timedelta(days=5), + ) + order = Order.objects.create( + conference=conference, + user=superuser, + status=Order.Status.PAID, + total=Decimal("80.00"), + subtotal=Decimal("80.00"), + reference="ORD-TSR-1", + ) + OrderLineItem.objects.create( + order=order, + description="Early Bird", + quantity=1, + unit_price=Decimal("80.00"), + line_total=Decimal("80.00"), + ticket_type=ticket, + ) + + result = get_ticket_sales_ratio(conference) + + assert len(result) == 1 + row = result[0] + assert row["is_early_bird"] is True + assert row["capacity"] == 10 + assert row["fill_rate"] == Decimal(10) + assert row["revenue_pct"] == Decimal("100.00") + + +# --------------------------------------------------------------------------- +# get_checkin_throughput — time-bucketed check-ins +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestCheckinThroughput: + def test_buckets_checkin_timestamps(self, conference, superuser): + Attendee.objects.create( + conference=conference, + user=superuser, + checked_in_at=timezone.now(), + ) + + result = get_checkin_throughput(conference, bucket_minutes=15) + + assert len(result) == 1 + assert result[0]["count"] == 1 + assert result[0]["bucket_end"] - result[0]["bucket_start"] == timedelta(minutes=15) + + +# --------------------------------------------------------------------------- +# get_content_analytics — slot_types loop +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestContentAnalyticsSlotTypes: + def test_counts_schedule_slots_by_type(self, conference): + talk = Talk.objects.create(conference=conference, pretalx_code="T1", title="Talk 1") + now = timezone.now() + ScheduleSlot.objects.create( + conference=conference, + talk=talk, + start=now, + end=now + timedelta(minutes=30), + slot_type=ScheduleSlot.SlotType.TALK, + ) + ScheduleSlot.objects.create( + conference=conference, + start=now + timedelta(hours=1), + end=now + timedelta(hours=1, minutes=15), + slot_type=ScheduleSlot.SlotType.BREAK, + ) + + result = get_content_analytics(conference) + + assert result["slot_types"]["talk"] == 1 + assert result["slot_types"]["break"] == 1 + assert result["total_schedule_slots"] == 2 + + +# --------------------------------------------------------------------------- +# get_letter_request_summary (reports.py) — by_status loop +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestLetterRequestSummaryByStatus: + def test_counts_by_status(self, conference, superuser): + LetterRequest.objects.create( + conference=conference, + user=superuser, + passport_name="Jane Doe", + passport_number="X123456", + nationality="Canadian", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 4), + destination_address="123 Main St", + status=LetterRequest.Status.SUBMITTED, + ) + + result = get_letter_request_summary(conference) + + assert result["total"] == 1 + assert result["by_status"]["submitted"] == 1 + assert result["pending_count"] == 1 + + +# --------------------------------------------------------------------------- +# ReportsDashboardView — budget-vs-actuals context (views_reports.py) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestReportsDashboardBudgetContext: + def test_budget_fields_populate_context(self, client_logged_in_super, budgeted_conference, superuser): + second_user = User.objects.create_user(username="second", password="password", email="second@test.com") + + Order.objects.create( + conference=budgeted_conference, + user=superuser, + status=Order.Status.PAID, + reference="ORD-DASH-BUDGET-1", + total=Decimal("2500.00"), + subtotal=Decimal("2500.00"), + ) + Attendee.objects.create(user=superuser, conference=budgeted_conference) + TravelGrant.objects.create( + conference=budgeted_conference, + user=superuser, + status=TravelGrant.GrantStatus.ACCEPTED, + requested_amount=Decimal("500.00"), + approved_amount=Decimal("400.00"), + ) + TravelGrant.objects.create( + conference=budgeted_conference, + user=second_user, + status=TravelGrant.GrantStatus.DISBURSED, + requested_amount=Decimal("300.00"), + approved_amount=Decimal("300.00"), + disbursed_amount=Decimal("300.00"), + ) + + resp = client_logged_in_super.get(_url("reports-dashboard", budgeted_conference)) + + assert resp.status_code == 200 + budget = resp.context["budget"] + assert budget["revenue_target"] == Decimal("10000.00") + assert budget["revenue_actual"] == Decimal("2500.00") + assert budget["attendance_target"] == 100 + assert budget["attendance_actual"] == 1 + assert budget["grant_target"] == Decimal("5000.00") + assert budget["grant_committed"] == Decimal("400.00") + assert budget["grant_disbursed"] == Decimal("300.00") + assert "chart_budget_json" in resp.context + + +# --------------------------------------------------------------------------- +# VisaLetterExportView — CSV export +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestVisaLetterExportView: + def test_exports_csv_with_reviewer_info(self, client_logged_in_reports, conference, reports_user, superuser): + LetterRequest.objects.create( + conference=conference, + user=superuser, + passport_name="Jane Doe", + passport_number="X123456", + nationality="Canadian", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 4), + destination_address="123 Main St", + embassy_name="Canadian Embassy", + status=LetterRequest.Status.APPROVED, + reviewed_by=reports_user, + reviewed_at=timezone.now(), + ) + + resp = client_logged_in_reports.get(_url("report-visa-letters-export", conference)) + + assert resp.status_code == 200 + assert resp["Content-Type"] == "text/csv" + content = resp.content.decode() + assert "Jane Doe" in content + assert "Canadian" in content + assert "reporter" in content or reports_user.get_full_name() in content + + +# --------------------------------------------------------------------------- +# VisaLetterReportView — dashboard context +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestVisaLetterReportView: + def test_builds_summary_and_chart_context(self, client_logged_in_reports, conference, superuser): + LetterRequest.objects.create( + conference=conference, + user=superuser, + passport_name="Jane Doe", + passport_number="X123456", + nationality="Canadian", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 4), + destination_address="123 Main St", + status=LetterRequest.Status.SUBMITTED, + ) + + resp = client_logged_in_reports.get(_url("report-visa-letters", conference)) + + assert resp.status_code == 200 + assert resp.context["letter_summary"]["total"] == 1 + assert next(iter(resp.context["letter_requests"])).passport_name == "Jane Doe" + assert "chart_data" in resp.context diff --git a/tests/test_manage/test_views_coverage.py b/tests/test_manage/test_views_coverage.py new file mode 100644 index 0000000..f9da84a --- /dev/null +++ b/tests/test_manage/test_views_coverage.py @@ -0,0 +1,465 @@ +"""Coverage backfill tests for django_program.manage.views / views_analytics / forms. + +Targets the dashboard budget helper, ConferenceEditView's invalid-form branch, +TravelGrantManageListView's budget tracking block, the expense management CRUD +views (previously untested), and the analytics JSON-serialization helper. +""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.contrib.auth.models import User +from django.test import Client +from django.urls import reverse + +from django_program.conference.models import Conference, Expense, ExpenseCategory +from django_program.manage.views_analytics import _decimal_to_float +from django_program.programs.models import TravelGrant +from django_program.registration.models import Order + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def superuser(db): + return User.objects.create_superuser(username="admin", password="password", email="admin@test.com") + + +@pytest.fixture +def regular_user(db): + return User.objects.create_user(username="regular", password="password", email="regular@test.com") + + +@pytest.fixture +def second_user(db): + return User.objects.create_user(username="second", password="password", email="second@test.com") + + +@pytest.fixture +def client_logged_in_super(superuser): + c = Client() + c.login(username="admin", password="password") + return c + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="Test Conf", + slug="test-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + pretalx_event_slug="test-event", + is_active=True, + ) + + +@pytest.fixture +def budgeted_conference(db): + """A conference with all three budget fields set, for dashboard budget coverage.""" + return Conference.objects.create( + name="Budgeted Conf", + slug="budgeted-conf", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + pretalx_event_slug="budgeted-event", + is_active=True, + revenue_budget=Decimal("10000.00"), + target_attendance=100, + grant_budget=Decimal("5000.00"), + ) + + +@pytest.fixture +def expense_category(conference): + return ExpenseCategory.objects.create( + conference=conference, + name="Venue", + slug="venue", + budget_amount="1000.00", + order=1, + ) + + +# --------------------------------------------------------------------------- +# _build_dashboard_budget_context / DashboardView (views.py 745-777, 833-834) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestDashboardBudgetContext: + def test_dashboard_includes_budget_when_fields_set( + self, client_logged_in_super, budgeted_conference, superuser, second_user + ): + from django_program.registration.attendee import Attendee + + Order.objects.create( + conference=budgeted_conference, + user=superuser, + status=Order.Status.PAID, + reference="ORD-BUDGET-1", + total="2500.00", + ) + Attendee.objects.create(user=superuser, conference=budgeted_conference) + TravelGrant.objects.create( + conference=budgeted_conference, + user=superuser, + status=TravelGrant.GrantStatus.ACCEPTED, + requested_amount="500.00", + approved_amount="400.00", + ) + TravelGrant.objects.create( + conference=budgeted_conference, + user=second_user, + status=TravelGrant.GrantStatus.DISBURSED, + requested_amount="300.00", + approved_amount="300.00", + disbursed_amount="300.00", + ) + + url = reverse("manage:dashboard", kwargs={"conference_slug": budgeted_conference.slug}) + resp = client_logged_in_super.get(url) + + assert resp.status_code == 200 + budget = resp.context["budget"] + assert budget["revenue_target"] == budgeted_conference.revenue_budget + assert budget["revenue_actual"] == 2500 + assert budget["revenue_pct"] == 25.0 + assert budget["attendance_target"] == 100 + assert budget["attendance_actual"] == 1 + assert budget["attendance_pct"] == 1.0 + assert budget["grant_target"] == budgeted_conference.grant_budget + assert budget["grant_committed"] == 400 + assert budget["grant_disbursed"] == 300 + assert budget["grant_pct"] == 8.0 + assert "chart_budget_json" in resp.context + + def test_dashboard_no_budget_when_fields_unset(self, client_logged_in_super, conference): + """Conference without budget fields gets an empty budget dict (falsy).""" + url = reverse("manage:dashboard", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert "budget" not in resp.context + + +# --------------------------------------------------------------------------- +# ConferenceEditView invalid-form branch (views.py 910) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestConferenceEditViewInvalidPost: + def test_post_invalid_form_rerenders_with_errors(self, client_logged_in_super, conference): + url = reverse("manage:conference-edit", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.post( + url, + { + # "name" omitted -> required field missing, form invalid. + "start_date": "2027-05-01", + "end_date": "2027-05-03", + "timezone": "UTC", + "total_capacity": 0, + }, + ) + assert resp.status_code == 200 + assert resp.context["form"].is_valid() is False + conference.refresh_from_db() + assert conference.name == "Test Conf" + + +# --------------------------------------------------------------------------- +# TravelGrantManageListView budget tracking (views.py 2004-2015) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestTravelGrantListBudgetTracking: + def test_grant_budget_context_present_when_set( + self, client_logged_in_super, budgeted_conference, regular_user, second_user + ): + TravelGrant.objects.create( + conference=budgeted_conference, + user=regular_user, + status=TravelGrant.GrantStatus.ACCEPTED, + requested_amount="1000.00", + approved_amount="800.00", + ) + TravelGrant.objects.create( + conference=budgeted_conference, + user=second_user, + status=TravelGrant.GrantStatus.DISBURSED, + requested_amount="200.00", + approved_amount="200.00", + disbursed_amount="200.00", + ) + + url = reverse("manage:travel-grant-list", kwargs={"conference_slug": budgeted_conference.slug}) + resp = client_logged_in_super.get(url) + + assert resp.status_code == 200 + grant_budget = resp.context["grant_budget"] + assert grant_budget["budget"] == 5000.0 + assert grant_budget["committed"] == 1000.0 + assert grant_budget["disbursed"] == 200.0 + assert grant_budget["remaining"] == 4000.0 + assert grant_budget["pct_committed"] == 20.0 + assert grant_budget["pct_disbursed"] == 4.0 + assert "chart_grant_budget_json" in resp.context + + def test_no_grant_budget_context_when_unset(self, client_logged_in_super, conference, regular_user): + TravelGrant.objects.create( + conference=conference, + user=regular_user, + status=TravelGrant.GrantStatus.SUBMITTED, + requested_amount="100.00", + ) + url = reverse("manage:travel-grant-list", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert "grant_budget" not in resp.context + + +# --------------------------------------------------------------------------- +# Expense Category CRUD views (views.py 3818-3987, forms.py 778-781) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExpenseCategoryViews: + def test_list_view_annotates_budget_and_spent(self, client_logged_in_super, conference, expense_category): + Expense.objects.create( + conference=conference, + category=expense_category, + description="Deposit", + amount="250.00", + date=date(2027, 4, 1), + ) + url = reverse("manage:expense-category-list", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "expenses" + assert resp.context["total_budget"] == Decimal("1000.00") + assert resp.context["total_spent"] == 250 + + def test_list_view_with_no_categories(self, client_logged_in_super, conference): + url = reverse("manage:expense-category-list", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["total_budget"] == 0 + assert resp.context["total_spent"] == 0 + + def test_create_view_get(self, client_logged_in_super, conference): + url = reverse("manage:expense-category-add", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "expenses" + assert resp.context["is_create"] is True + + def test_create_view_post(self, client_logged_in_super, conference): + url = reverse("manage:expense-category-add", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.post( + url, + { + "name": "Catering", + "slug": "catering", + "description": "Food and drink", + "budget_amount": "2000.00", + "order": 2, + }, + ) + assert resp.status_code == 302 + assert resp.url == reverse("manage:expense-category-list", kwargs={"conference_slug": conference.slug}) + category = ExpenseCategory.objects.get(conference=conference, slug="catering") + assert category.conference == conference + + def test_edit_view_get(self, client_logged_in_super, conference, expense_category): + url = reverse( + "manage:expense-category-edit", + kwargs={"conference_slug": conference.slug, "pk": expense_category.pk}, + ) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "expenses" + assert resp.context["is_create"] is False + + def test_edit_view_post(self, client_logged_in_super, conference, expense_category): + url = reverse( + "manage:expense-category-edit", + kwargs={"conference_slug": conference.slug, "pk": expense_category.pk}, + ) + resp = client_logged_in_super.post( + url, + { + "name": "Venue & AV", + "slug": "venue", + "description": "", + "budget_amount": "1200.00", + "order": 1, + }, + ) + assert resp.status_code == 302 + assert resp.url == reverse("manage:expense-category-list", kwargs={"conference_slug": conference.slug}) + expense_category.refresh_from_db() + assert expense_category.name == "Venue & AV" + + +# --------------------------------------------------------------------------- +# Expense CRUD views (views.py 3891-3987, forms.py 778-781) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExpenseViews: + def test_list_view_basic(self, client_logged_in_super, conference, expense_category): + expense = Expense.objects.create( + conference=conference, + category=expense_category, + description="Deposit", + amount="500.00", + date=date(2027, 4, 1), + ) + url = reverse("manage:expense-list", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "expenses" + assert expense in resp.context["expenses"] + assert resp.context["total_expenses"] == 500 + assert resp.context["expense_count"] == 1 + assert resp.context["current_category"] == "" + + def test_list_view_filtered_by_category(self, client_logged_in_super, conference, expense_category): + other_category = ExpenseCategory.objects.create( + conference=conference, + name="Swag", + slug="swag", + order=2, + ) + in_category = Expense.objects.create( + conference=conference, + category=expense_category, + description="Deposit", + amount="500.00", + date=date(2027, 4, 1), + ) + Expense.objects.create( + conference=conference, + category=other_category, + description="T-shirts", + amount="300.00", + date=date(2027, 4, 2), + ) + url = reverse("manage:expense-list", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url, {"category": expense_category.pk}) + assert resp.status_code == 200 + assert list(resp.context["expenses"]) == [in_category] + assert resp.context["total_expenses"] == 500 + assert resp.context["expense_count"] == 1 + assert resp.context["current_category"] == str(expense_category.pk) + + def test_create_view_get_scopes_category_queryset(self, client_logged_in_super, conference, expense_category): + """Covers ExpenseForm.__init__ scoping category choices to the conference (forms.py 778-781).""" + other_conference = Conference.objects.create( + name="Other Conf", + slug="other-conf", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + is_active=True, + ) + ExpenseCategory.objects.create(conference=other_conference, name="Other", slug="other", order=1) + + url = reverse("manage:expense-add", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "expenses" + assert resp.context["is_create"] is True + category_qs = resp.context["form"].fields["category"].queryset + assert list(category_qs) == [expense_category] + + def test_create_view_post(self, client_logged_in_super, conference, expense_category, superuser): + url = reverse("manage:expense-add", kwargs={"conference_slug": conference.slug}) + resp = client_logged_in_super.post( + url, + { + "category": expense_category.pk, + "description": "Banner printing", + "amount": "150.00", + "vendor": "Print Co", + "date": "2027-04-15", + "receipt_reference": "", + "notes": "", + }, + ) + assert resp.status_code == 302 + assert resp.url == reverse("manage:expense-list", kwargs={"conference_slug": conference.slug}) + created = Expense.objects.get(conference=conference, description="Banner printing") + assert created.created_by == superuser + + def test_edit_view_get(self, client_logged_in_super, conference, expense_category): + expense = Expense.objects.create( + conference=conference, + category=expense_category, + description="Deposit", + amount="500.00", + date=date(2027, 4, 1), + ) + url = reverse( + "manage:expense-edit", + kwargs={"conference_slug": conference.slug, "pk": expense.pk}, + ) + resp = client_logged_in_super.get(url) + assert resp.status_code == 200 + assert resp.context["active_nav"] == "expenses" + assert resp.context["is_create"] is False + + def test_edit_view_post(self, client_logged_in_super, conference, expense_category): + expense = Expense.objects.create( + conference=conference, + category=expense_category, + description="Deposit", + amount="500.00", + date=date(2027, 4, 1), + ) + url = reverse( + "manage:expense-edit", + kwargs={"conference_slug": conference.slug, "pk": expense.pk}, + ) + resp = client_logged_in_super.post( + url, + { + "category": expense_category.pk, + "description": "Deposit (revised)", + "amount": "550.00", + "vendor": "", + "date": "2027-04-01", + "receipt_reference": "", + "notes": "", + }, + ) + assert resp.status_code == 302 + assert resp.url == reverse("manage:expense-list", kwargs={"conference_slug": conference.slug}) + expense.refresh_from_db() + assert expense.description == "Deposit (revised)" + assert expense.amount == 550 + + +# --------------------------------------------------------------------------- +# _decimal_to_float (views_analytics.py 45-56) +# --------------------------------------------------------------------------- + + +class TestDecimalToFloat: + def test_converts_decimal(self): + from decimal import Decimal + + assert _decimal_to_float(Decimal("12.50")) == 12.5 + + def test_passes_through_non_decimal(self): + """Non-Decimal values are returned unchanged (used as json.dumps default).""" + assert _decimal_to_float("already-a-string") == "already-a-string" + assert _decimal_to_float(42) == 42 diff --git a/tests/test_registration/test_checkin_admin_coverage.py b/tests/test_registration/test_checkin_admin_coverage.py new file mode 100644 index 0000000..92bb6fc --- /dev/null +++ b/tests/test_registration/test_checkin_admin_coverage.py @@ -0,0 +1,699 @@ +"""Coverage backfill tests for: + +- ``services/checkin.py`` (order-status validation and door-check guards) +- ``views_checkin.py`` (JSON API edge cases: malformed bodies, missing + ticket types, 409/404 branches, redemption counts in preload export) +- ``views.py`` (the attendee-facing visa letter download view, and the + rare IntegrityError race in letter request creation) +- ``admin.py`` (purchase order inline permission guards and display methods) +""" + +import json +from datetime import date +from decimal import Decimal +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile +from django.http import HttpRequest +from django.test import Client, RequestFactory +from django.urls import reverse + +from django_program.conference.models import Conference +from django_program.registration.admin import ( + PurchaseOrderAdmin, + PurchaseOrderCreditNoteInline, + PurchaseOrderPaymentInline, +) +from django_program.registration.attendee import Attendee +from django_program.registration.letter import LetterRequest +from django_program.registration.models import AddOn, Order, OrderLineItem, TicketType +from django_program.registration.purchase_order import PurchaseOrder +from django_program.registration.services.checkin import CheckInService +from django_program.registration.services.purchase_orders import create_purchase_order + +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +# -- Helpers ------------------------------------------------------------------ + + +def _make_conference(**kwargs: object) -> Conference: + defaults: dict[str, object] = { + "name": "TestCon", + "slug": f"testcon-ca-{uuid4().hex[:6]}", + "start_date": date(2026, 7, 1), + "end_date": date(2026, 7, 5), + } + defaults.update(kwargs) + return Conference.objects.create(**defaults) + + +def _make_user(**kwargs: object) -> object: + defaults: dict[str, object] = { + "username": f"user-ca-{uuid4().hex[:8]}", + "email": f"{uuid4().hex[:8]}@test.com", + } + defaults.update(kwargs) + return User.objects.create_user(**defaults) + + +def _make_staff_user(**kwargs: object) -> object: + from django.contrib.auth.models import Permission + + defaults: dict[str, object] = { + "username": f"staff-ca-{uuid4().hex[:8]}", + "email": f"{uuid4().hex[:8]}@test.com", + "is_staff": True, + "password": "testpass123", + } + defaults.update(kwargs) + user = User.objects.create_user(**defaults) + perm = Permission.objects.get(content_type__app_label="program_conference", codename="change_conference") + user.user_permissions.add(perm) + return user + + +def _make_order(*, conference: Conference, user: object, status: str = Order.Status.PAID) -> Order: + return Order.objects.create( + conference=conference, + user=user, + status=status, + subtotal=Decimal("100.00"), + total=Decimal("100.00"), + reference=f"ORD-CA-{uuid4().hex[:8].upper()}", + ) + + +def _make_ticket_type(*, conference: Conference, **kwargs: object) -> TicketType: + defaults: dict[str, object] = { + "name": "General Admission", + "slug": f"general-ca-{uuid4().hex[:6]}", + "price": Decimal("100.00"), + "is_active": True, + } + defaults.update(kwargs) + return TicketType.objects.create(conference=conference, **defaults) + + +def _make_addon(*, conference: Conference, **kwargs: object) -> AddOn: + defaults: dict[str, object] = { + "name": "Workshop", + "slug": f"workshop-ca-{uuid4().hex[:6]}", + "price": Decimal("50.00"), + "is_active": True, + } + defaults.update(kwargs) + return AddOn.objects.create(conference=conference, **defaults) + + +def _make_line_item( + *, + order: Order, + ticket_type: TicketType | None = None, + addon: AddOn | None = None, + quantity: int = 1, + description: str = "", +) -> OrderLineItem: + unit_price = Decimal("100.00") + if ticket_type: + unit_price = ticket_type.price + description = description or str(ticket_type.name) + elif addon: + unit_price = addon.price + description = description or str(addon.name) + else: + description = description or "Line Item" + return OrderLineItem.objects.create( + order=order, + description=description, + quantity=quantity, + unit_price=unit_price, + line_total=unit_price * quantity, + ticket_type=ticket_type, + addon=addon, + ) + + +def _make_attendee(*, conference: Conference, user: object, order: Order | None = None) -> Attendee: + return Attendee.objects.create(user=user, conference=conference, order=order) + + +# ============================================================================= +# CheckInService.validate_order_status +# ============================================================================= + + +@pytest.mark.unit +class TestValidateOrderStatus: + def test_returns_message_when_attendee_has_no_order(self): + conf = _make_conference() + user = _make_user() + attendee = _make_attendee(conference=conf, user=user, order=None) + + result = CheckInService.validate_order_status(attendee) + + assert result == "Attendee has no associated order." + + def test_returns_message_when_order_status_invalid(self): + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user, status=Order.Status.PENDING) + attendee = _make_attendee(conference=conf, user=user, order=order) + + result = CheckInService.validate_order_status(attendee) + + assert result is not None + assert "check-in not allowed" in result + + +# ============================================================================= +# CheckInService.record_door_check — conference mismatch guards +# ============================================================================= + + +@pytest.mark.unit +class TestRecordDoorCheckConferenceMismatch: + def test_raises_when_ticket_type_belongs_to_other_conference(self): + conf = _make_conference() + other_conf = _make_conference() + user = _make_user() + attendee = _make_attendee(conference=conf, user=user) + foreign_ticket_type = _make_ticket_type(conference=other_conf) + + with pytest.raises(ValueError, match="does not belong to the attendee's conference"): + CheckInService.record_door_check( + attendee=attendee, + ticket_type=foreign_ticket_type, + addon=None, + station="Door A", + ) + + def test_raises_when_addon_belongs_to_other_conference(self): + conf = _make_conference() + other_conf = _make_conference() + user = _make_user() + attendee = _make_attendee(conference=conf, user=user) + foreign_addon = _make_addon(conference=other_conf) + + with pytest.raises(ValueError, match="does not belong to the attendee's conference"): + CheckInService.record_door_check( + attendee=attendee, + ticket_type=None, + addon=foreign_addon, + station="Door A", + ) + + +# ============================================================================= +# views_checkin._get_ticket_type_name / _parse_json_body (unit-level) +# ============================================================================= + + +@pytest.mark.unit +class TestTicketTypeNameHelper: + def test_returns_empty_string_for_none_order(self): + from django_program.registration.views_checkin import _get_ticket_type_name + + assert _get_ticket_type_name(None) == "" + + def test_returns_ticket_type_name_from_line_items(self): + from django_program.registration.views_checkin import _get_ticket_type_name + + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user) + ticket_type = _make_ticket_type(conference=conf, name="VIP Pass") + _make_line_item(order=order, ticket_type=ticket_type) + + assert _get_ticket_type_name(order) == "VIP Pass" + + +@pytest.mark.unit +class TestParseJsonBody: + def test_returns_none_for_invalid_json(self): + from django_program.registration.views_checkin import _parse_json_body + + request = HttpRequest() + request._body = b"not valid json {" + assert _parse_json_body(request) is None + + def test_returns_none_for_non_dict_payload(self): + from django_program.registration.views_checkin import _parse_json_body + + request = HttpRequest() + request._body = b"[1, 2, 3]" + assert _parse_json_body(request) is None + + +# ============================================================================= +# ScanView edge cases +# ============================================================================= + + +@pytest.mark.integration +class TestScanViewEdgeCases: + def _url(self, conference: Conference) -> str: + return reverse("registration:checkin-scan", args=[conference.slug]) + + def test_invalid_json_body_returns_400(self): + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data="not json", + content_type="application/json", + ) + assert response.status_code == 400 + assert response.json()["error"] == "Invalid JSON body" + + def test_missing_access_code_returns_400(self): + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({}), + content_type="application/json", + ) + assert response.status_code == 400 + assert response.json()["error"] == "access_code is required" + + def test_order_status_error_returns_409(self): + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user, status=Order.Status.PENDING) + attendee = _make_attendee(conference=conf, user=user, order=order) + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"access_code": attendee.access_code}), + content_type="application/json", + ) + + assert response.status_code == 409 + data = response.json() + assert "check-in not allowed" in data["error"] + assert data["order_status"] == Order.Status.PENDING + + def test_returns_ticket_type_name_in_badge(self): + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user) + ticket_type = _make_ticket_type(conference=conf, name="Corporate") + _make_line_item(order=order, ticket_type=ticket_type) + attendee = _make_attendee(conference=conf, user=user, order=order) + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"access_code": attendee.access_code}), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["badge"]["ticket_type"] == "Corporate" + + +# ============================================================================= +# LookupView — redeemed counts +# ============================================================================= + + +@pytest.mark.integration +class TestLookupViewRedeemedCounts: + def _url(self, conference: Conference, access_code: str) -> str: + return reverse("registration:checkin-lookup", args=[conference.slug, access_code]) + + def test_reflects_redeemed_count_for_addon_line_item(self): + from django_program.registration.services.checkin import RedemptionService + + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user) + addon = _make_addon(conference=conf) + line_item = _make_line_item(order=order, addon=addon, quantity=2) + attendee = _make_attendee(conference=conf, user=user, order=order) + staff = _make_staff_user() + + RedemptionService.redeem_product(attendee=attendee, order_line_item=line_item) + + client = Client() + client.force_login(staff) + response = client.get(self._url(conf, attendee.access_code)) + + assert response.status_code == 200 + data = response.json() + assert len(data["redeemable"]) == 1 + assert data["redeemable"][0]["redeemed_count"] == 1 + assert data["redeemable"][0]["remaining"] == 1 + + +# ============================================================================= +# RedeemView edge cases +# ============================================================================= + + +@pytest.mark.integration +class TestRedeemViewEdgeCases: + def _url(self, conference: Conference) -> str: + return reverse("registration:checkin-redeem", args=[conference.slug]) + + def test_invalid_json_body_returns_400(self): + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data="not json", + content_type="application/json", + ) + assert response.status_code == 400 + assert response.json()["error"] == "Invalid JSON body" + + def test_non_integer_line_item_id_returns_400(self): + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"access_code": "SOMECODE", "line_item_id": "not-a-number"}), + content_type="application/json", + ) + assert response.status_code == 400 + assert "valid integer line_item_id" in response.json()["error"] + + def test_missing_access_code_returns_400(self): + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"access_code": "", "line_item_id": 1}), + content_type="application/json", + ) + assert response.status_code == 400 + + def test_unknown_access_code_returns_404(self): + conf = _make_conference() + staff = _make_staff_user() + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"access_code": "NOTFOUND", "line_item_id": 1}), + content_type="application/json", + ) + assert response.status_code == 404 + assert "not found" in response.json()["error"].lower() + + def test_order_status_error_returns_409(self): + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user, status=Order.Status.PENDING) + attendee = _make_attendee(conference=conf, user=user, order=order) + line_item = _make_line_item(order=order, quantity=1) + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"access_code": attendee.access_code, "line_item_id": line_item.pk}), + content_type="application/json", + ) + + assert response.status_code == 409 + + def test_unknown_line_item_returns_400(self): + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user) + attendee = _make_attendee(conference=conf, user=user, order=order) + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"access_code": attendee.access_code, "line_item_id": 999999}), + content_type="application/json", + ) + + assert response.status_code == 400 + assert response.json()["error"] == "Line item not found in attendee's order" + + +# ============================================================================= +# OfflinePreloadView — redeemed counts in preload export +# ============================================================================= + + +@pytest.mark.integration +class TestOfflinePreloadViewRedeemedCounts: + def _url(self, conference: Conference) -> str: + return reverse("registration:checkin-preload", args=[conference.slug]) + + def test_includes_redeemed_counts_for_prior_redemptions(self): + from django_program.registration.services.checkin import RedemptionService + + conf = _make_conference() + user = _make_user() + order = _make_order(conference=conf, user=user) + addon = _make_addon(conference=conf) + line_item = _make_line_item(order=order, addon=addon, quantity=3) + attendee = _make_attendee(conference=conf, user=user, order=order) + staff = _make_staff_user() + + RedemptionService.redeem_product(attendee=attendee, order_line_item=line_item) + + client = Client() + client.force_login(staff) + response = client.get(self._url(conf)) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 1 + product = data["attendees"][0]["products"][0] + assert product["redeemed_count"] == 1 + assert product["remaining"] == 2 + + +# ============================================================================= +# LetterRequestCreateView — IntegrityError race +# ============================================================================= + + +def _mock_feature_enabled(feature: object, conference: object = None) -> bool: + return True + + +@pytest.mark.integration +class TestLetterRequestCreateIntegrityRace: + def _url(self, conference: Conference) -> str: + return reverse("registration:letter-request", kwargs={"conference_slug": conference.slug}) + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_integrity_error_on_save_redirects_to_detail(self, mock_feature): + conf = _make_conference() + user = _make_user(password="testpass123") + LetterRequest.objects.create( + conference=conf, + user=user, + passport_name="Existing Holder", + passport_number="X0000000", + nationality="Testlandia", + travel_from=date(2027, 5, 1), + travel_until=date(2027, 5, 10), + destination_address="123 Test St", + ) + + client = Client() + client.force_login(user) + + # Simulate a race: the pre-check "existing" lookup misses, but the + # real unique_together constraint still rejects the duplicate save. + with patch("django_program.registration.views.LetterRequest.objects.filter") as mock_filter: + mock_filter.return_value.first.return_value = None + response = client.post( + self._url(conf), + { + "passport_name": "Existing Holder", + "passport_number": "X0000000", + "nationality": "Testlandia", + "travel_from": "2027-05-01", + "travel_until": "2027-05-10", + "destination_address": "123 Test St", + }, + ) + + assert response.status_code == 302 + detail_url = reverse("registration:letter-request-detail", args=[conf.slug]) + assert response.url == detail_url + + +# ============================================================================= +# LetterRequestDownloadView (attendee-facing self-download) +# ============================================================================= + + +@pytest.mark.integration +class TestLetterRequestDownloadView: + def _url(self, conference: Conference) -> str: + return reverse("registration:letter-request-download", kwargs={"conference_slug": conference.slug}) + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_downloads_generated_pdf(self, mock_feature): + conf = _make_conference() + user = _make_user(password="testpass123") + letter_request = LetterRequest.objects.create( + conference=conf, + user=user, + passport_name="Jane Doe", + passport_number="AB1234567", + nationality="Testlandia", + travel_from=date(2027, 5, 1), + travel_until=date(2027, 5, 10), + destination_address="123 Test St", + status=LetterRequest.Status.GENERATED, + ) + letter_request.generated_pdf.save("invite.pdf", ContentFile(b"%PDF-fake-contents"), save=True) + + client = Client() + client.force_login(user) + response = client.get(self._url(conf)) + + assert response.status_code == 200 + assert response["Content-Type"] == "application/pdf" + assert response.content == b"%PDF-fake-contents" + assert "invitation-letter.pdf" in response["Content-Disposition"] + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_returns_404_when_no_pdf_generated(self, mock_feature): + conf = _make_conference() + user = _make_user(password="testpass123") + LetterRequest.objects.create( + conference=conf, + user=user, + passport_name="Jane Doe", + passport_number="AB1234567", + nationality="Testlandia", + travel_from=date(2027, 5, 1), + travel_until=date(2027, 5, 10), + destination_address="123 Test St", + ) + + client = Client() + client.force_login(user) + response = client.get(self._url(conf)) + + assert response.status_code == 404 + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_returns_404_when_no_letter_request_exists(self, mock_feature): + conf = _make_conference() + user = _make_user(password="testpass123") + + client = Client() + client.force_login(user) + response = client.get(self._url(conf)) + + assert response.status_code == 404 + + +# ============================================================================= +# PurchaseOrder admin — inline permission guards and display methods +# ============================================================================= + + +@pytest.fixture +def po_conference(): + return Conference.objects.create( + name="AdminPOCon", + slug="admin-po-con", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + ) + + +@pytest.fixture +def po_staff_user(): + return User.objects.create_user( + username="adminpostaff", + email="adminpostaff@example.com", + password="testpass123", + is_staff=True, + ) + + +@pytest.fixture +def admin_purchase_order(po_conference, po_staff_user): + return create_purchase_order( + conference=po_conference, + organization_name="Admin Corp", + contact_email="billing@admincorp.example", + contact_name="Admin Contact", + line_items=[{"description": "Ticket", "quantity": 1, "unit_price": Decimal("200.00")}], + created_by=po_staff_user, + ) + + +@pytest.mark.unit +class TestPurchaseOrderInlinePermissions: + def test_payment_inline_has_add_permission_false(self): + inline = PurchaseOrderPaymentInline(PurchaseOrder, admin_site=MagicMock()) + request = RequestFactory().get("/") + assert inline.has_add_permission(request) is False + + def test_payment_inline_has_delete_permission_false(self): + inline = PurchaseOrderPaymentInline(PurchaseOrder, admin_site=MagicMock()) + request = RequestFactory().get("/") + assert inline.has_delete_permission(request) is False + + def test_credit_note_inline_has_add_permission_false(self): + inline = PurchaseOrderCreditNoteInline(PurchaseOrder, admin_site=MagicMock()) + request = RequestFactory().get("/") + assert inline.has_add_permission(request) is False + + def test_credit_note_inline_has_delete_permission_false(self): + inline = PurchaseOrderCreditNoteInline(PurchaseOrder, admin_site=MagicMock()) + request = RequestFactory().get("/") + assert inline.has_delete_permission(request) is False + + +@pytest.mark.integration +class TestPurchaseOrderAdminQuerysetAndDisplays: + def test_get_queryset_annotates_totals(self, admin_purchase_order): + admin_instance = PurchaseOrderAdmin(PurchaseOrder, admin_site=MagicMock()) + request = RequestFactory().get("/") + qs = admin_instance.get_queryset(request) + po = qs.get(pk=admin_purchase_order.pk) + + assert po._annotated_total_paid == Decimal("0.00") + assert po._annotated_total_credited == Decimal("0.00") + + def test_balance_due_display_renders_string(self, admin_purchase_order): + admin_instance = PurchaseOrderAdmin(PurchaseOrder, admin_site=MagicMock()) + result = admin_instance.balance_due_display(admin_purchase_order) + assert result == str(admin_purchase_order.balance_due) + + def test_total_paid_display_renders_string(self, admin_purchase_order): + admin_instance = PurchaseOrderAdmin(PurchaseOrder, admin_site=MagicMock()) + result = admin_instance.total_paid_display(admin_purchase_order) + assert result == str(admin_purchase_order.total_paid) diff --git a/tests/test_registration/test_stripe_client_coverage.py b/tests/test_registration/test_stripe_client_coverage.py new file mode 100644 index 0000000..0266091 --- /dev/null +++ b/tests/test_registration/test_stripe_client_coverage.py @@ -0,0 +1,175 @@ +"""Coverage backfill tests for Terminal-related methods on StripeClient.""" + +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest + +from django_program.conference.models import Conference +from django_program.registration.stripe_client import StripeClient + +# -- Fixtures ----------------------------------------------------------------- + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="TestCon", + slug="testcon-stripe-terminal", + start_date="2027-06-01", + end_date="2027-06-03", + timezone="UTC", + stripe_secret_key="sk_test_abc123", + stripe_publishable_key="pk_test_xyz789", + ) + + +@pytest.fixture +def mock_stripe_client_cls(): + with patch("django_program.registration.stripe_client.stripe.StripeClient") as mock_cls: + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + yield mock_cls, mock_instance.v1 + + +# ============================================================================= +# TestCreateConnectionToken +# ============================================================================= + + +@pytest.mark.unit +class TestCreateConnectionToken: + def test_returns_token_secret(self, conference, mock_stripe_client_cls): + _, mock_instance = mock_stripe_client_cls + mock_token = MagicMock(secret="pst_test_secret_xyz") + mock_instance.terminal.connection_tokens.create.return_value = mock_token + + client = StripeClient(conference) + secret = client.create_connection_token() + + mock_instance.terminal.connection_tokens.create.assert_called_once_with() + assert secret == "pst_test_secret_xyz" + + +# ============================================================================= +# TestListReaders +# ============================================================================= + + +@pytest.mark.unit +class TestListReaders: + def test_list_readers_without_location(self, conference, mock_stripe_client_cls): + _, mock_instance = mock_stripe_client_cls + mock_reader = MagicMock(id="tmr_1") + mock_instance.terminal.readers.list.return_value = MagicMock(data=[mock_reader]) + + client = StripeClient(conference) + readers = client.list_readers() + + call_kwargs = mock_instance.terminal.readers.list.call_args + params = call_kwargs.kwargs["params"] + assert params == {"limit": 100} + assert readers == [mock_reader] + + def test_list_readers_with_location(self, conference, mock_stripe_client_cls): + _, mock_instance = mock_stripe_client_cls + mock_reader = MagicMock(id="tmr_2") + mock_instance.terminal.readers.list.return_value = MagicMock(data=[mock_reader]) + + client = StripeClient(conference) + readers = client.list_readers(location="tml_location_1") + + call_kwargs = mock_instance.terminal.readers.list.call_args + params = call_kwargs.kwargs["params"] + assert params == {"limit": 100, "location": "tml_location_1"} + assert readers == [mock_reader] + + +# ============================================================================= +# TestCreateTerminalPaymentIntent +# ============================================================================= + + +@pytest.mark.unit +class TestCreateTerminalPaymentIntent: + def test_minimal_params(self, conference, mock_stripe_client_cls): + _, mock_instance = mock_stripe_client_cls + mock_intent = MagicMock(id="pi_terminal_min") + mock_instance.payment_intents.create.return_value = mock_intent + + client = StripeClient(conference) + result = client.create_terminal_payment_intent(amount=Decimal("10.00"), currency="usd") + + call_kwargs = mock_instance.payment_intents.create.call_args + params = call_kwargs.kwargs["params"] + assert params["amount"] == 1000 + assert params["currency"] == "usd" + assert params["capture_method"] == "manual" + assert params["payment_method_types"] == ["card_present"] + assert "customer" not in params + assert "metadata" not in params + assert "description" not in params + assert result.id == "pi_terminal_min" + + def test_full_params(self, conference, mock_stripe_client_cls): + _, mock_instance = mock_stripe_client_cls + mock_intent = MagicMock(id="pi_terminal_full") + mock_instance.payment_intents.create.return_value = mock_intent + + client = StripeClient(conference) + result = client.create_terminal_payment_intent( + amount=Decimal("25.50"), + currency="usd", + customer_id="cus_abc123", + metadata={"order_id": "42"}, + description="Terminal payment for TestCon", + ) + + call_kwargs = mock_instance.payment_intents.create.call_args + params = call_kwargs.kwargs["params"] + assert params["amount"] == 2550 + assert params["customer"] == "cus_abc123" + assert params["metadata"] == {"order_id": "42"} + assert params["description"] == "Terminal payment for TestCon" + assert result.id == "pi_terminal_full" + + +# ============================================================================= +# TestProcessTerminalPayment +# ============================================================================= + + +@pytest.mark.unit +class TestProcessTerminalPayment: + def test_dispatches_payment_intent_to_reader(self, conference, mock_stripe_client_cls): + _, mock_instance = mock_stripe_client_cls + mock_reader = MagicMock(id="tmr_proc") + mock_instance.terminal.readers.process_payment_intent.return_value = mock_reader + + client = StripeClient(conference) + result = client.process_terminal_payment(reader_id="tmr_proc", payment_intent_id="pi_abc") + + mock_instance.terminal.readers.process_payment_intent.assert_called_once_with( + "tmr_proc", + params={"payment_intent": "pi_abc"}, + ) + assert result is mock_reader + + +# ============================================================================= +# TestCancelReaderAction +# ============================================================================= + + +@pytest.mark.unit +class TestCancelReaderAction: + def test_cancels_reader_action(self, conference, mock_stripe_client_cls): + _, mock_instance = mock_stripe_client_cls + mock_reader = MagicMock(id="tmr_cancel") + mock_instance.terminal.readers.cancel_action.return_value = mock_reader + + client = StripeClient(conference) + result = client.cancel_reader_action("tmr_cancel") + + mock_instance.terminal.readers.cancel_action.assert_called_once_with("tmr_cancel") + assert result is mock_reader diff --git a/tests/test_registration/test_terminal_coverage.py b/tests/test_registration/test_terminal_coverage.py new file mode 100644 index 0000000..a5645c5 --- /dev/null +++ b/tests/test_registration/test_terminal_coverage.py @@ -0,0 +1,1038 @@ +"""Coverage backfill tests for django_program.registration.views_terminal. + +Exercises error branches, exception handling, and secondary code paths +not covered by the existing ``tests/test_registration/test_terminal.py``. +""" + +import json +from datetime import date +from decimal import Decimal +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +import stripe +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import Client +from django.urls import reverse + +from django_program.conference.models import Conference +from django_program.registration.attendee import Attendee +from django_program.registration.models import AddOn, Cart, CartItem, Order, Payment, TicketType +from django_program.registration.terminal import TerminalPayment + +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +# -- Helpers ------------------------------------------------------------------ + + +def _make_conference(**kwargs: object) -> Conference: + defaults: dict[str, object] = { + "name": "TestCon", + "slug": f"testcon-{uuid4().hex[:6]}", + "start_date": date(2026, 7, 1), + "end_date": date(2026, 7, 5), + "stripe_secret_key": "sk_test_fake123", + } + defaults.update(kwargs) + return Conference.objects.create(**defaults) + + +def _make_user(**kwargs: object) -> object: + defaults: dict[str, object] = { + "username": f"user-{uuid4().hex[:8]}", + "email": f"{uuid4().hex[:8]}@test.com", + } + defaults.update(kwargs) + return User.objects.create_user(**defaults) + + +def _make_staff_user(**kwargs: object) -> object: + """Create a staff user with the change_conference permission required by terminal API.""" + defaults: dict[str, object] = { + "username": f"staff-{uuid4().hex[:8]}", + "email": f"{uuid4().hex[:8]}@test.com", + "is_staff": True, + "password": "testpass123", + } + defaults.update(kwargs) + user = User.objects.create_user(**defaults) + perm = Permission.objects.get(content_type__app_label="program_conference", codename="change_conference") + user.user_permissions.add(perm) + return user + + +def _make_order(*, conference: Conference, user: object, status: str = Order.Status.PAID, **kwargs: object) -> Order: + defaults: dict[str, object] = { + "conference": conference, + "user": user, + "status": status, + "subtotal": Decimal("100.00"), + "total": Decimal("100.00"), + "reference": f"ORD-{uuid4().hex[:8].upper()}", + } + defaults.update(kwargs) + return Order.objects.create(**defaults) + + +def _make_payment(*, order: Order, method: str = Payment.Method.TERMINAL, **kwargs: object) -> Payment: + defaults: dict[str, object] = { + "order": order, + "method": method, + "amount": order.total, + "stripe_payment_intent_id": f"pi_{uuid4().hex[:16]}", + } + defaults.update(kwargs) + return Payment.objects.create(**defaults) + + +def _make_ticket_type(*, conference: Conference, **kwargs: object) -> TicketType: + defaults: dict[str, object] = { + "name": "General Admission", + "slug": f"general-{uuid4().hex[:6]}", + "price": Decimal("100.00"), + "is_active": True, + } + defaults.update(kwargs) + return TicketType.objects.create(conference=conference, **defaults) + + +def _make_addon(*, conference: Conference, **kwargs: object) -> AddOn: + defaults: dict[str, object] = { + "name": "Workshop", + "slug": f"workshop-{uuid4().hex[:6]}", + "price": Decimal("50.00"), + "is_active": True, + } + defaults.update(kwargs) + return AddOn.objects.create(conference=conference, **defaults) + + +def _make_attendee(*, conference: Conference, user: object, order: Order | None = None) -> Attendee: + return Attendee.objects.create(user=user, conference=conference, order=order) + + +def _stripe_error() -> stripe.StripeError: + return stripe.StripeError("something went wrong") + + +# ============================================================================= +# ConnectionTokenView +# ============================================================================= + + +@pytest.mark.integration +class TestConnectionTokenViewCoverage: + def _url(self, conference: Conference) -> str: + return reverse("registration:terminal-connection-token", args=[conference.slug]) + + def test_returns_400_when_stripe_client_init_fails(self) -> None: + conf = _make_conference(stripe_secret_key="") + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), content_type="application/json") + + assert response.status_code == 400 + assert "error" in response.json() + + @patch("django_program.registration.views_terminal.StripeClient") + def test_returns_502_on_stripe_error(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + + mock_client = MagicMock() + mock_client.create_connection_token.side_effect = _stripe_error() + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), content_type="application/json") + + assert response.status_code == 502 + assert "error" in response.json() + + +# ============================================================================= +# CreatePaymentIntentView +# ============================================================================= + + +@pytest.mark.integration +class TestCreatePaymentIntentViewCoverage: + def _url(self, conference: Conference) -> str: + return reverse("registration:terminal-create-intent", args=[conference.slug]) + + def test_invalid_json_body(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), data="not json", content_type="application/json") + + assert response.status_code == 400 + + def test_json_body_not_an_object(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), data=json.dumps([1, 2, 3]), content_type="application/json") + + assert response.status_code == 400 + + def test_missing_reader_id(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"amount": "10.00"}), + content_type="application/json", + ) + + assert response.status_code == 400 + assert "reader_id" in response.json()["error"] + + def test_missing_order_id_and_amount(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1"}), + content_type="application/json", + ) + + assert response.status_code == 400 + assert "order_id or amount" in response.json()["error"] + + def test_order_id_not_found(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "order_id": 999999}), + content_type="application/json", + ) + + assert response.status_code == 404 + + def test_order_id_not_an_int(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "order_id": "not-an-int"}), + content_type="application/json", + ) + + assert response.status_code == 404 + + def test_order_not_pending(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + order = _make_order(conference=conf, user=user, status=Order.Status.PAID) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "order_id": order.pk}), + content_type="application/json", + ) + + assert response.status_code == 409 + assert "not pending" in response.json()["error"] + + def test_order_already_fully_paid(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + order = _make_order( + conference=conf, + user=user, + status=Order.Status.PENDING, + subtotal=Decimal("50.00"), + total=Decimal("50.00"), + ) + _make_payment(order=order, status=Payment.Status.SUCCEEDED, amount=Decimal("50.00")) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "order_id": order.pk}), + content_type="application/json", + ) + + assert response.status_code == 409 + assert "fully paid" in response.json()["error"] + + @patch("django_program.registration.views_terminal.StripeClient") + def test_order_id_with_remaining_balance_succeeds(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + order = _make_order( + conference=conf, + user=user, + status=Order.Status.PENDING, + subtotal=Decimal("100.00"), + total=Decimal("100.00"), + ) + + mock_intent = MagicMock(id="pi_order_remaining", client_secret="secret_abc") + mock_client = MagicMock() + mock_client.create_terminal_payment_intent.return_value = mock_intent + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "order_id": order.pk}), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + assert data["order_id"] == order.pk + assert data["order_reference"] == str(order.reference) + + def test_amount_not_positive(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "amount": "0"}), + content_type="application/json", + ) + + assert response.status_code == 400 + assert "positive" in response.json()["error"] + + def test_amount_invalid(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "amount": "not-a-number"}), + content_type="application/json", + ) + + assert response.status_code == 400 + assert "Invalid amount" in response.json()["error"] + + def test_stripe_client_init_failure_returns_400(self) -> None: + conf = _make_conference(stripe_secret_key="") + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "amount": "10.00"}), + content_type="application/json", + ) + + assert response.status_code == 400 + + @patch("django_program.registration.views_terminal.StripeClient") + def test_create_terminal_payment_intent_value_error(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + + mock_client = MagicMock() + mock_client.create_terminal_payment_intent.side_effect = ValueError("bad amount") + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "amount": "10.00"}), + content_type="application/json", + ) + + assert response.status_code == 400 + assert "Invalid payment amount" in response.json()["error"] + + @patch("django_program.registration.views_terminal.StripeClient") + def test_create_terminal_payment_intent_stripe_error(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + + mock_client = MagicMock() + mock_client.create_terminal_payment_intent.side_effect = _stripe_error() + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"reader_id": "tmr_1", "amount": "10.00"}), + content_type="application/json", + ) + + assert response.status_code == 502 + + @patch("django_program.registration.views_terminal.StripeClient") + def test_walkup_sale_with_valid_attendee_access_code(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user(first_name="Attendee", last_name="One") + attendee = _make_attendee(conference=conf, user=user) + + mock_intent = MagicMock(id="pi_walkup_attendee", client_secret="secret_attendee") + mock_client = MagicMock() + mock_client.create_terminal_payment_intent.return_value = mock_intent + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps( + { + "reader_id": "tmr_1", + "amount": "15.00", + "attendee_access_code": attendee.access_code, + } + ), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + order = Order.objects.get(pk=data["order_id"]) + assert order.user_id == user.pk + + @patch("django_program.registration.views_terminal.StripeClient") + def test_walkup_sale_with_unknown_attendee_access_code(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + + mock_intent = MagicMock(id="pi_walkup_unknown", client_secret="secret_unknown") + mock_client = MagicMock() + mock_client.create_terminal_payment_intent.return_value = mock_intent + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps( + { + "reader_id": "tmr_1", + "amount": "15.00", + "attendee_access_code": "NOPE12345", + } + ), + content_type="application/json", + ) + + assert response.status_code == 404 + assert "Attendee not found" in response.json()["error"] + + +# ============================================================================= +# CapturePaymentView +# ============================================================================= + + +@pytest.mark.integration +class TestCapturePaymentViewCoverage: + def _url(self, conference: Conference) -> str: + return reverse("registration:terminal-capture", args=[conference.slug]) + + def test_invalid_json_body(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), data="nope", content_type="application/json") + + assert response.status_code == 400 + + def test_missing_payment_intent_id(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), data=json.dumps({}), content_type="application/json") + + assert response.status_code == 400 + + def test_terminal_payment_not_found(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_missing"}), + content_type="application/json", + ) + + assert response.status_code == 404 + + def test_stripe_client_init_failure(self) -> None: + conf = _make_conference(stripe_secret_key="") + staff = _make_staff_user() + user = _make_user() + order = _make_order(conference=conf, user=user) + payment = _make_payment(order=order) + TerminalPayment.objects.create( + payment=payment, + conference=conf, + payment_intent_id="pi_no_key", + capture_status=TerminalPayment.CaptureStatus.AUTHORIZED, + ) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_no_key"}), + content_type="application/json", + ) + + assert response.status_code == 400 + + @patch("django_program.registration.views_terminal.StripeClient") + def test_capture_stripe_error(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + order = _make_order(conference=conf, user=user) + payment = _make_payment(order=order) + TerminalPayment.objects.create( + payment=payment, + conference=conf, + payment_intent_id="pi_capture_err", + capture_status=TerminalPayment.CaptureStatus.AUTHORIZED, + ) + + mock_client = MagicMock() + mock_client.capture_payment_intent.side_effect = _stripe_error() + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_capture_err"}), + content_type="application/json", + ) + + assert response.status_code == 502 + + @patch("django_program.registration.views_terminal.StripeClient") + def test_capture_marks_pending_order_paid(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + order = _make_order(conference=conf, user=user, status=Order.Status.PENDING) + payment = _make_payment(order=order) + TerminalPayment.objects.create( + payment=payment, + conference=conf, + payment_intent_id="pi_capture_pending", + capture_status=TerminalPayment.CaptureStatus.AUTHORIZED, + ) + + mock_client = MagicMock() + mock_client.capture_payment_intent.return_value = MagicMock(id="pi_capture_pending") + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_capture_pending"}), + content_type="application/json", + ) + + assert response.status_code == 200 + order.refresh_from_db() + assert order.status == Order.Status.PAID + + +# ============================================================================= +# CancelPaymentView +# ============================================================================= + + +@pytest.mark.integration +class TestCancelPaymentViewCoverage: + def _url(self, conference: Conference) -> str: + return reverse("registration:terminal-cancel", args=[conference.slug]) + + def test_invalid_json_body(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), data="nope", content_type="application/json") + + assert response.status_code == 400 + + def test_missing_payment_intent_id(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), data=json.dumps({}), content_type="application/json") + + assert response.status_code == 400 + + def test_terminal_payment_not_found(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_missing_cancel"}), + content_type="application/json", + ) + + assert response.status_code == 404 + + def test_stripe_client_init_failure(self) -> None: + conf = _make_conference(stripe_secret_key="") + staff = _make_staff_user() + user = _make_user() + order = _make_order(conference=conf, user=user) + payment = _make_payment(order=order) + TerminalPayment.objects.create( + payment=payment, + conference=conf, + payment_intent_id="pi_cancel_no_key", + capture_status=TerminalPayment.CaptureStatus.AUTHORIZED, + ) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_cancel_no_key"}), + content_type="application/json", + ) + + assert response.status_code == 400 + + @patch("django_program.registration.views_terminal.StripeClient") + def test_cancel_with_reader_id_dispatches_cancel_reader_action(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + order = _make_order(conference=conf, user=user) + payment = _make_payment(order=order) + TerminalPayment.objects.create( + payment=payment, + conference=conf, + payment_intent_id="pi_cancel_reader", + reader_id="tmr_reader_1", + capture_status=TerminalPayment.CaptureStatus.AUTHORIZED, + ) + + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_cancel_reader"}), + content_type="application/json", + ) + + assert response.status_code == 200 + mock_client.cancel_reader_action.assert_called_once_with("tmr_reader_1") + + @patch("django_program.registration.views_terminal.StripeClient") + def test_cancel_stripe_error(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + order = _make_order(conference=conf, user=user) + payment = _make_payment(order=order) + TerminalPayment.objects.create( + payment=payment, + conference=conf, + payment_intent_id="pi_cancel_err", + reader_id="tmr_err", + capture_status=TerminalPayment.CaptureStatus.AUTHORIZED, + ) + + mock_client = MagicMock() + mock_client.cancel_reader_action.side_effect = _stripe_error() + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"payment_intent_id": "pi_cancel_err"}), + content_type="application/json", + ) + + assert response.status_code == 502 + + +# ============================================================================= +# CartOperationsView +# ============================================================================= + + +@pytest.mark.integration +class TestCartOperationsViewCoverage: + def _url(self, conference: Conference) -> str: + return reverse("registration:terminal-cart", args=[conference.slug]) + + def test_invalid_json_body(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post(self._url(conf), data="nope", content_type="application/json") + + assert response.status_code == 400 + + def test_invalid_action(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "destroy"}), + content_type="application/json", + ) + + assert response.status_code == 400 + + def test_update_items_not_a_list(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "update", "items": "not-a-list"}), + content_type="application/json", + ) + + assert response.status_code == 400 + + def test_update_with_unknown_attendee_access_code(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps( + { + "action": "update", + "attendee_access_code": "NOTFOUND99", + "items": [], + } + ), + content_type="application/json", + ) + + assert response.status_code == 404 + + def test_update_with_valid_attendee_access_code(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + attendee = _make_attendee(conference=conf, user=user) + ticket = _make_ticket_type(conference=conf) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps( + { + "action": "update", + "attendee_access_code": attendee.access_code, + "items": [{"ticket_type_id": ticket.pk, "quantity": 2}], + } + ), + content_type="application/json", + ) + + assert response.status_code == 200 + cart = Cart.objects.get(user=user, conference=conf) + assert cart.items.count() == 1 + + def test_update_item_data_not_a_dict(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "update", "items": ["not-a-dict"]}), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + + def test_update_item_invalid_quantity(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + ticket = _make_ticket_type(conference=conf) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps( + { + "action": "update", + "items": [{"ticket_type_id": ticket.pk, "quantity": "not-a-number"}], + } + ), + content_type="application/json", + ) + + assert response.status_code == 400 + assert "quantity" in response.json()["error"] + + def test_update_item_without_ticket_or_addon_id(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "update", "items": [{"quantity": 1}]}), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + + def test_update_item_with_unknown_ticket_type_id(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "update", "items": [{"ticket_type_id": 9999999, "quantity": 1}]}), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + + def test_update_item_with_addon_id_adds_addon(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + addon = _make_addon(conference=conf) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "update", "items": [{"addon_id": addon.pk, "quantity": 3}]}), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["addon_id"] == addon.pk + assert data["items"][0]["quantity"] == 3 + assert data["total"] == str(addon.price * 3) + + def test_update_item_with_unknown_addon_id(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "update", "items": [{"addon_id": 9999999, "quantity": 1}]}), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + + def test_checkout_with_unknown_attendee_access_code(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "checkout", "attendee_access_code": "NOTFOUND88"}), + content_type="application/json", + ) + + assert response.status_code == 404 + + def test_checkout_with_valid_attendee_access_code_but_no_cart(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + user = _make_user() + attendee = _make_attendee(conference=conf, user=user) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "checkout", "attendee_access_code": attendee.access_code}), + content_type="application/json", + ) + + assert response.status_code == 404 + assert "No open cart" in response.json()["error"] + + def test_checkout_no_open_cart(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "checkout"}), + content_type="application/json", + ) + + assert response.status_code == 404 + assert "No open cart" in response.json()["error"] + + def test_checkout_empty_cart(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + Cart.objects.create(user=staff, conference=conf, status=Cart.Status.OPEN) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps({"action": "checkout"}), + content_type="application/json", + ) + + assert response.status_code == 400 + assert "Cart is empty" in response.json()["error"] + + def test_checkout_success_with_billing_info(self) -> None: + conf = _make_conference() + staff = _make_staff_user() + ticket = _make_ticket_type(conference=conf) + cart = Cart.objects.create(user=staff, conference=conf, status=Cart.Status.OPEN) + CartItem.objects.create(cart=cart, ticket_type=ticket, quantity=1) + + client = Client() + client.force_login(staff) + response = client.post( + self._url(conf), + data=json.dumps( + { + "action": "checkout", + "billing_name": "Jane Doe", + "billing_email": "jane@example.com", + } + ), + content_type="application/json", + ) + + assert response.status_code == 200 + data = response.json() + order = Order.objects.get(pk=data["order_id"]) + assert order.billing_name == "Jane Doe" + assert order.billing_email == "jane@example.com" + cart.refresh_from_db() + assert cart.status == Cart.Status.CHECKED_OUT + + +# ============================================================================= +# ListReadersView +# ============================================================================= + + +@pytest.mark.integration +class TestListReadersViewCoverage: + def _url(self, conference: Conference) -> str: + return reverse("registration:terminal-readers", args=[conference.slug]) + + def test_stripe_client_init_failure_returns_400(self) -> None: + conf = _make_conference(stripe_secret_key="") + staff = _make_staff_user() + + client = Client() + client.force_login(staff) + response = client.get(self._url(conf)) + + assert response.status_code == 400 + + @patch("django_program.registration.views_terminal.StripeClient") + def test_list_readers_stripe_error(self, mock_client_cls: MagicMock) -> None: + conf = _make_conference() + staff = _make_staff_user() + + mock_client = MagicMock() + mock_client.list_readers.side_effect = _stripe_error() + mock_client_cls.return_value = mock_client + + client = Client() + client.force_login(staff) + response = client.get(self._url(conf)) + + assert response.status_code == 502 diff --git a/tests/test_registration/test_webhooks_services_coverage.py b/tests/test_registration/test_webhooks_services_coverage.py new file mode 100644 index 0000000..105bca4 --- /dev/null +++ b/tests/test_registration/test_webhooks_services_coverage.py @@ -0,0 +1,525 @@ +"""Coverage backfill tests for webhooks.py and services/purchase_orders.py. + +Targets previously-uncovered branches: metadata-less webhook payloads, +duplicate/terminal payment states, bulk-purchase integration paths in +charge.refunded / checkout.session.completed / checkout.session.expired, +and the PDF-generation helpers in the purchase order service. +""" + +from datetime import date +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest +from django.contrib.auth import get_user_model + +from django_program.conference.models import Conference +from django_program.registration.models import Order, Payment, StripeEvent +from django_program.registration.purchase_order import PurchaseOrderPayment +from django_program.registration.services.purchase_orders import ( + _draw_financial_summary, + _draw_invoice_letterhead, + _invoice_wrap_text, + create_purchase_order, + generate_invoice_pdf, + generate_po_reference, + issue_credit_note, + record_payment, +) +from django_program.registration.webhooks import ( + ChargeRefundedWebhook, + CheckoutSessionCompletedWebhook, + CheckoutSessionExpiredWebhook, + PaymentIntentPaymentFailedWebhook, + PaymentIntentSucceededWebhook, +) +from django_program.sponsors.models import BulkPurchase + +User = get_user_model() + + +# -- Fixtures ----------------------------------------------------------------- + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="TestCon", + slug="testcon-wh-svc", + start_date="2027-06-01", + end_date="2027-06-03", + timezone="UTC", + is_active=True, + stripe_secret_key="sk_test_abc123", + stripe_publishable_key="pk_test_xyz789", + stripe_webhook_secret="whsec_test_secret", + venue="Convention Center", + address="123 Main St", + website_url="https://example.com", + ) + + +@pytest.fixture +def user(db): + return User.objects.create_user( + username="whsvcuser", + email="whsvc@example.com", + password="testpass123", + ) + + +@pytest.fixture +def order(conference, user): + return Order.objects.create( + conference=conference, + user=user, + status=Order.Status.PENDING, + subtotal=Decimal("100.00"), + total=Decimal("100.00"), + reference="ORD-WHSVC001", + ) + + +# ============================================================================= +# PaymentIntentSucceededWebhook — metadata/duplicate edge cases +# ============================================================================= + + +@pytest.mark.unit +@pytest.mark.django_db +class TestPaymentIntentSucceededMissingMetadata: + def test_process_webhook_skips_when_no_order_id(self): + payload = { + "data": { + "object": { + "id": "pi_no_meta_001", + "amount": 5000, + "metadata": {}, + }, + }, + } + event = StripeEvent.objects.create( + stripe_id="evt_pi_no_meta_001", + kind="payment_intent.succeeded", + payload=payload, + ) + handler = PaymentIntentSucceededWebhook(event) + handler.process_webhook() # should not raise, should return early + + assert Payment.objects.count() == 0 + + def test_send_signal_skips_when_no_order_id(self): + payload = { + "data": { + "object": { + "id": "pi_no_meta_002", + "metadata": {}, + }, + }, + } + event = StripeEvent.objects.create( + stripe_id="evt_pi_no_meta_002", + kind="payment_intent.succeeded", + payload=payload, + ) + handler = PaymentIntentSucceededWebhook(event) + handler.send_signal() # should not raise, should return early + + @patch("django_program.registration.webhooks.get_config") + def test_skips_duplicate_already_captured_payment(self, mock_get_config, order): + mock_config = MagicMock() + mock_config.currency = "USD" + mock_get_config.return_value = mock_config + + Payment.objects.create( + order=order, + method=Payment.Method.STRIPE, + status=Payment.Status.SUCCEEDED, + stripe_payment_intent_id="pi_dup_001", + amount=Decimal("100.00"), + ) + + payload = { + "data": { + "object": { + "id": "pi_dup_001", + "amount": 10000, + "metadata": {"order_id": str(order.pk)}, + "latest_charge": "ch_dup_001", + }, + }, + } + event = StripeEvent.objects.create( + stripe_id="evt_pi_dup_001", + kind="payment_intent.succeeded", + payload=payload, + ) + handler = PaymentIntentSucceededWebhook(event) + handler.process_webhook() + + # No new payment was created; still exactly the one SUCCEEDED payment. + assert Payment.objects.filter(order=order).count() == 1 + order.refresh_from_db() + assert order.status == Order.Status.PENDING + + +@pytest.mark.unit +@pytest.mark.django_db +class TestPaymentIntentPaymentFailedMissingMetadata: + def test_process_webhook_skips_when_no_order_id(self): + payload = { + "data": { + "object": { + "id": "pi_fail_no_meta_001", + "metadata": {}, + }, + }, + } + event = StripeEvent.objects.create( + stripe_id="evt_pi_fail_no_meta_001", + kind="payment_intent.payment_failed", + payload=payload, + ) + handler = PaymentIntentPaymentFailedWebhook(event) + handler.process_webhook() # should not raise, should return early + + +# ============================================================================= +# ChargeRefundedWebhook — bulk purchase refund integration +# ============================================================================= + + +@pytest.mark.unit +@pytest.mark.django_db +class TestChargeRefundedBulkPurchase: + @patch("django_program.registration.webhooks.get_config") + def test_full_refund_refunds_matching_bulk_purchase(self, mock_get_config, conference, order): + mock_config = MagicMock() + mock_config.currency = "USD" + mock_get_config.return_value = mock_config + + Payment.objects.create( + order=order, + method=Payment.Method.STRIPE, + status=Payment.Status.SUCCEEDED, + stripe_payment_intent_id="pi_bp_refund_001", + amount=Decimal("100.00"), + ) + bulk_purchase = BulkPurchase.objects.create( + conference=conference, + quantity=5, + unit_price=Decimal("20.00"), + total_amount=Decimal("100.00"), + stripe_payment_intent_id="pi_bp_refund_001", + payment_status=BulkPurchase.PaymentStatus.PAID, + ) + + payload = { + "data": { + "object": { + "payment_intent": "pi_bp_refund_001", + "amount": 10000, + "amount_refunded": 10000, + }, + }, + } + event = StripeEvent.objects.create( + stripe_id="evt_bp_refund_001", + kind="charge.refunded", + payload=payload, + ) + handler = ChargeRefundedWebhook(event) + handler.process_webhook() + + bulk_purchase.refresh_from_db() + assert bulk_purchase.payment_status == BulkPurchase.PaymentStatus.REFUNDED + + +# ============================================================================= +# CheckoutSessionCompletedWebhook +# ============================================================================= + + +@pytest.mark.unit +@pytest.mark.django_db +class TestCheckoutSessionCompletedWebhook: + def _make_event(self, session_id, metadata, payment_status="paid", payment_intent=""): + payload = { + "data": { + "object": { + "id": session_id, + "metadata": metadata, + "payment_status": payment_status, + "payment_intent": payment_intent, + }, + }, + } + return StripeEvent.objects.create( + stripe_id=f"evt_checkout_{session_id}", + kind="checkout.session.completed", + payload=payload, + ) + + def test_skips_when_no_bulk_purchase_id_in_metadata(self, db): + event = self._make_event("cs_no_bp_001", metadata={}) + handler = CheckoutSessionCompletedWebhook(event) + handler.process_webhook() # should not raise, should return early + + def test_defers_when_payment_status_not_paid(self, db): + event = self._make_event( + "cs_unpaid_001", + metadata={"bulk_purchase_id": "1"}, + payment_status="unpaid", + ) + handler = CheckoutSessionCompletedWebhook(event) + handler.process_webhook() # should not raise, should return early + + def test_fulfills_bulk_purchase_and_updates_payment_intent(self, conference): + bulk_purchase = BulkPurchase.objects.create( + conference=conference, + quantity=3, + unit_price=Decimal("10.00"), + total_amount=Decimal("30.00"), + stripe_checkout_session_id="cs_fulfill_001", + stripe_payment_intent_id="", + payment_status=BulkPurchase.PaymentStatus.PROCESSING, + ) + event = self._make_event( + "cs_fulfill_001", + metadata={"bulk_purchase_id": str(bulk_purchase.pk)}, + payment_status="paid", + payment_intent="pi_fulfill_001", + ) + handler = CheckoutSessionCompletedWebhook(event) + + with patch("django_program.sponsors.services.BulkPurchaseService.handle_checkout_webhook") as mock_handle: + mock_handle.return_value = bulk_purchase + handler.process_webhook() + mock_handle.assert_called_once_with("cs_fulfill_001") + + bulk_purchase.refresh_from_db() + assert bulk_purchase.stripe_payment_intent_id == "pi_fulfill_001" + + +# ============================================================================= +# CheckoutSessionExpiredWebhook +# ============================================================================= + + +@pytest.mark.unit +@pytest.mark.django_db +class TestCheckoutSessionExpiredWebhook: + def _make_event(self, session_id): + payload = {"data": {"object": {"id": session_id}}} + return StripeEvent.objects.create( + stripe_id=f"evt_expired_{session_id}", + kind="checkout.session.expired", + payload=payload, + ) + + def test_returns_when_no_matching_bulk_purchase(self, db): + event = self._make_event("cs_no_match_001") + handler = CheckoutSessionExpiredWebhook(event) + handler.process_webhook() # should not raise, should return early + + def test_marks_matching_bulk_purchase_failed(self, conference): + bulk_purchase = BulkPurchase.objects.create( + conference=conference, + quantity=2, + unit_price=Decimal("15.00"), + total_amount=Decimal("30.00"), + stripe_checkout_session_id="cs_expire_001", + payment_status=BulkPurchase.PaymentStatus.PENDING, + ) + event = self._make_event("cs_expire_001") + handler = CheckoutSessionExpiredWebhook(event) + handler.process_webhook() + + bulk_purchase.refresh_from_db() + assert bulk_purchase.payment_status == BulkPurchase.PaymentStatus.FAILED + + +# ============================================================================= +# generate_po_reference — exhaustion path +# ============================================================================= + + +@pytest.mark.integration +@pytest.mark.django_db +class TestGeneratePOReferenceExhaustion: + def test_raises_runtime_error_when_all_attempts_collide(self): + with patch("django_program.registration.services.purchase_orders.PurchaseOrder.objects.filter") as mock_filter: + mock_filter.return_value.exists.return_value = True + with pytest.raises(RuntimeError, match="unique PO reference"): + generate_po_reference() + + +# ============================================================================= +# record_payment / issue_credit_note — validation errors +# ============================================================================= + + +@pytest.fixture +def po_conference(db): + return Conference.objects.create( + name="POCon", + slug="po-con-coverage", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + ) + + +@pytest.fixture +def po_staff_user(db): + return User.objects.create_user( + username="pocoveragestaff", + email="pocoveragestaff@example.com", + password="testpass123", + is_staff=True, + ) + + +@pytest.fixture +def simple_purchase_order(po_conference, po_staff_user): + return create_purchase_order( + conference=po_conference, + organization_name="Coverage Corp", + contact_email="billing@coverage.example", + contact_name="Pat Doe", + billing_address="456 Coverage Ave\nSuite 2", + line_items=[ + { + "description": "Ticket", + "quantity": 2, + "unit_price": Decimal("150.00"), + }, + ], + created_by=po_staff_user, + ) + + +@pytest.mark.integration +@pytest.mark.django_db +class TestRecordPaymentValidation: + def test_raises_for_non_positive_amount(self, simple_purchase_order): + with pytest.raises(ValueError, match="must be positive"): + record_payment( + simple_purchase_order, + amount=Decimal("0.00"), + method=PurchaseOrderPayment.Method.WIRE, + payment_date=date(2027, 5, 1), + ) + + def test_raises_for_invalid_method(self, simple_purchase_order): + with pytest.raises(ValueError, match="Invalid payment method"): + record_payment( + simple_purchase_order, + amount=Decimal("50.00"), + method="bitcoin", + payment_date=date(2027, 5, 1), + ) + + +@pytest.mark.integration +@pytest.mark.django_db +class TestIssueCreditNoteValidation: + def test_raises_for_non_positive_amount(self, simple_purchase_order): + with pytest.raises(ValueError, match="must be positive"): + issue_credit_note( + simple_purchase_order, + amount=Decimal("-5.00"), + reason="Invalid test credit", + ) + + +# ============================================================================= +# generate_invoice_pdf — full render path (letterhead, summary, history) +# ============================================================================= + + +@pytest.mark.integration +@pytest.mark.django_db +class TestGenerateInvoicePdf: + def test_renders_full_invoice_with_credits_and_payments(self, simple_purchase_order, po_staff_user): + record_payment( + simple_purchase_order, + amount=Decimal("100.00"), + method=PurchaseOrderPayment.Method.WIRE, + reference="WIRE-REF-1", + payment_date=date(2027, 5, 1), + entered_by=po_staff_user, + ) + issue_credit_note( + simple_purchase_order, + amount=Decimal("25.00"), + reason="Speaker discount", + issued_by=po_staff_user, + ) + simple_purchase_order.refresh_from_db() + + pdf_bytes = generate_invoice_pdf(simple_purchase_order) + + assert pdf_bytes.startswith(b"%PDF") + assert len(pdf_bytes) > 0 + + def test_letterhead_includes_venue_address_and_website(self, po_conference, simple_purchase_order): + po_conference.venue = "Big Hall" + po_conference.address = "789 Event Blvd" + po_conference.website_url = "https://coverage.example" + po_conference.save(update_fields=["venue", "address", "website_url"]) + + import io + + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.pdfgen import canvas + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + width, _height = A4 + margin = 25 * mm + y = _draw_invoice_letterhead(c, po_conference, margin, 800, width) + + assert y < 800 + + def test_financial_summary_includes_total_credited_line(self, simple_purchase_order, po_staff_user): + issue_credit_note( + simple_purchase_order, + amount=Decimal("10.00"), + reason="Partial credit", + issued_by=po_staff_user, + ) + simple_purchase_order.refresh_from_db() + + import io + + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.pdfgen import canvas + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + width, _height = A4 + margin = 25 * mm + usable_width = width - 2 * margin + y = _draw_financial_summary(c, simple_purchase_order, margin, 500, usable_width) + + assert y < 500 + + +@pytest.mark.unit +class TestInvoiceWrapText: + def test_wraps_long_text_into_multiple_lines(self): + import io + + from reportlab.lib.pagesizes import A4 + from reportlab.pdfgen import canvas + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + + long_text = "word " * 50 + lines = _invoice_wrap_text(c, long_text.strip(), "Helvetica", 10, max_width=50) + + assert len(lines) > 1 diff --git a/tests/test_sponsors/test_admin_models_coverage.py b/tests/test_sponsors/test_admin_models_coverage.py new file mode 100644 index 0000000..32327e5 --- /dev/null +++ b/tests/test_sponsors/test_admin_models_coverage.py @@ -0,0 +1,116 @@ +"""Coverage-focused tests for django_program.sponsors.admin and models.""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.contrib import admin + +from django_program.conference.models import Conference +from django_program.registration.models import Voucher +from django_program.sponsors.admin import BulkPurchaseVoucherInline +from django_program.sponsors.models import BulkPurchase, BulkPurchaseVoucher, Sponsor, SponsorLevel + + +@pytest.fixture +def conference() -> Conference: + return Conference.objects.create( + name="AdminCon", + slug="admincon", + start_date=date(2027, 7, 1), + end_date=date(2027, 7, 3), + timezone="UTC", + ) + + +@pytest.fixture +def level(conference: Conference) -> SponsorLevel: + return SponsorLevel.objects.create( + conference=conference, + name="Gold", + cost=Decimal("5000.00"), + ) + + +@pytest.fixture +def sponsor(conference: Conference, level: SponsorLevel) -> Sponsor: + return Sponsor.objects.create( + conference=conference, + level=level, + name="AdminCo", + ) + + +# --------------------------------------------------------------------------- +# admin.py line 35: BulkPurchaseVoucherInline.has_add_permission always False +# --------------------------------------------------------------------------- + + +def test_bulk_purchase_voucher_inline_denies_add(): + inline = BulkPurchaseVoucherInline(BulkPurchase, admin_site=admin.site) + assert inline.has_add_permission(request=None, obj=None) is False + assert inline.has_add_permission(request=None) is False + + +# --------------------------------------------------------------------------- +# models.py: computed_total (443), vouchers_generated (448), is_fulfilled (453) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_bulk_purchase_computed_total(conference: Conference, sponsor: Sponsor): + bp = BulkPurchase.objects.create( + conference=conference, + sponsor=sponsor, + quantity=4, + unit_price=Decimal("25.00"), + total_amount=Decimal("100.00"), + ) + assert bp.computed_total == Decimal("100.00") + + +@pytest.mark.django_db +def test_bulk_purchase_vouchers_generated_and_is_fulfilled(conference: Conference, sponsor: Sponsor): + bp = BulkPurchase.objects.create( + conference=conference, + sponsor=sponsor, + quantity=2, + unit_price=Decimal("10.00"), + total_amount=Decimal("20.00"), + ) + assert bp.vouchers_generated == 0 + assert bp.is_fulfilled is False + + v1 = Voucher.objects.create(conference=conference, code="MODEL1", voucher_type="comp") + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=v1) + + bp.refresh_from_db() + assert bp.vouchers_generated == 1 + assert bp.is_fulfilled is False + + v2 = Voucher.objects.create(conference=conference, code="MODEL2", voucher_type="comp") + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=v2) + + bp.refresh_from_db() + assert bp.vouchers_generated == 2 + assert bp.is_fulfilled is True + + +# --------------------------------------------------------------------------- +# models.py line 481: BulkPurchaseVoucher.__str__ +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_bulk_purchase_voucher_str(conference: Conference, sponsor: Sponsor): + bp = BulkPurchase.objects.create( + conference=conference, + sponsor=sponsor, + quantity=1, + unit_price=Decimal("10.00"), + total_amount=Decimal("10.00"), + ) + voucher = Voucher.objects.create(conference=conference, code="STRCODE", voucher_type="comp") + link = BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher) + + assert str(link) == f"{bp} → STRCODE" diff --git a/tests/test_sponsors/test_services_coverage.py b/tests/test_sponsors/test_services_coverage.py new file mode 100644 index 0000000..fa59894 --- /dev/null +++ b/tests/test_sponsors/test_services_coverage.py @@ -0,0 +1,434 @@ +"""Coverage-focused tests for django_program.sponsors.services.""" + +import datetime +from datetime import date +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest +from django.contrib.auth import get_user_model + +from django_program.conference.models import Conference +from django_program.registration.models import AddOn, TicketType, Voucher +from django_program.sponsors.models import BulkPurchase, BulkPurchaseVoucher, Sponsor, SponsorLevel +from django_program.sponsors.services import ( + BulkPurchaseError, + BulkPurchaseService, + _parse_datetime, +) + +User = get_user_model() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def conference() -> Conference: + return Conference.objects.create( + name="ServiceCon", + slug="servicecon", + start_date=date(2027, 6, 1), + end_date=date(2027, 6, 3), + timezone="UTC", + stripe_secret_key="sk_test_abc123", + ) + + +@pytest.fixture +def level(conference: Conference) -> SponsorLevel: + return SponsorLevel.objects.create( + conference=conference, + name="Gold", + cost=Decimal("5000.00"), + ) + + +@pytest.fixture +def sponsor(conference: Conference, level: SponsorLevel) -> Sponsor: + return Sponsor.objects.create( + conference=conference, + level=level, + name="Acme Corp", + ) + + +@pytest.fixture +def ticket_type(conference: Conference) -> TicketType: + return TicketType.objects.create( + conference=conference, + name="Regular", + slug="regular", + price=Decimal("349.00"), + bulk_enabled=True, + ) + + +@pytest.fixture +def addon(conference: Conference) -> AddOn: + return AddOn.objects.create( + conference=conference, + name="T-Shirt", + slug="tshirt", + price=Decimal("35.00"), + bulk_enabled=True, + ) + + +@pytest.fixture +def user() -> object: + return User.objects.create_user( + username="requester", + email="requester@example.com", + password="testpass123", + ) + + +def make_bp(conference, sponsor, **overrides) -> BulkPurchase: + defaults: dict[str, object] = { + "conference": conference, + "sponsor": sponsor, + "quantity": 3, + "unit_price": Decimal("100.00"), + "total_amount": Decimal("300.00"), + "payment_status": BulkPurchase.PaymentStatus.PENDING, + "voucher_config": {}, + } + defaults.update(overrides) + return BulkPurchase.objects.create(**defaults) + + +# --------------------------------------------------------------------------- +# _parse_datetime (lines 45-51) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestParseDatetime: + def test_none_returns_none(self): + assert _parse_datetime(None) is None + + def test_empty_string_returns_none(self): + assert _parse_datetime("") is None + + def test_datetime_instance_passthrough(self): + dt = datetime.datetime(2027, 6, 1, 12, 0, tzinfo=datetime.UTC) + assert _parse_datetime(dt) is dt + + def test_iso_string_is_parsed(self): + result = _parse_datetime("2027-06-01T12:00:00+00:00") + assert result == datetime.datetime(2027, 6, 1, 12, 0, tzinfo=datetime.UTC) + + def test_unsupported_type_returns_none(self): + assert _parse_datetime(12345) is None + + +# --------------------------------------------------------------------------- +# create_bulk_purchase (lines 94-122) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestCreateBulkPurchase: + def test_quantity_below_one_raises(self, sponsor): + with pytest.raises(BulkPurchaseError, match="Quantity must be at least 1"): + BulkPurchaseService.create_bulk_purchase( + sponsor=sponsor, + quantity=0, + ticket_type=None, + unit_price=Decimal("10.00"), + ) + + def test_negative_unit_price_raises(self, sponsor): + with pytest.raises(BulkPurchaseError, match="Unit price cannot be negative"): + BulkPurchaseService.create_bulk_purchase( + sponsor=sponsor, + quantity=5, + ticket_type=None, + unit_price=Decimal("-1.00"), + ) + + def test_creates_pending_purchase_with_ticket_type(self, sponsor, ticket_type, user): + bp = BulkPurchaseService.create_bulk_purchase( + sponsor=sponsor, + quantity=4, + ticket_type=ticket_type, + unit_price=Decimal("50.00"), + requested_by=user, + voucher_config={"voucher_type": "comp"}, + ) + assert bp.pk is not None + assert bp.payment_status == BulkPurchase.PaymentStatus.PENDING + assert bp.total_amount == Decimal("200.00") + assert bp.product_description == "4x Regular voucher codes" + assert bp.requested_by == user + assert bp.voucher_config == {"voucher_type": "comp"} + + def test_creates_purchase_without_ticket_type(self, sponsor): + bp = BulkPurchaseService.create_bulk_purchase( + sponsor=sponsor, + quantity=2, + ticket_type=None, + unit_price=Decimal("25.00"), + voucher_config={"voucher_type": "comp"}, + ) + assert bp.product_description == "" + assert bp.voucher_config == {"voucher_type": "comp"} + + +# --------------------------------------------------------------------------- +# create_checkout_session (lines 149-211) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestCreateCheckoutSession: + def test_raises_when_not_pending(self, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PAID) + with pytest.raises(BulkPurchaseError, match="Cannot create checkout"): + BulkPurchaseService.create_checkout_session(bp, "https://ok", "https://cancel") + + def test_raises_for_zero_total(self, conference, sponsor): + bp = make_bp(conference, sponsor, total_amount=Decimal("0.00"), unit_price=Decimal("0.00")) + with pytest.raises(BulkPurchaseError, match="zero-amount"): + BulkPurchaseService.create_checkout_session(bp, "https://ok", "https://cancel") + + @patch("django_program.sponsors.services.StripeClient") + def test_creates_session_and_updates_purchase(self, mock_stripe_cls, conference, sponsor, ticket_type): + bp = make_bp( + conference, + sponsor, + ticket_type=ticket_type, + product_description="3x Regular voucher codes", + ) + mock_client = MagicMock() + mock_stripe_cls.return_value = mock_client + mock_session = MagicMock() + mock_session.id = "cs_test_123" + mock_session.url = "https://checkout.stripe.com/pay/cs_test_123" + mock_client.client.v1.checkout.sessions.create.return_value = mock_session + + url = BulkPurchaseService.create_checkout_session(bp, "https://success", "https://cancel") + + assert url == "https://checkout.stripe.com/pay/cs_test_123" + mock_stripe_cls.assert_called_once_with(conference) + bp.refresh_from_db() + assert bp.stripe_checkout_session_id == "cs_test_123" + assert bp.payment_status == BulkPurchase.PaymentStatus.PROCESSING + + @patch("django_program.sponsors.services.StripeClient") + def test_uses_default_description_when_blank(self, mock_stripe_cls, conference, sponsor): + bp = make_bp(conference, sponsor, product_description="") + mock_client = MagicMock() + mock_stripe_cls.return_value = mock_client + mock_session = MagicMock() + mock_session.id = "cs_test_456" + mock_session.url = "https://checkout.stripe.com/pay/cs_test_456" + mock_client.client.v1.checkout.sessions.create.return_value = mock_session + + BulkPurchaseService.create_checkout_session(bp, "https://success", "https://cancel") + + call_kwargs = mock_client.client.v1.checkout.sessions.create.call_args.kwargs + product_name = call_kwargs["params"]["line_items"][0]["price_data"]["product_data"]["name"] + assert product_name == "Bulk voucher purchase (3 codes)" + + +# --------------------------------------------------------------------------- +# fulfill_bulk_purchase (lines 234-312) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestFulfillBulkPurchase: + def test_already_fulfilled_returns_empty_list(self, conference, sponsor): + bp = make_bp( + conference, + sponsor, + quantity=1, + payment_status=BulkPurchase.PaymentStatus.PAID, + voucher_config={"voucher_type": "comp", "discount_value": 100}, + ) + voucher = Voucher.objects.create(conference=conference, code="EXIST1", voucher_type="comp") + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher) + + result = BulkPurchaseService.fulfill_bulk_purchase(bp) + assert result == [] + + def test_raises_for_disallowed_state(self, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PENDING) + with pytest.raises(BulkPurchaseError, match="Cannot fulfill"): + BulkPurchaseService.fulfill_bulk_purchase(bp) + + def test_raises_for_approved_with_nonzero_total(self, conference, sponsor): + bp = make_bp( + conference, + sponsor, + payment_status=BulkPurchase.PaymentStatus.APPROVED, + total_amount=Decimal("300.00"), + ) + with pytest.raises(BulkPurchaseError, match="payment must be completed"): + BulkPurchaseService.fulfill_bulk_purchase(bp) + + def test_raises_for_missing_voucher_config(self, conference, sponsor): + bp = make_bp( + conference, + sponsor, + payment_status=BulkPurchase.PaymentStatus.PAID, + voucher_config={}, + ) + with pytest.raises(BulkPurchaseError, match="voucher_config is missing"): + BulkPurchaseService.fulfill_bulk_purchase(bp) + + def test_fulfills_comp_from_approved_zero_total(self, conference, sponsor, ticket_type, addon): + bp = make_bp( + conference, + sponsor, + quantity=2, + total_amount=Decimal("0.00"), + unit_price=Decimal("0.00"), + ticket_type=ticket_type, + addon=addon, + payment_status=BulkPurchase.PaymentStatus.APPROVED, + voucher_config={ + "voucher_type": "comp", + "discount_value": 100, + "max_uses": 2, + "prefix": "ACME-", + "valid_from": "2027-06-01T00:00:00+00:00", + "valid_until": "2027-06-03T00:00:00+00:00", + "unlocks_hidden_tickets": True, + }, + ) + + vouchers = BulkPurchaseService.fulfill_bulk_purchase(bp) + + assert len(vouchers) == 2 + for v in vouchers: + assert v.code.startswith("ACME-") + assert v.max_uses == 2 + assert v.unlocks_hidden_tickets is True + + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.PAID + assert BulkPurchaseVoucher.objects.filter(bulk_purchase=bp).count() == 2 + + def test_fulfills_without_sponsor_or_ticket_addon(self, conference): + bp = BulkPurchase.objects.create( + conference=conference, + sponsor=None, + quantity=1, + unit_price=Decimal("10.00"), + total_amount=Decimal("10.00"), + payment_status=BulkPurchase.PaymentStatus.PAID, + voucher_config={"voucher_type": "comp", "discount_value": 100}, + ) + + vouchers = BulkPurchaseService.fulfill_bulk_purchase(bp) + + assert len(vouchers) == 1 + assert vouchers[0].code.startswith("BULK-") + + +# --------------------------------------------------------------------------- +# handle_checkout_webhook (lines 329-363) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestHandleCheckoutWebhook: + def test_returns_none_when_no_matching_purchase(self): + result = BulkPurchaseService.handle_checkout_webhook("cs_does_not_exist") + assert result is None + + def test_returns_existing_when_already_fulfilled(self, conference, sponsor): + bp = make_bp( + conference, + sponsor, + quantity=1, + payment_status=BulkPurchase.PaymentStatus.PAID, + stripe_checkout_session_id="cs_already_done", + ) + voucher = Voucher.objects.create(conference=conference, code="WEBHOOKDONE", voucher_type="comp") + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher) + result = BulkPurchaseService.handle_checkout_webhook("cs_already_done") + assert result == bp + + def test_marks_failed_on_fulfillment_error(self, conference, sponsor): + bp = make_bp( + conference, + sponsor, + payment_status=BulkPurchase.PaymentStatus.PROCESSING, + stripe_checkout_session_id="cs_bad_config", + voucher_config={}, + ) + result = BulkPurchaseService.handle_checkout_webhook("cs_bad_config") + assert result is None + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.FAILED + + def test_fulfills_and_returns_purchase(self, conference, sponsor): + bp = make_bp( + conference, + sponsor, + quantity=1, + payment_status=BulkPurchase.PaymentStatus.PROCESSING, + stripe_checkout_session_id="cs_good", + voucher_config={"voucher_type": "comp", "discount_value": 100}, + ) + result = BulkPurchaseService.handle_checkout_webhook("cs_good") + assert result is not None + assert result.payment_status == BulkPurchase.PaymentStatus.PAID + + +# --------------------------------------------------------------------------- +# refund_bulk_purchase (lines 377-391) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestRefundBulkPurchase: + def test_refunds_and_deactivates_vouchers(self, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PAID) + voucher = Voucher.objects.create(conference=conference, code="REFUND1", voucher_type="comp", is_active=True) + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher) + + BulkPurchaseService.refund_bulk_purchase(bp) + + bp.refresh_from_db() + voucher.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.REFUNDED + assert voucher.is_active is False + + def test_refund_with_no_vouchers(self, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PAID) + + BulkPurchaseService.refund_bulk_purchase(bp) + + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.REFUNDED + + +# --------------------------------------------------------------------------- +# mark_failed (lines 404-419) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestMarkFailed: + def test_marks_pending_as_failed(self, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PENDING) + + BulkPurchaseService.mark_failed(bp) + + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.FAILED + + def test_skips_already_paid_purchase(self, conference, sponsor): + bp = make_bp(conference, sponsor, payment_status=BulkPurchase.PaymentStatus.PAID) + + BulkPurchaseService.mark_failed(bp) + + bp.refresh_from_db() + assert bp.payment_status == BulkPurchase.PaymentStatus.PAID diff --git a/tests/test_sponsors/test_views_coverage.py b/tests/test_sponsors/test_views_coverage.py new file mode 100644 index 0000000..305fbc5 --- /dev/null +++ b/tests/test_sponsors/test_views_coverage.py @@ -0,0 +1,326 @@ +"""Coverage-focused tests for django_program.sponsors.views portal flows.""" + +import csv +import io +from datetime import date +from decimal import Decimal + +import pytest +from django.contrib.auth import get_user_model +from django.test import Client + +from django_program.conference.models import Conference +from django_program.registration.models import TicketType, Voucher +from django_program.sponsors.models import BulkPurchase, BulkPurchaseVoucher, Sponsor, SponsorLevel + +User = get_user_model() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def conference() -> Conference: + return Conference.objects.create( + name="PortalCon", + slug="portalcon", + start_date=date(2027, 8, 1), + end_date=date(2027, 8, 3), + timezone="UTC", + ) + + +@pytest.fixture +def level(conference: Conference) -> SponsorLevel: + return SponsorLevel.objects.create( + conference=conference, + name="Gold", + cost=Decimal("5000.00"), + ) + + +@pytest.fixture +def sponsor(conference: Conference, level: SponsorLevel) -> Sponsor: + return Sponsor.objects.create( + conference=conference, + level=level, + name="PortalCo", + is_active=True, + contact_email="contact@portalco.test", + ) + + +@pytest.fixture +def ticket_type(conference: Conference) -> TicketType: + return TicketType.objects.create( + conference=conference, + name="Regular", + slug="regular", + price=Decimal("349.00"), + bulk_enabled=True, + ) + + +@pytest.fixture +def sponsor_user(sponsor: Sponsor) -> object: + return User.objects.create_user( + username="sponsorcontact", + email="contact@portalco.test", + password="testpass123", + ) + + +@pytest.fixture +def staff_user() -> object: + return User.objects.create_user( + username="staffer", + email="staffer@example.com", + password="testpass123", + is_staff=True, + ) + + +@pytest.fixture +def unrelated_user() -> object: + return User.objects.create_user( + username="nobody", + email="nobody@example.com", + password="testpass123", + ) + + +@pytest.fixture +def no_email_user() -> object: + return User.objects.create_user( + username="noemail", + email="", + password="testpass123", + ) + + +def login(client: Client, user) -> None: + client.force_login(user) + + +# --------------------------------------------------------------------------- +# SponsorPortalMixin.dispatch (lines 144-149) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestDispatch: + def test_anonymous_redirects_to_login(self, client: Client, conference: Conference): + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 302 + assert "/accounts/login/" in response.url + + def test_authenticated_owner_reaches_view(self, client: Client, conference: Conference, sponsor_user, sponsor): + login(client, sponsor_user) + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 200 + assert response.context["sponsor"] == sponsor + assert response.context["conference"] == conference + + +# --------------------------------------------------------------------------- +# _resolve_sponsor (lines 161-179) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestResolveSponsor: + def test_staff_without_active_sponsor_denied(self, client: Client, conference: Conference, staff_user): + login(client, staff_user) + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 403 + + def test_staff_gets_first_active_sponsor(self, client: Client, conference: Conference, staff_user, sponsor): + login(client, staff_user) + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 200 + assert response.context["sponsor"] == sponsor + + def test_user_without_email_denied(self, client: Client, conference: Conference, no_email_user, sponsor): + login(client, no_email_user) + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 403 + + def test_matching_email_grants_access(self, client: Client, conference: Conference, sponsor_user, sponsor): + login(client, sponsor_user) + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 200 + assert response.context["sponsor"] == sponsor + + def test_unrelated_user_denied(self, client: Client, conference: Conference, unrelated_user, sponsor): + login(client, unrelated_user) + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 403 + + +# --------------------------------------------------------------------------- +# SponsorPortalMixin.get_context_data (lines 190-193) + SponsorPortalView (211-215) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_portal_home_lists_purchases(client: Client, conference: Conference, sponsor_user, sponsor, ticket_type): + bp = BulkPurchase.objects.create( + conference=conference, + sponsor=sponsor, + ticket_type=ticket_type, + quantity=5, + unit_price=Decimal("10.00"), + total_amount=Decimal("50.00"), + product_description="5x Regular voucher codes", + ) + login(client, sponsor_user) + response = client.get(f"/{conference.slug}/sponsors/portal/") + assert response.status_code == 200 + assert bp in response.context["purchases"] + assert response.context["conference"] == conference + assert response.context["sponsor"] == sponsor + + +# --------------------------------------------------------------------------- +# BulkPurchaseDetailView.get_context_data (lines 233-252) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_purchase_detail_view_context(client: Client, conference: Conference, sponsor_user, sponsor, ticket_type): + bp = BulkPurchase.objects.create( + conference=conference, + sponsor=sponsor, + ticket_type=ticket_type, + quantity=2, + unit_price=Decimal("10.00"), + total_amount=Decimal("20.00"), + ) + voucher1 = Voucher.objects.create(conference=conference, code="DET1", voucher_type="comp", max_uses=2, times_used=1) + voucher2 = Voucher.objects.create(conference=conference, code="DET2", voucher_type="comp", max_uses=3, times_used=0) + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher1) + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher2) + + login(client, sponsor_user) + response = client.get(f"/{conference.slug}/sponsors/portal/purchases/{bp.pk}/") + + assert response.status_code == 200 + assert response.context["purchase"] == bp + assert len(response.context["voucher_links"]) == 2 + assert response.context["total_uses"] == 1 + assert response.context["total_max"] == 5 + + +@pytest.mark.django_db +def test_purchase_detail_view_404_for_other_sponsor( + client: Client, conference: Conference, sponsor_user, sponsor, level: SponsorLevel +): + other_sponsor = Sponsor.objects.create(conference=conference, level=level, name="Other Sponsor", is_active=True) + other_bp = BulkPurchase.objects.create( + conference=conference, + sponsor=other_sponsor, + quantity=1, + unit_price=Decimal("5.00"), + total_amount=Decimal("5.00"), + ) + + login(client, sponsor_user) + response = client.get(f"/{conference.slug}/sponsors/portal/purchases/{other_bp.pk}/") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# BulkPurchaseExportCSVView.get (lines 269-299) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_export_csv_contains_voucher_rows(client: Client, conference: Conference, sponsor_user, sponsor): + bp = BulkPurchase.objects.create( + conference=conference, + sponsor=sponsor, + quantity=1, + unit_price=Decimal("10.00"), + total_amount=Decimal("10.00"), + ) + voucher = Voucher.objects.create( + conference=conference, + code="CSVCODE1", + voucher_type="comp", + max_uses=1, + times_used=0, + is_active=True, + ) + BulkPurchaseVoucher.objects.create(bulk_purchase=bp, voucher=voucher) + + login(client, sponsor_user) + response = client.get(f"/{conference.slug}/sponsors/portal/purchases/{bp.pk}/export/") + + assert response.status_code == 200 + assert response["Content-Type"] == "text/csv" + assert f"vouchers-purchase-{bp.pk}.csv" in response["Content-Disposition"] + + content = response.content.decode() + reader = csv.reader(io.StringIO(content)) + rows = list(reader) + assert rows[0] == ["code", "times_used", "max_uses", "is_active", "valid_from", "valid_until"] + assert rows[1][0] == "CSVCODE1" + assert rows[1][1] == "0" + assert rows[1][2] == "1" + assert rows[1][3] == "True" + assert rows[1][4] == "" + assert rows[1][5] == "" + + +@pytest.mark.django_db +def test_export_csv_404_for_other_sponsor( + client: Client, conference: Conference, sponsor_user, sponsor, level: SponsorLevel +): + other_sponsor = Sponsor.objects.create(conference=conference, level=level, name="Other Sponsor", is_active=True) + other_bp = BulkPurchase.objects.create( + conference=conference, + sponsor=other_sponsor, + quantity=1, + unit_price=Decimal("5.00"), + total_amount=Decimal("5.00"), + ) + + login(client, sponsor_user) + response = client.get(f"/{conference.slug}/sponsors/portal/purchases/{other_bp.pk}/export/") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# BulkPurchaseRequestView.form_valid (lines 320-343) +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_request_view_creates_pending_purchase_and_redirects( + client: Client, conference: Conference, sponsor_user, sponsor +): + login(client, sponsor_user) + response = client.post( + f"/{conference.slug}/sponsors/portal/purchases/request/", + data={ + "quantity": 7, + "ticket_type_preference": "Corporate", + "notes": "Need these for our booth staff.", + }, + ) + + assert response.status_code == 302 + assert response.url == f"/{conference.slug}/sponsors/portal/" + + bp = BulkPurchase.objects.get(sponsor=sponsor, quantity=7) + assert bp.payment_status == BulkPurchase.PaymentStatus.PENDING + assert bp.product_description == "Corporate" + assert bp.unit_price == Decimal("0.00") + assert bp.total_amount == Decimal("0.00") + assert bp.voucher_config == { + "notes": "Need these for our booth staff.", + "ticket_type_preference": "Corporate", + } + assert bp.requested_by == sponsor_user From 88699383750cc36c940ce367b44bf4c1b668a6a8 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Nov 2026 19:08:49 -0600 Subject: [PATCH 2/3] test: restore the 100% coverage gate --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 35e2230..e5f74f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,7 @@ exclude_lines = [ "@abstractmethod", "class .*(Protocol):", ] -fail_under = 94 +fail_under = 100 [tool.coverage.run] concurrency = ["multiprocessing", "thread"] From 7808181a9b7296b193fabe83298dda2fcbba822c Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Nov 2026 19:48:29 -0600 Subject: [PATCH 3/3] fix(manage): save conference and KPI targets atomically; portable font test - a failed KPI save no longer leaves the conference edit committed - font registration test uses reportlab's bundled Vera.ttf instead of a macOS-only system font, so the 100% gate holds on Linux --- src/django_program/manage/views.py | 11 ++++++----- tests/test_manage/test_views_coverage.py | 21 ++++++++++++++++++++- tests/test_registration/test_badges.py | 13 ++++++------- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index f651af1..fc1f549 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -910,11 +910,12 @@ def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpRes return self.render_to_response(self.get_context_data(form=form, kpi_form=kpi_form)) def _forms_valid(self, form: ConferenceForm, kpi_form: KPITargetsForm) -> HttpResponse: - """Save both the conference and KPI targets forms.""" - response = super().form_valid(form) - kpi = kpi_form.save(commit=False) - kpi.conference = self.conference - kpi.save() + """Save the conference and its KPI targets together.""" + with transaction.atomic(): + response = super().form_valid(form) + kpi = kpi_form.save(commit=False) + kpi.conference = self.conference + kpi.save() messages.success(self.request, "Conference updated successfully.") return response diff --git a/tests/test_manage/test_views_coverage.py b/tests/test_manage/test_views_coverage.py index f9da84a..04243a9 100644 --- a/tests/test_manage/test_views_coverage.py +++ b/tests/test_manage/test_views_coverage.py @@ -7,13 +7,15 @@ from datetime import date from decimal import Decimal +from unittest.mock import patch import pytest from django.contrib.auth.models import User +from django.db import DatabaseError from django.test import Client from django.urls import reverse -from django_program.conference.models import Conference, Expense, ExpenseCategory +from django_program.conference.models import Conference, Expense, ExpenseCategory, KPITargets from django_program.manage.views_analytics import _decimal_to_float from django_program.programs.models import TravelGrant from django_program.registration.models import Order @@ -463,3 +465,20 @@ def test_passes_through_non_decimal(self): """Non-Decimal values are returned unchanged (used as json.dumps default).""" assert _decimal_to_float("already-a-string") == "already-a-string" assert _decimal_to_float(42) == 42 + + +def test_conference_edit_rolls_back_when_kpi_save_fails(client_logged_in_super, conference): + url = reverse("manage:conference-edit", kwargs={"conference_slug": conference.slug}) + data = { + "name": "Renamed Conf", + "start_date": "2027-05-01", + "end_date": "2027-05-03", + "timezone": "UTC", + "total_capacity": 0, + "is_active": "on", + } + with patch.object(KPITargets, "save", side_effect=DatabaseError("kpi write failed")): + with pytest.raises(DatabaseError): + client_logged_in_super.post(url, data) + conference.refresh_from_db() + assert conference.name != "Renamed Conf" diff --git a/tests/test_registration/test_badges.py b/tests/test_registration/test_badges.py index e1a3e37..7277c3e 100644 --- a/tests/test_registration/test_badges.py +++ b/tests/test_registration/test_badges.py @@ -534,21 +534,20 @@ def test_register_pdf_font_not_found(self) -> None: service = BadgeGenerationService() assert service._register_pdf_font("NoSuchFont123.ttf", "TestFont") is None - @pytest.mark.skipif( - not __import__("pathlib").Path("/System/Library/Fonts/Geneva.ttf").exists(), - reason="macOS-only font (Geneva.ttf)", - ) def test_register_pdf_font_success(self) -> None: + from pathlib import Path from unittest.mock import patch + import reportlab + service = BadgeGenerationService() - font_path = "/System/Library/Fonts/Geneva.ttf" + font_path = str(Path(reportlab.__file__).parent / "fonts" / "Vera.ttf") with patch( "django_program.registration.services.badge._resolve_font_path", return_value=font_path, ): - result = service._register_pdf_font("Geneva.ttf", "TestGeneva") - assert result == "TestGeneva" + result = service._register_pdf_font("Vera.ttf", "TestVera") + assert result == "TestVera" def test_register_pdf_font_register_fails(self) -> None: from unittest.mock import patch