fix(events): keep registration status reads nonblocking

This commit is contained in:
2026-10-01 13:35:17 +03:30
parent 962238c32c
commit f924fcc52e
16 changed files with 498 additions and 107 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,))