feat(events): harden registration checkout lifecycle
This commit is contained in:
480
apps/events/tests/integration/test_registration_checkout.py
Normal file
480
apps/events/tests/integration/test_registration_checkout.py
Normal file
@@ -0,0 +1,480 @@
|
||||
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
|
||||
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_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_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_queues_payment_reconciliation(self, mocker):
|
||||
registration, payment = self.create_pending_checkout()
|
||||
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
||||
mocker.patch("apps.events.checkout.transaction.on_commit", side_effect=lambda callback: callback())
|
||||
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
|
||||
enqueue.assert_called_once_with(args=(payment.id,), countdown=0)
|
||||
Reference in New Issue
Block a user