Files
guilan-ace-backend/apps/events/tests/integration/test_registration_checkout.py

528 lines
21 KiB
Python

import json
import uuid
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from apps.events.models import Event, Registration, RegistrationNotification
from apps.events.checkout import cancel_event_checkouts, expire_stale_registration_checkouts
from apps.payments.models import Payment
from apps.users.models import User
from core.authentication import create_jwt_token
@pytest.mark.django_db
@pytest.mark.integration
class TestRegistrationCheckout:
def setup_method(self):
self.client = Client()
suffix = uuid.uuid4().hex[:10]
self.user = User.objects.create_user(
username=f"checkout-{suffix}",
email=f"checkout-{suffix}@example.com",
mobile=f"0912{suffix[:7]}",
password="CheckoutPass!123",
)
self.token = create_jwt_token(self.user)
now = timezone.now()
self.event = Event.objects.create(
title="Checkout event",
description="Paid checkout behavior test",
start_time=now + timedelta(days=2),
end_time=now + timedelta(days=2, hours=1),
registration_start_date=now - timedelta(days=1),
registration_end_date=now + timedelta(days=1),
slug=f"checkout-{suffix}",
price=1250000,
capacity=1,
status=Event.StatusChoices.PUBLISHED,
)
def auth_headers(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
def create_pending_checkout(self, *, expires_at=None):
now = timezone.now()
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=self.event.price,
checkout_started_at=now,
checkout_expires_at=expires_at or now + timedelta(minutes=15),
)
payment = Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
idempotency_key=uuid.uuid4(),
)
return registration, payment
def test_active_checkout_reserves_capacity_but_can_be_resumed_by_its_owner(self):
registration, _ = self.create_pending_checkout()
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 1
resume_response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
**self.auth_headers(),
)
other = User.objects.create_user(
username=f"other-{uuid.uuid4().hex[:8]}",
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
)
other_token = create_jwt_token(other)
other_response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {other_token}",
)
assert resume_response.status_code == 200
assert resume_response.json()["id"] == registration.id
assert other_response.status_code == 400
assert "ظرفیت" in other_response.json().get("detail", "")
def test_active_checkout_remains_resumable_after_registration_closes(self):
registration, _ = self.create_pending_checkout()
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
self.event.save(update_fields=["registration_end_date"])
response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
**self.auth_headers(),
)
assert response.status_code == 200
assert response.json()["id"] == registration.id
def test_registration_status_returns_resumable_payment_without_changing_legacy_flag(self):
registration, payment = self.create_pending_checkout()
response = self.client.get(
f"/api/events/{self.event.id}/is-registered",
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["is_registered"] is False
assert payload["checkout_state"] == "active"
assert payload["can_resume"] is True
assert payload["registration_id"] == registration.id
assert payload["payment_id"] == payment.id
expected_expiry = registration.checkout_expires_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
assert payload["checkout_expires_at"] == expected_expiry
def test_registration_hub_returns_ready_state_for_user_without_registration(self):
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "ready"
assert payload["event"]["slug"] == self.event.slug
assert payload["registration"] is None
assert payload["payment"] is None
assert payload["can_register"] is True
def test_status_reads_do_not_cancel_an_expired_checkout_when_notifications_are_unavailable(self, mocker):
registration, _ = self.create_pending_checkout(expires_at=timezone.now() - timedelta(seconds=1))
mocker.patch(
"apps.events.tasks.send_registration_cancellation_notification.delay",
side_effect=RuntimeError("broker unavailable"),
)
hub = self.client.get(f"/api/events/{self.event.slug}/registration/status", **self.auth_headers())
legacy = self.client.get(f"/api/events/{self.event.id}/is-registered", **self.auth_headers())
registration.refresh_from_db()
assert hub.status_code == 200
assert hub.json()["state"] == "verification_pending"
assert legacy.status_code == 200
assert registration.status == Registration.StatusChoices.PENDING
assert not RegistrationNotification.objects.filter(registration=registration).exists()
def test_registration_hub_requires_authentication(self):
response = self.client.get(f"/api/events/{self.event.slug}/registration/status")
assert response.status_code == 401
def test_registration_hub_does_not_trust_a_status_query_parameter(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
)
Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
authority=f"AUTH-{uuid.uuid4().hex}",
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status?status=failed",
**self.auth_headers(),
)
assert response.status_code == 200
assert response.json()["state"] == "paid_success"
def test_registration_hub_shows_confirmed_free_registration(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"registration_id": str(registration.ticket_id)},
**self.auth_headers(),
)
assert response.status_code == 200
assert response.json()["state"] == "free_success"
def test_registration_hub_returns_404_for_foreign_payment(self):
other = User.objects.create_user(
username=f"other-{uuid.uuid4().hex[:8]}",
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
)
payment = Payment.objects.create(
user=other,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 404
def test_registration_hub_uses_registration_ticket_uuid_and_exposes_active_payment(self):
registration, payment = self.create_pending_checkout()
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"registration_id": str(registration.ticket_id)},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "active_checkout"
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
assert payload["payment"]["id"] == payment.id
assert payload["can_resume"] is True
assert payload["can_cancel"] is True
def test_registration_hub_hides_another_users_registration(self):
other = User.objects.create_user(
username=f"other-{uuid.uuid4().hex[:8]}",
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
)
registration = Registration.objects.create(
event=self.event,
user=other,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"registration_id": str(registration.ticket_id)},
**self.auth_headers(),
)
assert response.status_code == 404
def test_registration_hub_accepts_legacy_payment_without_linked_registration(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "verification_pending"
assert payload["registration"] is None
assert payload["payment"]["id"] == payment.id
assert payload["can_register"] is False
def test_registration_hub_uses_event_id_for_legacy_events_without_a_slug(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
confirmation_notification_sent_at=timezone.now(),
)
payment = Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
authority=f"AUTH-{uuid.uuid4().hex}",
)
Event.objects.filter(pk=self.event.pk).update(slug="")
response = self.client.get(
f"/api/events/{self.event.pk}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "paid_success"
assert payload["event"]["slug"] == ""
assert payload["payment"]["id"] == payment.id
def test_registration_hub_prefers_active_attempt_over_newer_settled_attempt(self):
confirmed = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
)
Payment.objects.create(
user=self.user,
event=self.event,
registration=confirmed,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
authority=f"AUTH-{uuid.uuid4().hex}",
)
active, active_payment = self.create_pending_checkout()
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "active_checkout"
assert payload["registration"]["ticket_id"] == str(active.ticket_id)
assert payload["payment"]["id"] == active_payment.id
def test_registration_hub_returns_manual_review_and_refund_state_from_saved_records(self):
registration, payment = self.create_pending_checkout()
payment.verification_manual_review_at = timezone.now()
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
payment.save(update_fields=["verification_manual_review_at", "refund_status"])
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "refund_manual_review"
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
assert payload["payment"]["verification_state"] == "manual_review"
assert payload["payment"]["refund_status"] == "manual_review"
def test_expired_unresolved_checkout_releases_registration_and_blocks_duplicate_payment(self):
expired_at = timezone.now() - timedelta(seconds=1)
registration, payment = self.create_pending_checkout(expires_at=expired_at)
response = self.client.post(
"/api/payments/create",
data=json.dumps({
"event_id": self.event.id,
"description": "Retry after abandoned checkout",
"idempotency_key": str(uuid.uuid4()),
}),
content_type="application/json",
**self.auth_headers(),
)
registration.refresh_from_db()
payment.refresh_from_db()
assert response.status_code == 409
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
assert payment.status == Payment.OrderStatusChoices.PENDING
assert Payment.objects.filter(event=self.event, user=self.user).count() == 1
def test_payment_creation_rechecks_window_after_expiring_checkout_without_payment(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
checkout_expires_at=timezone.now() - timedelta(seconds=1),
final_price=self.event.price,
)
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
self.event.save(update_fields=["registration_end_date"])
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Expired checkout",
"idempotency_key": str(uuid.uuid4())}),
content_type="application/json",
**self.auth_headers(),
)
registration.refresh_from_db()
assert response.status_code == 400
assert registration.status == Registration.StatusChoices.CANCELLED
assert not Payment.objects.filter(registration=registration).exists()
def test_expiry_sweep_cancels_only_new_expired_checkouts_and_releases_the_seat(self):
expired_at = timezone.now() - timedelta(seconds=1)
registration, payment = self.create_pending_checkout(expires_at=expired_at)
legacy = Registration.objects.create(
event=self.event,
user=User.objects.create_user(
username=f"legacy-{uuid.uuid4().hex[:8]}",
email=f"legacy-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0914{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
),
status=Registration.StatusChoices.PENDING,
final_price=self.event.price,
)
count = expire_stale_registration_checkouts()
registration.refresh_from_db()
payment.refresh_from_db()
legacy.refresh_from_db()
assert count == 1
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
assert payment.status == Payment.OrderStatusChoices.PENDING
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
assert legacy.status == Registration.StatusChoices.PENDING
def test_user_can_cancel_active_checkout_without_deleting_registration(self, mocker):
registration, payment = self.create_pending_checkout()
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
response = self.client.post(
f"/api/events/{self.event.id}/checkout/cancel",
**self.auth_headers(),
)
registration.refresh_from_db()
payment.refresh_from_db()
assert response.status_code == 200
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.USER_CANCELLED
assert Registration.objects.filter(pk=registration.pk).exists()
assert payment.status == Payment.OrderStatusChoices.PENDING
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
def test_new_checkout_is_blocked_while_late_payment_refund_is_unresolved(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CANCELLED,
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
checkout_started_at=timezone.now() - timedelta(minutes=20),
checkout_expires_at=timezone.now() - timedelta(minutes=5),
final_price=self.event.price,
)
Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
)
register_response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
**self.auth_headers(),
)
payment_response = self.client.post(
"/api/payments/create",
data=json.dumps({
"event_id": self.event.id,
"description": "Do not charge twice while refund is unresolved",
"idempotency_key": str(uuid.uuid4()),
}),
content_type="application/json",
**self.auth_headers(),
)
assert register_response.status_code == 409
assert payment_response.status_code == 409
assert Registration.objects.filter(event=self.event, user=self.user).count() == 1
def test_event_cancellation_ends_checkouts_and_recovers_payment_reconciliation(self, mocker):
registration, payment = self.create_pending_checkout()
from apps.payments.tasks import sweep_stale_payment_verifications
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
self.event.status = Event.StatusChoices.CANCELLED
self.event.save(update_fields=["status"])
cancelled = cancel_event_checkouts(self.event.id)
registration.refresh_from_db()
assert cancelled == 1
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.EVENT_CANCELLED
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
assert RegistrationNotification.objects.filter(
registration=registration,
kind=RegistrationNotification.Kind.CANCELLED,
).exists()
assert sweep_stale_payment_verifications() == 1
enqueue.assert_called_once_with(args=(payment.id,))