fix(events): keep registration status reads nonblocking
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""PostgreSQL contention checks for the checkout expiry worker and status API."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.db import connection, connections, transaction
|
||||
from django.test import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.events.checkout import expire_stale_registration_checkouts
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.users.models import User
|
||||
from core.authentication import create_jwt_token
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.integration
|
||||
def test_status_reads_and_expiry_worker_progress_around_a_locked_checkout():
|
||||
if connection.vendor != "postgresql":
|
||||
pytest.skip("PostgreSQL row locks are required")
|
||||
|
||||
now = timezone.now()
|
||||
event = Event.objects.create(
|
||||
title="Checkout lock test", slug="checkout-lock-test", description="Lock 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),
|
||||
status=Event.StatusChoices.PUBLISHED, price=1000, capacity=2,
|
||||
)
|
||||
user = User.objects.create_user(username="checkout-lock-owner", mobile="09191112233", password="TestPass123!")
|
||||
other = User.objects.create_user(username="checkout-lock-other", mobile="09191112234", password="TestPass123!")
|
||||
locked = Registration.objects.create(
|
||||
event=event, user=user, status=Registration.StatusChoices.PENDING,
|
||||
checkout_expires_at=now - timedelta(minutes=2),
|
||||
)
|
||||
free = Registration.objects.create(
|
||||
event=event, user=other, status=Registration.StatusChoices.PENDING,
|
||||
checkout_expires_at=now - timedelta(minutes=1),
|
||||
)
|
||||
acquired = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def hold_row_lock():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
Registration.objects.select_for_update().get(pk=locked.pk)
|
||||
acquired.set()
|
||||
release.wait(10)
|
||||
finally:
|
||||
connections.close_all()
|
||||
|
||||
holder = threading.Thread(target=hold_row_lock)
|
||||
holder.start()
|
||||
try:
|
||||
assert acquired.wait(5)
|
||||
start = time.monotonic()
|
||||
response = Client().get(
|
||||
f"/api/events/{event.slug}/registration/status",
|
||||
HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(user)}",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert time.monotonic() - start < 2
|
||||
|
||||
assert expire_stale_registration_checkouts(event_id=event.id) == 1
|
||||
locked.refresh_from_db()
|
||||
free.refresh_from_db()
|
||||
assert locked.status == Registration.StatusChoices.PENDING
|
||||
assert free.status == Registration.StatusChoices.CANCELLED
|
||||
finally:
|
||||
release.set()
|
||||
holder.join(timeout=5)
|
||||
|
||||
assert not holder.is_alive()
|
||||
assert expire_stale_registration_checkouts(event_id=event.id) == 1
|
||||
locked.refresh_from_db()
|
||||
assert locked.status == Registration.StatusChoices.CANCELLED
|
||||
@@ -4,8 +4,9 @@ from datetime import timedelta
|
||||
from django.contrib import admin
|
||||
from django.utils import timezone
|
||||
from apps.events.admin import EventAdmin, RegistrationAdmin
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.events.models import Event, Registration, RegistrationNotification
|
||||
from apps.events.tasks import send_registration_cancellation_notification, send_registration_confirmation_notification
|
||||
from apps.events.tasks import deliver_pending_registration_notifications
|
||||
from apps.users.models import User
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -25,10 +26,33 @@ class TestEventModelTests:
|
||||
|
||||
def test_capacity_counts_confirmed_registration(self, mocker):
|
||||
delay = mocker.patch('apps.events.tasks.send_registration_confirmation_notification.delay')
|
||||
Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
registration = Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
assert self.event.current_attendees_count == 1
|
||||
assert not self.event.has_available_slots
|
||||
delay.assert_called_once()
|
||||
assert RegistrationNotification.objects.filter(
|
||||
registration=registration, kind=RegistrationNotification.Kind.CONFIRMED,
|
||||
).exists()
|
||||
delay.assert_not_called()
|
||||
|
||||
def test_notification_worker_retries_after_redis_failure(self, mocker):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED,
|
||||
)
|
||||
entry = RegistrationNotification.objects.get(registration=registration)
|
||||
notify = mocker.patch("apps.events.tasks.notify_user", side_effect=ConnectionError("redis unavailable"))
|
||||
|
||||
assert deliver_pending_registration_notifications() == 0
|
||||
entry.refresh_from_db()
|
||||
assert entry.delivered_at is None
|
||||
|
||||
notify.side_effect = None
|
||||
assert deliver_pending_registration_notifications() == 1
|
||||
entry.refresh_from_db()
|
||||
registration.refresh_from_db()
|
||||
assert entry.delivered_at is not None
|
||||
assert registration.confirmation_notification_sent_at is not None
|
||||
assert notify.call_args.args[1]["id"] == f"registration-{entry.pk}-confirmed"
|
||||
assert deliver_pending_registration_notifications() == 0
|
||||
|
||||
def test_unlimited_capacity_allows_registration(self):
|
||||
self.event.capacity = None
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from django.test import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.events.models import Event, Registration
|
||||
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
|
||||
@@ -142,6 +142,23 @@ class TestRegistrationCheckout:
|
||||
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
|
||||
@@ -376,6 +393,30 @@ class TestRegistrationCheckout:
|
||||
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)
|
||||
@@ -463,10 +504,11 @@ class TestRegistrationCheckout:
|
||||
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):
|
||||
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")
|
||||
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"])
|
||||
|
||||
@@ -477,4 +519,9 @@ class TestRegistrationCheckout:
|
||||
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)
|
||||
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,))
|
||||
|
||||
Reference in New Issue
Block a user