feat(events): harden registration checkout lifecycle

This commit is contained in:
2026-10-01 12:26:49 +03:30
parent 1daa755543
commit 962238c32c
29 changed files with 3048 additions and 207 deletions

View File

@@ -3,7 +3,7 @@ import io
import json
import tempfile
import uuid
from datetime import timedelta
from datetime import datetime, time as datetime_time, timedelta
from types import SimpleNamespace
from PIL import Image
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -110,6 +110,115 @@ class TestEventsAPIIntegrationTests:
assert response.status_code == 200
assert any((item['id'] == event.id for item in response.json()))
def test_browse_events_returns_numbered_pages_and_global_price_ceiling(self):
self._create_event(title='Price ceiling 650k toman', price=6500000)
self._create_event(title='Price ceiling 4.3m toman', price=43000000)
for index in range(13):
self._create_event(title=f'Browse page event {index}', price=100000)
first_page = self.client.get('/api/events/browse/', {'page': 1})
second_page = self.client.get('/api/events/browse/', {'page': 2})
assert first_page.status_code == 200
assert first_page.json()['count'] == 17
assert first_page.json()['page'] == 1
assert first_page.json()['page_size'] == 12
assert first_page.json()['total_pages'] == 2
assert len(first_page.json()['results']) == 12
assert second_page.json()['page'] == 2
assert len(second_page.json()['results']) == 5
assert first_page.json()['price_ceiling'] == 5000000
assert 'address' in first_page.json()['results'][0]
def test_browse_events_upcoming_includes_future_events_that_cannot_register(self):
now = timezone.now()
open_event = self._create_event(
title='Browse open event',
start_time=now + timedelta(days=4),
end_time=now + timedelta(days=4, hours=2),
registration_start_date=now - timedelta(days=1),
registration_end_date=now + timedelta(days=2),
)
not_started = self._create_event(
title='Browse not started event',
start_time=now + timedelta(days=5),
end_time=now + timedelta(days=5, hours=2),
registration_start_date=now + timedelta(days=1),
)
closed = self._create_event(
title='Browse closed event',
start_time=now + timedelta(days=6),
end_time=now + timedelta(days=6, hours=2),
registration_end_date=now - timedelta(minutes=1),
)
full = self._create_event(
title='Browse full event',
start_time=now + timedelta(days=7),
end_time=now + timedelta(days=7, hours=2),
capacity=1,
)
self._create_registration(full, self.user, status=Registration.StatusChoices.CONFIRMED)
upcoming = self.client.get('/api/events/browse/', {'state': 'upcoming', 'search': 'Browse'}).json()
open_results = self.client.get('/api/events/browse/', {'state': 'open', 'search': 'Browse'}).json()
upcoming_ids = {item['id'] for item in upcoming['results']}
open_ids = {item['id'] for item in open_results['results']}
assert {not_started.id, closed.id, full.id}.issubset(upcoming_ids)
assert open_event.id not in upcoming_ids
assert open_ids == {open_event.id}
def test_browse_events_filters_start_dates_inclusive_and_combines_type_price_and_free(self):
selected_day = timezone.localdate()
local_timezone = timezone.get_current_timezone()
def at(day, hour, minute):
return timezone.make_aware(datetime.combine(day, datetime_time(hour, minute)), local_timezone)
start_day = self._create_event(
title='Date range probe free',
event_type=Event.TypeChoices.ONLINE,
price=0,
start_time=at(selected_day, 0, 0),
end_time=at(selected_day, 2, 0),
)
end_day = self._create_event(
title='Date range probe paid online',
event_type=Event.TypeChoices.ONLINE,
price=300000,
start_time=at(selected_day, 23, 59),
end_time=at(selected_day + timedelta(days=1), 1, 0),
)
self._create_event(
title='Date range probe paid onsite',
event_type=Event.TypeChoices.ON_SITE,
price=300000,
start_time=at(selected_day, 12, 0),
end_time=at(selected_day, 14, 0),
)
outside_day = self._create_event(
title='Date range probe outside',
event_type=Event.TypeChoices.ONLINE,
price=300000,
start_time=at(selected_day + timedelta(days=1), 0, 0),
end_time=at(selected_day + timedelta(days=1), 2, 0),
)
params = {
'search': 'Date range probe',
'date_from': selected_day.isoformat(),
'date_to': selected_day.isoformat(),
'event_type': Event.TypeChoices.ONLINE,
'price_min': 10000,
'price_max': 40000,
}
paid_results = self.client.get('/api/events/browse/', params).json()
free_results = self.client.get('/api/events/browse/', {**params, 'free_only': 'true'}).json()
assert {item['id'] for item in paid_results['results']} == {end_day.id}
assert {item['id'] for item in free_results['results']} == {start_day.id}
assert outside_day.id not in {item['id'] for item in paid_results['results']}
def test_list_events_handles_comma_status_parameter(self):
event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED)
results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0)

View 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)