test: migrate backend suites to pytest with module coverage gate

Split backend tests into unit and integration suites, add isolated fixtures and coverage enforcement, and update CI and test documentation. Cover current authentication, content, notification, payment, media, and admin flows; fix behaviors revealed by the new tests. Validate both suites and the per-module gate against local PostgreSQL.
This commit is contained in:
2026-09-26 16:50:29 +03:30
parent 3ec28638d1
commit ff99130938
76 changed files with 3271 additions and 1357 deletions

View File

@@ -22,9 +22,9 @@ class EventGallerySchema(ModelSchema):
absolute_image_preview_url: Optional[str] = None
absolute_image_blur_url: Optional[str] = None
class Config:
class Meta:
model = Gallery
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
fields = ['id', 'title', 'description', 'image', 'alt_text',
'width', 'height', 'is_public', 'created_at']
@staticmethod
@@ -55,9 +55,9 @@ class EventSchema(ModelSchema):
absolute_featured_image_thumbnail_url: Optional[str] = None
absolute_featured_image_preview_url: Optional[str] = None
class Config:
class Meta:
model = Event
model_fields = [
fields = [
'id', 'title', 'slug', 'description', 'featured_image', 'event_type',
'address', 'location', 'online_link', 'start_time', 'end_time',
'registration_start_date', 'registration_end_date', 'registration_success_markdown',
@@ -179,9 +179,9 @@ class RegistrationSchema(ModelSchema):
event: EventListSchema
discount_code: str | None = None
class Config:
class Meta:
model = Registration
model_fields = [
fields = [
'id',
'status',
'registered_at',

View File

@@ -0,0 +1,61 @@
import json
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.events.models import Event, Registration
from apps.gallery.models import Gallery
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def _put(client, path, payload, headers):
return client.put(path, data=json.dumps(payload), content_type="application/json", **headers)
def test_event_reschedule_and_cancellation_notify_registered_users(client, user_factory, auth_headers,
event_factory, mocker):
staff = user_factory(is_staff=True)
attendee = user_factory(is_mobile_verified=True)
event = event_factory()
Registration.objects.create(event=event, user=attendee, status=Registration.StatusChoices.CONFIRMED)
notify = mocker.patch("apps.events.api.views.notify_user")
sms = mocker.patch("apps.events.api.views.send_critical_sms.delay")
path = f"/api/events/{event.pk}"
assert _put(client, path, {"location": "New hall"}, auth_headers(attendee)).status_code == 403
changed = _put(client, path, {"location": "New hall"}, auth_headers(staff))
assert changed.status_code == 200, changed.content
event.refresh_from_db()
assert event.location == "New hall"
assert notify.call_args.args[1]["type"] == "event_rescheduled"
sms.assert_called_with(attendee.mobile, "event_reschedule", event.title)
cancelled = _put(client, path, {"status": Event.StatusChoices.CANCELLED}, auth_headers(staff))
assert cancelled.status_code == 200, cancelled.content
event.refresh_from_db()
assert event.status == Event.StatusChoices.CANCELLED
assert notify.call_args.args[1]["type"] == "event_cancelled"
assert sms.call_count == 2
def test_event_gallery_upload_validation_and_removal(client, user_factory, auth_headers,
event_factory, mocker, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
staff = user_factory(is_staff=True)
member = user_factory()
event = event_factory()
headers = auth_headers(staff)
path = f"/api/events/{event.pk}/gallery"
queued = mocker.patch("apps.events.api.views.process_uploaded_image.delay")
assert client.get(path, **auth_headers(member)).status_code == 403
invalid = client.post(path, {"file": SimpleUploadedFile("bad.txt", b"bad", content_type="text/plain")}, **headers)
assert invalid.status_code == 400
uploaded = client.post(path, {"file": SimpleUploadedFile("photo.png", b"image", content_type="image/png")}, **headers)
assert uploaded.status_code == 201, uploaded.content
image = Gallery.objects.get(pk=uploaded.json()["id"])
assert event.gallery_images.filter(pk=image.pk).exists()
queued.assert_called_once_with(image.pk)
assert client.get(path, **headers).json()[0]["id"] == image.pk
assert client.delete(f"{path}/{image.pk}", **headers).status_code == 200
assert not event.gallery_images.filter(pk=image.pk).exists()
assert Gallery.deleted_objects.filter(pk=image.pk).exists()

View File

@@ -0,0 +1,35 @@
import pytest
from types import SimpleNamespace
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.tasks import send_registration_cancellation_notification, send_registration_confirmation_notification
from apps.users.models import User
@pytest.mark.django_db
@pytest.mark.integration
class TestEventModelTests:
def setup_method(self):
now = timezone.now()
self.event = Event.objects.create(title='Workshop', description='**Important** details', start_time=now + timedelta(days=1), end_time=now + timedelta(days=1, hours=2), capacity=1, registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(hours=1))
self.user = User.objects.create_user(username='attendee', mobile='09191110003', password='password')
def test_markdown_description_and_registration_window_remain_available(self):
assert '<strong>Important</strong>' in self.event.description_html
assert self.event.is_registration_open
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
assert not self.event.is_registration_open
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)
assert self.event.current_attendees_count == 1
assert not self.event.has_available_slots
delay.assert_called_once()
def test_unlimited_capacity_allows_registration(self):
self.event.capacity = None
assert self.event.has_available_slots

View File

@@ -1,117 +1,66 @@
import pytest
import io
import json
import tempfile
import uuid
from datetime import timedelta
from types import SimpleNamespace
from PIL import Image
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.utils import timezone
from core.authentication import create_jwt_token
from apps.events.api.schemas import (
EventSchema,
EventGallerySchema,
EventListSchema,
RegistrationSchema,
PaymentAdminSchema,
EventAdminDetailSchema,
)
from apps.events.api.schemas import EventSchema, EventGallerySchema, EventListSchema, RegistrationSchema, PaymentAdminSchema, EventAdminDetailSchema
from apps.events.api.views import list_events
from apps.events.models import Event, Registration
from apps.gallery.models import Gallery
from apps.payments.models import DiscountCode
from apps.users.models import Major, University, User
MEDIA_ROOT = tempfile.mkdtemp()
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
class EventsAPIIntegrationTests(TestCase):
password = "TestPass123!"
@pytest.mark.django_db
@pytest.mark.integration
class TestEventsAPIIntegrationTests:
password = 'TestPass123!'
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="event_user",
email="event.user@example.com",
mobile="09198000001",
password=cls.password,
)
cls.user = User.objects.create_user(username='event_user', email='event.user@example.com', mobile='09198000001', password=cls.password)
cls.user.is_email_verified = True
cls.user.save(update_fields=["is_email_verified"])
cls.staff = User.objects.create_user(
username="event_staff",
email="event.staff@example.com",
mobile="09198000002",
password=cls.password,
is_staff=True,
)
cls.user.save(update_fields=['is_email_verified'])
cls.staff = User.objects.create_user(username='event_staff', email='event.staff@example.com', mobile='09198000002', password=cls.password, is_staff=True)
cls.staff.is_email_verified = True
cls.staff.save(update_fields=["is_email_verified"])
cls.major, _ = Major.objects.get_or_create(code="CS", defaults={"name": "Computer Science"})
cls.university, _ = University.objects.get_or_create(code="UT", defaults={"name": "University of Tehran"})
cls.staff.save(update_fields=['is_email_verified'])
cls.major, _ = Major.objects.get_or_create(code='CS', defaults={'name': 'Computer Science'})
cls.university, _ = University.objects.get_or_create(code='UT', defaults={'name': 'University of Tehran'})
cls.user.major = cls.major
cls.user.university = cls.university
cls.user.save(update_fields=["major", "university"])
cls.user.save(update_fields=['major', 'university'])
cls.staff.major = cls.major
cls.staff.university = cls.university
cls.staff.save(update_fields=["major", "university"])
cls.staff.save(update_fields=['major', 'university'])
def setUp(self):
super().setUp()
def setup_method(self):
type(self).setUpTestData()
self.token = create_jwt_token(self.user)
self.staff_token = create_jwt_token(self.staff)
self.event = self._create_event(
title="Integration Event",
description="Integration description.",
status=Event.StatusChoices.PUBLISHED,
price=0,
)
self.other_event = self._create_event(
title="Other Published",
description="Searchable",
status=Event.StatusChoices.PUBLISHED,
price=0,
)
self.event = self._create_event(title='Integration Event', description='Integration description.', status=Event.StatusChoices.PUBLISHED, price=0)
self.other_event = self._create_event(title='Other Published', description='Searchable', status=Event.StatusChoices.PUBLISHED, price=0)
def _auth_headers(self, token):
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
return {'HTTP_AUTHORIZATION': f'Bearer {token}'}
def _create_event(self, **overrides):
now = timezone.now()
defaults = {
"title": "Event Title",
"description": "Description",
"start_time": now,
"end_time": now + timedelta(hours=2),
"registration_start_date": now - timedelta(days=1),
"registration_end_date": now + timedelta(days=5),
"slug": f"event-{uuid.uuid4().hex[:6]}",
"location": "Campus",
"online_link": "https://meet.example.com",
"price": 0,
"capacity": 10,
"status": Event.StatusChoices.PUBLISHED,
}
defaults = {'title': 'Event Title', 'description': 'Description', 'start_time': now, 'end_time': now + timedelta(hours=2), 'registration_start_date': now - timedelta(days=1), 'registration_end_date': now + timedelta(days=5), 'slug': f'event-{uuid.uuid4().hex[:6]}', 'location': 'Campus', 'online_link': 'https://meet.example.com', 'price': 0, 'capacity': 10, 'status': Event.StatusChoices.PUBLISHED}
defaults.update(overrides)
return Event.objects.create(**defaults)
def _create_gallery_image(self):
buffer = io.BytesIO()
Image.new("RGB", (10, 10), color="blue").save(buffer, format="PNG")
Image.new('RGB', (10, 10), color='blue').save(buffer, format='PNG')
buffer.seek(0)
file = SimpleUploadedFile("gallery.png", buffer.read(), content_type="image/png")
return Gallery.objects.create(
title="Gallery image",
description="desc",
image=file,
uploaded_by=self.user,
)
file = SimpleUploadedFile('gallery.png', buffer.read(), content_type='image/png')
return Gallery.objects.create(title='Gallery image', description='desc', image=file, uploaded_by=self.user)
def _create_paid_event(self):
return self._create_event(price=30000, capacity=5)
@@ -119,430 +68,230 @@ class EventsAPIIntegrationTests(TestCase):
def _create_registration(self, event, user, status=Registration.StatusChoices.PENDING):
return Registration.objects.create(event=event, user=user, status=status, final_price=event.price)
# Basic event endpoints ------------------------------------------------
def test_list_events_filters_and_search(self):
# Act
response = self.client.get("/api/events/", {"status": "published", "search": "Searchable"})
response = self.client.get('/api/events/', {'status': 'published', 'search': 'Searchable'})
data = response.json()
# Assert
self.assertEqual(response.status_code, 200)
self.assertTrue(any(item["id"] == self.other_event.id for item in data))
assert response.status_code == 200
assert any((item['id'] == self.other_event.id for item in data))
def test_get_event_by_id_and_slug(self):
response_id = self.client.get(f"/api/events/{self.event.id}")
response_slug = self.client.get(f"/api/events/slug/{self.event.slug}")
self.assertEqual(response_id.status_code, 200)
self.assertEqual(response_slug.status_code, 200)
self.assertEqual(response_id.json()["id"], self.event.id)
self.assertEqual(response_slug.json()["slug"], self.event.slug)
response_id = self.client.get(f'/api/events/{self.event.id}')
response_slug = self.client.get(f'/api/events/slug/{self.event.slug}')
assert response_id.status_code == 200
assert response_slug.status_code == 200
assert response_id.json()['id'] == self.event.id
assert response_slug.json()['slug'] == self.event.slug
def test_create_update_and_delete_event(self):
payload = {
"title": "New Event",
"description": "Desc",
"start_time": (timezone.now() + timedelta(days=1)).isoformat(),
"end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(),
"event_type": Event.TypeChoices.ON_SITE,
"status": Event.StatusChoices.DRAFT,
"price": 5000,
}
created = self.client.post(
"/api/events/",
data=json.dumps(payload),
content_type="application/json",
**self._auth_headers(self.staff_token),
)
self.assertEqual(created.status_code, 201)
event_id = created.json()["id"]
updated = self.client.put(
f"/api/events/{event_id}",
data=json.dumps({"title": "Updated Event"}),
content_type="application/json",
**self._auth_headers(self.staff_token),
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.json()["title"], "Updated Event")
deleted = self.client.delete(f"/api/events/{event_id}", **self._auth_headers(self.staff_token))
self.assertEqual(deleted.status_code, 200)
payload = {'title': 'New Event', 'description': 'Desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'status': Event.StatusChoices.DRAFT, 'price': 5000}
created = self.client.post('/api/events/', data=json.dumps(payload), content_type='application/json', **self._auth_headers(self.staff_token))
assert created.status_code == 201
event_id = created.json()['id']
updated = self.client.put(f'/api/events/{event_id}', data=json.dumps({'title': 'Updated Event'}), content_type='application/json', **self._auth_headers(self.staff_token))
assert updated.status_code == 200
assert updated.json()['title'] == 'Updated Event'
deleted = self.client.delete(f'/api/events/{event_id}', **self._auth_headers(self.staff_token))
assert deleted.status_code == 200
def test_admin_detail_and_registration_list_requires_staff(self):
staff_headers = self._auth_headers(self.staff_token)
user_headers = self._auth_headers(self.token)
_ = self._create_registration(self.event, self.user, status=Registration.StatusChoices.CONFIRMED)
# Non staff forbidden
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **user_headers)
self.assertEqual(list_resp.status_code, 403)
# Staff allowed
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **staff_headers)
detail_resp = self.client.get(f"/api/events/{self.event.id}/admin-detail", **staff_headers)
self.assertEqual(list_resp.status_code, 200)
self.assertEqual(detail_resp.status_code, 200)
list_resp = self.client.get(f'/api/events/{self.event.id}/admin-registrations', **user_headers)
assert list_resp.status_code == 403
list_resp = self.client.get(f'/api/events/{self.event.id}/admin-registrations', **staff_headers)
detail_resp = self.client.get(f'/api/events/{self.event.id}/admin-detail', **staff_headers)
assert list_resp.status_code == 200
assert detail_resp.status_code == 200
def test_list_events_filters_by_event_type_and_search(self):
event = self._create_event(
title="Special Search",
description="Unique discovery",
event_type=Event.TypeChoices.ONLINE,
status=Event.StatusChoices.PUBLISHED,
)
response = self.client.get(
"/api/events/",
{
"event_type": Event.TypeChoices.ONLINE,
"search": "Unique discovery",
},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(any(item["id"] == event.id for item in response.json()))
event = self._create_event(title='Special Search', description='Unique discovery', event_type=Event.TypeChoices.ONLINE, status=Event.StatusChoices.PUBLISHED)
response = self.client.get('/api/events/', {'event_type': Event.TypeChoices.ONLINE, 'search': 'Unique discovery'})
assert response.status_code == 200
assert any((item['id'] == event.id for item in response.json()))
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,
)
self.assertIn(event, list(results))
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)
assert event in list(results)
def test_create_event_attaches_gallery_images(self):
gallery = self._create_gallery_image()
payload = {
"title": "Gallery Event",
"description": "Gallery desc",
"start_time": (timezone.now() + timedelta(days=1)).isoformat(),
"end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(),
"event_type": Event.TypeChoices.ON_SITE,
"status": Event.StatusChoices.DRAFT,
"price": 5000,
"gallery_image_ids": [gallery.id],
}
response = self.client.post(
"/api/events/",
data=json.dumps(payload),
content_type="application/json",
**self._auth_headers(self.staff_token),
)
payload = {'title': 'Gallery Event', 'description': 'Gallery desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'status': Event.StatusChoices.DRAFT, 'price': 5000, 'gallery_image_ids': [gallery.id]}
response = self.client.post('/api/events/', data=json.dumps(payload), content_type='application/json', **self._auth_headers(self.staff_token))
body = response.json()
self.assertEqual(response.status_code, 201)
self.assertTrue(body["gallery_images"])
updated = self.client.put(
f"/api/events/{body['id']}",
data=json.dumps(
{
"title": "Gallery Event Updated",
"gallery_image_ids": [gallery.id],
}
),
content_type="application/json",
**self._auth_headers(self.staff_token),
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.json()["slug"], "gallery-event-updated")
self.assertTrue(updated.json()["gallery_images"])
assert response.status_code == 201
assert body['gallery_images']
updated = self.client.put(f'/api/events/{body['id']}', data=json.dumps({'title': 'Gallery Event Updated', 'gallery_image_ids': [gallery.id]}), content_type='application/json', **self._auth_headers(self.staff_token))
assert updated.status_code == 200
assert updated.json()['slug'] == 'gallery-event-updated'
assert updated.json()['gallery_images']
def test_admin_registration_filters_include_university_major_and_search(self):
event = self.event
self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
headers = self._auth_headers(self.staff_token)
response = self.client.get(
f"/api/events/{event.id}/admin-registrations",
{
"university": self.user.university.code,
"major": self.user.major.code,
"search": self.user.username,
"status": [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING],
},
**headers,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
response = self.client.get(f'/api/events/{event.id}/admin-registrations', {'university': self.user.university.code, 'major': self.user.major.code, 'search': self.user.username, 'status': [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING]}, **headers)
assert response.status_code == 200
assert response.json()['count'] == 1
def test_register_before_start_and_after_end_dates_fail(self):
future_event = self._create_event(registration_start_date=timezone.now() + timedelta(days=1))
future_response = self.client.post(
f"/api/events/{future_event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(future_response.status_code, 400)
future_response = self.client.post(f'/api/events/{future_event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}')
assert future_response.status_code == 400
closed_event = self._create_event(registration_end_date=timezone.now() - timedelta(hours=1))
closed_response = self.client.post(
f"/api/events/{closed_event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(closed_response.status_code, 400)
closed_response = self.client.post(f'/api/events/{closed_event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}')
assert closed_response.status_code == 400
def test_register_recreates_after_cancelled_registration(self):
event = self._create_event(price=0)
Registration.objects.create(
event=event,
user=self.user,
status=Registration.StatusChoices.CANCELLED,
final_price=0,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
Registration.objects.create(event=event, user=self.user, status=Registration.StatusChoices.CANCELLED, final_price=0)
response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json')
assert response.status_code == 200
assert response.json()['status'] == Registration.StatusChoices.CONFIRMED
def test_register_updates_final_price_when_none(self):
event = self._create_paid_event()
registration = Registration.objects.create(
event=event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=None,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["final_price"], event.price)
registration = Registration.objects.create(event=event, user=self.user, status=Registration.StatusChoices.PENDING, final_price=None)
response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json')
assert response.status_code == 200
assert response.json()['final_price'] == event.price
def _create_discount_code(self, event):
code = DiscountCode.objects.create(
code=f"CODE-{uuid.uuid4().hex[:4]}",
value=50,
type=DiscountCode.Type.PERCENT,
is_active=True,
)
code = DiscountCode.objects.create(code=f'CODE-{uuid.uuid4().hex[:4]}', value=50, type=DiscountCode.Type.PERCENT, is_active=True)
code.applicable_events.add(event)
return code
def test_register_for_event_with_free_price_confirms(self):
event = self._create_event(price=0)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json')
assert response.status_code == 200
assert response.json()['status'] == Registration.StatusChoices.CONFIRMED
def test_register_for_event_with_discount_updates_final_price(self):
event = self._create_paid_event()
code = self._create_discount_code(event)
response = self.client.post(
f"/api/events/{event.id}/register",
data=json.dumps({"discount_code": code.code}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
response = self.client.post(f'/api/events/{event.id}/register', data=json.dumps({'discount_code': code.code}), content_type='application/json', HTTP_AUTHORIZATION=f'Bearer {self.token}')
result = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(result["discount_code"], code.code)
self.assertEqual(result["discount_amount"], event.price // 2)
self.assertEqual(result["final_price"], event.price // 2)
assert response.status_code == 200
assert result['discount_code'] == code.code
assert result['discount_amount'] == event.price // 2
assert result['final_price'] == event.price // 2
def test_register_fails_when_capacity_full(self):
event = self._create_event(capacity=1)
other = self._create_event_user("other_user", "other@example.com")
Registration.objects.create(
event=event,
user=other,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
other = self._create_event_user('other_user', 'other@example.com')
Registration.objects.create(event=event, user=other, status=Registration.StatusChoices.CONFIRMED, final_price=0)
response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json')
assert response.status_code == 400
def _create_event_user(self, username, email):
suffix = str(abs(hash(username)) % 1_000_000).zfill(6)
user = User.objects.create_user(username=username, email=email, mobile=f"09190{suffix}", password=self.password)
suffix = str(abs(hash(username)) % 1000000).zfill(6)
user = User.objects.create_user(username=username, email=email, mobile=f'09190{suffix}', password=self.password)
user.is_email_verified = True
user.save(update_fields=["is_email_verified"])
user.save(update_fields=['is_email_verified'])
user.major = self.user.major
user.university = self.user.university
user.save(update_fields=["major", "university"])
user.save(update_fields=['major', 'university'])
return user
def test_register_rejects_duplicate_confirmed(self):
event = self._create_event(price=0)
Registration.objects.create(
event=event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
Registration.objects.create(event=event, user=self.user, status=Registration.StatusChoices.CONFIRMED, final_price=0)
response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json')
assert response.status_code == 400
def test_registration_status_update_and_cancel(self):
event = self._create_event(price=0)
registration = self._create_registration(event, self.user)
update = self.client.put(
f"/api/events/registrations/{registration.id}",
data=json.dumps({"status": Registration.StatusChoices.ATTENDED}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(update.status_code, 200)
self.assertEqual(update.json()["status"], Registration.StatusChoices.ATTENDED)
cancel = self.client.delete(
f"/api/events/registrations/{registration.id}",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(cancel.status_code, 200)
self.assertEqual(cancel.json()["message"], "ثبت‌نام شما لغو شد :(")
update = self.client.put(f'/api/events/registrations/{registration.id}', data=json.dumps({'status': Registration.StatusChoices.ATTENDED}), content_type='application/json', HTTP_AUTHORIZATION=f'Bearer {self.token}')
assert update.status_code == 200
assert update.json()['status'] == Registration.StatusChoices.ATTENDED
cancel = self.client.delete(f'/api/events/registrations/{registration.id}', HTTP_AUTHORIZATION=f'Bearer {self.token}')
assert cancel.status_code == 200
assert cancel.json()['message'] == 'ثبت\u200cنام شما لغو شد :('
def test_verify_registration_and_my_registrations(self):
event = self._create_event(price=0)
registration = self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
verify = self.client.get(
f"/api/events/registerations/verify/{registration.ticket_id}",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(verify.status_code, 200)
self.assertEqual(verify.json()["ticket_id"], str(registration.ticket_id))
my_regs = self.client.get(
"/api/events/my-registrations",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(my_regs.status_code, 200)
self.assertGreater(len(my_regs.json()), 0)
status_resp = self.client.get(
f"/api/events/{event.id}/is-registered",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(status_resp.status_code, 200)
self.assertTrue(status_resp.json()["is_registered"])
verify = self.client.get(f'/api/events/registerations/verify/{registration.ticket_id}', HTTP_AUTHORIZATION=f'Bearer {self.token}')
assert verify.status_code == 200
assert verify.json()['ticket_id'] == str(registration.ticket_id)
my_regs = self.client.get('/api/events/my-registrations', HTTP_AUTHORIZATION=f'Bearer {self.token}')
assert my_regs.status_code == 200
assert len(my_regs.json()) > 0
status_resp = self.client.get(f'/api/events/{event.id}/is-registered', HTTP_AUTHORIZATION=f'Bearer {self.token}')
assert status_resp.status_code == 200
assert status_resp.json()['is_registered']
def test_list_event_registrations(self):
event = self.event
self._create_registration(event, self.user)
response = self.client.get(f"/api/events/{event.id}/registrations")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json())
response = self.client.get(f'/api/events/{event.id}/registrations')
assert response.status_code == 200
assert response.json()
def test_list_event_registrations_admin_filters(self):
event = self.event
self._create_registration(event, self.user, status=Registration.StatusChoices.PENDING)
headers = self._auth_headers(self.staff_token)
response = self.client.get(
f"/api/events/{event.id}/admin-registrations",
{"status": [Registration.StatusChoices.PENDING]},
**headers,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
response = self.client.get(f'/api/events/{event.id}/admin-registrations', {'status': [Registration.StatusChoices.PENDING]}, **headers)
assert response.status_code == 200
assert response.json()['count'] == 1
@pytest.fixture(autouse=True)
def _pytest_client(self, client):
self.client = client
class EventSchemasIntegrationTests(TestCase):
password = "SchemaPass!123"
@pytest.fixture(autouse=True)
def _pytest_settings(self, settings):
settings.MEDIA_ROOT = MEDIA_ROOT
def setUp(self):
self.user = User.objects.create_user(
username="schema_user",
email="schema.user@example.com",
mobile="09198000003",
password=self.password,
)
@pytest.mark.django_db
@pytest.mark.integration
class TestEventSchemasIntegrationTests:
password = 'SchemaPass!123'
def setup_method(self):
self.user = User.objects.create_user(username='schema_user', email='schema.user@example.com', mobile='09198000003', password=self.password)
self.user.is_email_verified = True
self.user.save(update_fields=["is_email_verified"])
self.event = Event.objects.create(
title="Schema Event",
description="**bold**",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
price=1000,
slug="schema-event",
)
Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.ATTENDED,
final_price=0,
)
self.user.save(update_fields=['is_email_verified'])
self.event = Event.objects.create(title='Schema Event', description='**bold**', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=1), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), price=1000, slug='schema-event')
Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED, final_price=0)
Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.ATTENDED, final_price=0)
def _mock_request(self):
return SimpleNamespace(build_absolute_uri=lambda path: f"https://test{path}")
return SimpleNamespace(build_absolute_uri=lambda path: f'https://test{path}')
def test_gallery_schema_returns_full_url(self):
obj = SimpleNamespace(image=SimpleNamespace(url="/media/gallery.png"))
result = EventGallerySchema.resolve_absolute_image_url(obj, {"request": self._mock_request()})
self.assertEqual(result, "https://test/media/gallery.png")
obj = SimpleNamespace(image=SimpleNamespace(url='/media/gallery.png'))
result = EventGallerySchema.resolve_absolute_image_url(obj, {'request': self._mock_request()})
assert result == 'https://test/media/gallery.png'
def test_event_schema_resolvers(self):
context = {"request": self._mock_request()}
event_obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
self.assertEqual(EventSchema.resolve_absolute_featured_image_url(event_obj, context), "https://test/media/feat.png")
self.assertEqual(EventSchema.resolve_registration_count(self.event), 2)
self.assertIn("<p>", EventSchema.resolve_description_html(self.event))
context = {'request': self._mock_request()}
event_obj = SimpleNamespace(featured_image=SimpleNamespace(url='/media/feat.png'), registrations=self.event.registrations)
assert EventSchema.resolve_absolute_featured_image_url(event_obj, context) == 'https://test/media/feat.png'
assert EventSchema.resolve_registration_count(self.event) == 2
assert '<p>' in EventSchema.resolve_description_html(self.event)
def test_event_list_schema_resolvers(self):
obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
context = {"request": self._mock_request()}
self.assertEqual(EventListSchema.resolve_absolute_featured_image_url(obj, context), "https://test/media/feat.png")
self.assertEqual(EventListSchema.resolve_registration_count(self.event), 2)
obj = SimpleNamespace(featured_image=SimpleNamespace(url='/media/feat.png'), registrations=self.event.registrations)
context = {'request': self._mock_request()}
assert EventListSchema.resolve_absolute_featured_image_url(obj, context) == 'https://test/media/feat.png'
assert EventListSchema.resolve_registration_count(self.event) == 2
def test_registration_schema_resolves_discount_code(self):
discount = DiscountCode.objects.create(code="SCHEMA", type=DiscountCode.Type.FIXED, value=100, is_active=True)
discount = DiscountCode.objects.create(code='SCHEMA', type=DiscountCode.Type.FIXED, value=100, is_active=True)
discount.applicable_events.add(self.event)
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=900,
discount_code=discount,
)
self.assertEqual(RegistrationSchema.resolve_discount_code(registration), discount.code)
registration = Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED, final_price=900, discount_code=discount)
assert RegistrationSchema.resolve_discount_code(registration) == discount.code
def test_payment_admin_schema_normalizes_discount_code(self):
self.assertIsNone(PaymentAdminSchema.normalize_discount_code(None))
self.assertEqual(PaymentAdminSchema.normalize_discount_code("123"), "123")
self.assertEqual(PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code="ABC")), "ABC")
assert PaymentAdminSchema.normalize_discount_code(None) is None
assert PaymentAdminSchema.normalize_discount_code('123') == '123'
assert PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code='ABC')) == 'ABC'
def test_event_admin_detail_resolves_registrations(self):
registrations = EventAdminDetailSchema.resolve_registrations(self.event)
self.assertTrue(list(registrations))
# TODO registration-related tests
assert list(registrations)

View File

@@ -0,0 +1,34 @@
from types import SimpleNamespace
import pytest
from django.contrib import admin
from apps.events.admin import EventAdmin
from apps.events.models import Event
pytestmark = pytest.mark.unit
def test_event_admin_formats_empty_price_and_capacity():
model_admin = EventAdmin(Event, admin.site)
event = SimpleNamespace(price=None, capacity=None, current_attendees_count=3, is_registration_open=True)
assert model_admin.price_display(event) == "رایگان"
assert model_admin.capacity_display(event) == "نامحدود"
assert model_admin.attendees_display(event) == 3
assert model_admin.is_registration_open_display(event)
def test_event_admin_status_actions_update_selection(mocker):
model_admin = EventAdmin(Event, admin.site)
mocker.patch.object(model_admin, "message_user")
queryset = mocker.Mock()
queryset.count.return_value = 2
for action, status in (
(model_admin.make_published, Event.StatusChoices.PUBLISHED),
(model_admin.make_draft, Event.StatusChoices.DRAFT),
(model_admin.make_cancelled, Event.StatusChoices.CANCELLED),
(model_admin.make_completed, Event.StatusChoices.COMPLETED),
):
action(SimpleNamespace(), queryset)
queryset.update.assert_called_with(status=status)

View File

@@ -0,0 +1,72 @@
import pytest
from types import SimpleNamespace
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.tasks import send_registration_cancellation_notification, send_registration_confirmation_notification
from apps.users.models import User
@pytest.mark.unit
class TestAutomaticRegistrationNotificationTests:
def test_confirmation_delivers_in_app_with_event_link(self, mocker):
registrations = mocker.patch('apps.events.tasks.Registration.objects')
notify_user = mocker.patch('apps.events.tasks.notify_user')
registration = SimpleNamespace(user_id=7, event_id=12, ticket_id='ticket', event=SimpleNamespace(title='Workshop', slug='workshop'))
registrations.select_related.return_value.get.return_value = registration
result = send_registration_confirmation_notification.run('1')
assert result == {'sent': True}
assert notify_user.call_args.args[0] == 7
assert notify_user.call_args.args[1]['action_url'] == '/events/workshop'
def test_cancellation_delivers_in_app_and_optional_sms(self, mocker):
registrations = mocker.patch('apps.events.tasks.Registration.objects')
notify_user = mocker.patch('apps.events.tasks.notify_user')
send_sms = mocker.patch('apps.events.tasks.send_critical_sms.delay')
registrations.select_related.return_value.get.return_value = SimpleNamespace(user_id=7, event_id=12, user=SimpleNamespace(mobile='09191110002', is_mobile_verified=True), event=SimpleNamespace(title='Workshop', slug='workshop'))
assert send_registration_cancellation_notification.run('1') == {'sent': True}
assert notify_user.call_args.args[1]['type'] == 'event_cancellation'
send_sms.assert_called_once_with('09191110002', 'event_cancellation', 'Workshop')
def test_cancellation_skips_sms_for_unverified_mobile(self, mocker):
registrations = mocker.patch('apps.events.tasks.Registration.objects')
mocker.patch('apps.events.tasks.notify_user')
send_sms = mocker.patch('apps.events.tasks.send_critical_sms.delay')
registrations.select_related.return_value.get.return_value = SimpleNamespace(
user_id=7, event_id=12,
user=SimpleNamespace(mobile='09191110002', is_mobile_verified=False),
event=SimpleNamespace(title='Workshop', slug='workshop'),
)
assert send_registration_cancellation_notification.run('1') == {'sent': True}
send_sms.assert_not_called()
def test_confirmation_failure_requests_retry(self, mocker):
registrations = mocker.patch('apps.events.tasks.Registration.objects')
registrations.select_related.return_value.get.side_effect = Registration.DoesNotExist()
retry = mocker.patch.object(
send_registration_confirmation_notification, 'retry', side_effect=RuntimeError('retry requested')
)
with pytest.raises(RuntimeError, match='retry requested'):
send_registration_confirmation_notification.run('missing')
assert retry.call_args.kwargs['countdown'] == 60
@pytest.mark.unit
class TestManualActionRemovalTests:
def test_event_and_registration_admin_have_no_manual_send_actions(self):
assert not EventAdmin.__dict__.get('actions_row')
assert not RegistrationAdmin.__dict__.get('actions_row')
def test_bulk_status_actions_save_each_registration_for_automatic_notification(self, mocker):
model_admin = RegistrationAdmin(Registration, admin.site)
registration = mocker.Mock()
mocker.patch.object(model_admin, 'message_user')
model_admin.confirm_registrations(SimpleNamespace(), [registration])
assert registration.status == Registration.StatusChoices.CONFIRMED
registration.save.assert_called_with(update_fields=['status'])
registration.reset_mock()
model_admin.cancel_registrations(SimpleNamespace(), [registration])
assert registration.status == Registration.StatusChoices.CANCELLED
registration.save.assert_called_with(update_fields=['status'])

View File

@@ -1,93 +0,0 @@
from types import SimpleNamespace
from unittest import mock
from datetime import timedelta
from django.contrib import admin
from django.test import SimpleTestCase, TestCase
from django.utils import timezone
from apps.events.admin import EventAdmin, RegistrationAdmin
from apps.events.models import Event, Registration
from apps.events.tasks import (
send_registration_cancellation_notification,
send_registration_confirmation_notification,
)
from apps.users.models import User
class AutomaticRegistrationNotificationTests(SimpleTestCase):
@mock.patch("apps.events.tasks.notify_user")
@mock.patch("apps.events.tasks.Registration.objects")
def test_confirmation_delivers_in_app_with_event_link(self, registrations, notify_user):
registration = SimpleNamespace(
user_id=7,
event_id=12,
ticket_id="ticket",
event=SimpleNamespace(title="Workshop", slug="workshop"),
)
registrations.select_related.return_value.get.return_value = registration
result = send_registration_confirmation_notification.run("1")
self.assertEqual(result, {"sent": True})
self.assertEqual(notify_user.call_args.args[0], 7)
self.assertEqual(notify_user.call_args.args[1]["action_url"], "/events/workshop")
@mock.patch("apps.events.tasks.send_critical_sms.delay")
@mock.patch("apps.events.tasks.notify_user")
@mock.patch("apps.events.tasks.Registration.objects")
def test_cancellation_delivers_in_app_and_optional_sms(self, registrations, notify_user, send_sms):
registrations.select_related.return_value.get.return_value = SimpleNamespace(
user_id=7,
event_id=12,
user=SimpleNamespace(mobile="09191110002", is_mobile_verified=True),
event=SimpleNamespace(title="Workshop", slug="workshop"),
)
self.assertEqual(send_registration_cancellation_notification.run("1"), {"sent": True})
self.assertEqual(notify_user.call_args.args[1]["type"], "event_cancellation")
send_sms.assert_called_once_with("09191110002", "event_cancellation", "Workshop")
class ManualActionRemovalTests(SimpleTestCase):
def test_event_and_registration_admin_have_no_manual_send_actions(self):
self.assertFalse(EventAdmin.__dict__.get("actions_row"))
self.assertFalse(RegistrationAdmin.__dict__.get("actions_row"))
def test_bulk_status_actions_save_each_registration_for_automatic_notification(self):
model_admin = RegistrationAdmin(Registration, admin.site)
registration = mock.Mock()
with mock.patch.object(model_admin, "message_user"):
model_admin.confirm_registrations(SimpleNamespace(), [registration])
self.assertEqual(registration.status, Registration.StatusChoices.CONFIRMED)
registration.save.assert_called_with(update_fields=["status"])
registration.reset_mock()
model_admin.cancel_registrations(SimpleNamespace(), [registration])
self.assertEqual(registration.status, Registration.StatusChoices.CANCELLED)
registration.save.assert_called_with(update_fields=["status"])
class EventModelTests(TestCase):
def setUp(self):
now = timezone.now()
self.event = Event.objects.create(
title="Workshop", description="**Important** details", start_time=now + timedelta(days=1),
end_time=now + timedelta(days=1, hours=2), capacity=1,
registration_start_date=now - timedelta(days=1),
registration_end_date=now + timedelta(hours=1),
)
self.user = User.objects.create_user(username="attendee", mobile="09191110003", password="password")
def test_markdown_description_and_registration_window_remain_available(self):
self.assertIn("<strong>Important</strong>", self.event.description_html)
self.assertTrue(self.event.is_registration_open)
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
self.assertFalse(self.event.is_registration_open)
@mock.patch("apps.events.tasks.send_registration_confirmation_notification.delay")
def test_capacity_counts_confirmed_registration(self, delay):
Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED)
self.assertEqual(self.event.current_attendees_count, 1)
self.assertFalse(self.event.has_available_slots)
delay.assert_called_once()
def test_unlimited_capacity_allows_registration(self):
self.event.capacity = None
self.assertTrue(self.event.has_available_slots)