528 lines
21 KiB
Python
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,))
|