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

@@ -1,23 +1,20 @@
[run] [run]
branch = True branch = True
source = source =
users apps
api core
utils
payments
communications
gallery
events
blog
config config
omit = omit =
*/migrations/* */migrations/*
*/tests/* */tests/*
*/__init__.py */__init__.py
*/apps.py
config/settings/* config/settings/*
config/urls.py
config/wsgi.py config/wsgi.py
config/asgi.py config/asgi.py
config/services/location.py
config/services/notifications.py
config/services/zarinpal.py
[report] [report]
skip_empty = True skip_empty = True

View File

@@ -27,15 +27,6 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env: env:
SECRET_KEY: github-ci-secret-key SECRET_KEY: github-ci-secret-key
DEBUG: "False" DEBUG: "False"
@@ -83,8 +74,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements-test.txt
pip install coverage
- name: Prepare environment file - name: Prepare environment file
run: cp .env.test .env run: cp .env.test .env
@@ -92,10 +82,13 @@ jobs:
- name: Run migrations - name: Run migrations
run: python manage.py migrate --noinput run: python manage.py migrate --noinput
- name: Run tests with coverage - name: Run unit and integration tests with coverage
run: | run: |
coverage run --rcfile=.coveragerc manage.py test apps.users.tests apps.events.tests apps.payments.tests apps.notifications.tests apps.communications.tests core.tests --settings=config.settings.test --verbosity 2 coverage run --rcfile=.coveragerc -m pytest -m unit -q
coverage run --rcfile=.coveragerc --append -m pytest -m integration -q
coverage report -m coverage report -m
coverage json -o coverage.json
python scripts/check_coverage.py coverage.json --minimum 80
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -34,10 +34,26 @@ python manage.py runserver
``` ```
## Tests ## Tests
```bash
python manage.py test --settings=config.settings.test Install test dependencies, then run the two pytest suites:
```powershell
python -m pip install -r requirements-test.txt
python -m pytest -m unit
python -m pytest -m integration
``` ```
CI uses PostgreSQL for integration tests and combines branch coverage from both suites. The same coverage gate can be run locally after configuring the test database:
```powershell
python -m coverage run -m pytest -m unit
python -m coverage run --append -m pytest -m integration
python -m coverage json -o coverage.json
python scripts/check_coverage.py coverage.json --minimum 80
```
Coverage includes executable modules in `apps`, `core`, and `config`. The fixed exclusions are migrations, tests, `__init__.py`, `apps.py`, `config/settings`, ASGI/WSGI entrypoints, and constant-only service config modules (`location`, `notifications`, and `zarinpal`). Every included module must reach 80% combined statement and branch coverage.
## Notes ## Notes
- Domain routers live under `apps/<domain>/api`. - Domain routers live under `apps/<domain>/api`.
- Shared auth helpers live in `core/authentication.py`. - Shared auth helpers live in `core/authentication.py`.

View File

@@ -0,0 +1,40 @@
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_analytics_requires_staff_and_reports_current_users(client, user_factory, auth_headers):
member = user_factory()
staff = user_factory(is_staff=True)
path = "/api/analytics/admin/users"
assert client.get(path).status_code == 401
assert client.get(path, **auth_headers(member)).status_code == 403
response = client.get(path, **auth_headers(staff))
assert response.status_code == 200
assert response.json()["summary"]["total_users"] == 2
def test_staff_analytics_endpoints_return_empty_aggregate_shapes(client, user_factory, event_factory, auth_headers):
staff = user_factory(is_staff=True)
event = event_factory()
headers = auth_headers(staff)
options = client.get("/api/analytics/admin/events/options", {"search": "Test", "limit": 1}, **headers)
assert options.status_code == 200
assert options.json()["count"] == 1
assert options.json()["results"][0]["value"] == str(event.pk)
for path in ("/api/analytics/admin/events", "/api/analytics/admin/blog", "/api/analytics/admin/dashboard"):
response = client.get(path, **headers)
assert response.status_code == 200, (path, response.content)
assert response.json()
def test_analytics_rejects_invalid_date_range(client, user_factory, auth_headers):
staff = user_factory(is_staff=True)
response = client.get(
"/api/analytics/admin/users",
{"date_from": "2026-12-31", "date_to": "2026-01-01"},
**auth_headers(staff),
)
assert response.status_code == 400

View File

@@ -0,0 +1,55 @@
from datetime import datetime, timedelta, timezone
import pytest
from ninja.errors import HttpError
from apps.analytics.api.views import (
_auto_granularity,
_filters_payload,
_normalize_entry_year,
_parse_boundary,
_point_group_from_rows,
_require_staff,
)
pytestmark = pytest.mark.unit
@pytest.mark.parametrize(
("value", "expected"),
[(None, "نامشخص"), ("۱۴۰۴", "1404"), ("404", "1404"), ("94", "1394"), ("bad", "نامشخص")],
)
def test_year_normalization(value, expected):
assert _normalize_entry_year(value) == expected
def test_date_filters_validate_and_choose_granularity():
assert _parse_boundary(None) is None
with pytest.raises(HttpError):
_parse_boundary("not-a-date")
start = _parse_boundary("2026-01-01")
end = _parse_boundary("2026-01-31", is_end=True)
assert start < end
assert _auto_granularity(start, end) == "day"
assert _auto_granularity(start, start + timedelta(days=90)) == "week"
assert _auto_granularity(None, end) == "month"
assert _filters_payload(start, end, include_granularity=True)["granularity"] == "day"
def test_point_group_sorts_and_counts_remainder():
result = _point_group_from_rows(
[{"label": "B", "value": 1}, {"label": "A", "value": 3}, {"label": None, "value": 0}],
limit=1,
)
assert result["top_items"] == [{"label": "A", "value": 3}]
assert result["other_count"] == 2
assert result["total_count"] == 3
def test_staff_guard_rejects_anonymous_and_nonstaff():
with pytest.raises(HttpError):
_require_staff(None)
with pytest.raises(HttpError):
_require_staff(type("User", (), {"is_staff": False, "is_superuser": False})())
_require_staff(type("User", (), {"is_staff": True, "is_superuser": False})())

View File

@@ -13,9 +13,9 @@ class CategorySchema(ModelSchema):
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
parent_id: Optional[int] = None parent_id: Optional[int] = None
class Config: class Meta:
model = Category model = Category
model_fields = ["id", "name", "slug", "description", "created_at"] fields = ["id", "name", "slug", "description", "created_at"]
@staticmethod @staticmethod
def resolve_parent_id(obj): def resolve_parent_id(obj):
@@ -55,9 +55,9 @@ class CategoryFilterSchema(Schema):
class TagSchema(ModelSchema): class TagSchema(ModelSchema):
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
class Config: class Meta:
model = Tag model = Tag
model_fields = ["id", "name", "slug", "created_at"] fields = ["id", "name", "slug", "created_at"]
class AdminTagSchema(TagSchema): class AdminTagSchema(TagSchema):
@@ -119,9 +119,9 @@ class PostAssetSchema(ModelSchema):
markdown_link: Optional[str] = None markdown_link: Optional[str] = None
uploaded_by: AuthorSchema uploaded_by: AuthorSchema
class Config: class Meta:
model = PostAsset model = PostAsset
model_fields = [ fields = [
"id", "id",
"file_type", "file_type",
"title", "title",
@@ -301,9 +301,9 @@ class CommentSchema(ModelSchema):
deleted_at: Optional[datetime] = None deleted_at: Optional[datetime] = None
hidden_replies_count: int = 0 hidden_replies_count: int = 0
class Config: class Meta:
model = Comment model = Comment
model_fields = ["id", "content", "created_at", "updated_at", "is_approved", "hidden_at"] fields = ["id", "content", "created_at", "updated_at", "is_approved", "hidden_at"]
@staticmethod @staticmethod
def resolve_post_id(obj): def resolve_post_id(obj):
@@ -359,9 +359,9 @@ class BlogProfileActivitySchema(Schema):
class BlogBannerSchema(ModelSchema): class BlogBannerSchema(ModelSchema):
image_url: str image_url: str
class Config: class Meta:
model = BlogBanner model = BlogBanner
model_fields = ["id", "title", "alt_text", "url", "sort_order"] fields = ["id", "title", "alt_text", "url", "sort_order"]
@staticmethod @staticmethod
def resolve_image_url(obj, context): def resolve_image_url(obj, context):

View File

@@ -184,7 +184,7 @@ class Command(BaseCommand):
def _seed_writers(self, editor_group: Group, password: str): def _seed_writers(self, editor_group: Group, password: str):
writers = [] writers = []
for index, spec in enumerate(WRITERS, start=1): for index, spec in enumerate(WRITERS, start=1):
user, created = User.objects.get_or_create( user, created = User.all_objects.get_or_create(
username=spec["username"], username=spec["username"],
defaults={ defaults={
"first_name": spec["first_name"], "first_name": spec["first_name"],
@@ -201,6 +201,8 @@ class Command(BaseCommand):
user.bio = spec["bio"] user.bio = spec["bio"]
user.is_active = True user.is_active = True
user.is_mobile_verified = True user.is_mobile_verified = True
user.is_deleted = False
user.deleted_at = None
if created: if created:
user.set_password(password) user.set_password(password)
set_image_field( set_image_field(
@@ -218,19 +220,24 @@ class Command(BaseCommand):
return writers return writers
def _seed_categories(self): def _seed_categories(self):
root, _ = Category.objects.get_or_create( root, _ = Category.all_objects.get_or_create(
slug="mock-بلاگ-انجمن", slug="mock-بلاگ-انجمن",
defaults={"name": "بلاگ انجمن", "description": "دسته اصلی محتوای تستی بلاگ"}, defaults={"name": "بلاگ انجمن", "description": "دسته اصلی محتوای تستی بلاگ"},
) )
root.is_deleted = False
root.deleted_at = None
root.save(update_fields=["is_deleted", "deleted_at"])
names = ["برنامه‌نویسی", "علوم کامپیوتر", "توسعه نرم‌افزار", "ابزارها", "هوش مصنوعی"] names = ["برنامه‌نویسی", "علوم کامپیوتر", "توسعه نرم‌افزار", "ابزارها", "هوش مصنوعی"]
categories = {"بلاگ انجمن": root} categories = {"بلاگ انجمن": root}
for name in names: for name in names:
category, _ = Category.objects.get_or_create( category, _ = Category.all_objects.get_or_create(
slug=f"mock-{name}", slug=f"mock-{name}",
defaults={"name": name, "parent": root, "description": f"مطالب تستی درباره {name}"}, defaults={"name": name, "parent": root, "description": f"مطالب تستی درباره {name}"},
) )
category.name = name category.name = name
category.parent = root category.parent = root
category.is_deleted = False
category.deleted_at = None
category.save() category.save()
categories[name] = category categories[name] = category
return categories return categories
@@ -238,8 +245,10 @@ class Command(BaseCommand):
def _seed_tags(self): def _seed_tags(self):
tags = {} tags = {}
for name in TAG_NAMES: for name in TAG_NAMES:
tag, _ = Tag.objects.get_or_create(slug=f"mock-{name}", defaults={"name": name}) tag, _ = Tag.all_objects.get_or_create(slug=f"mock-{name}", defaults={"name": name})
tag.name = name tag.name = name
tag.is_deleted = False
tag.deleted_at = None
tag.save() tag.save()
tags[name] = tag tags[name] = tag
return tags return tags
@@ -247,7 +256,7 @@ class Command(BaseCommand):
def _seed_banners(self): def _seed_banners(self):
colors = [(9, 80, 90), (120, 64, 24), (38, 70, 83)] colors = [(9, 80, 90), (120, 64, 24), (38, 70, 83)]
for index in range(1, 4): for index in range(1, 4):
banner, _ = BlogBanner.objects.get_or_create( banner, _ = BlogBanner.all_objects.get_or_create(
title=f"Mock Blog Banner {index}", title=f"Mock Blog Banner {index}",
defaults={ defaults={
"url": f"https://east-guilan-ce.ir/blog?mock-banner={index}", "url": f"https://east-guilan-ce.ir/blog?mock-banner={index}",
@@ -260,6 +269,8 @@ class Command(BaseCommand):
banner.alt_text = f"بنر تستی بلاگ {index}" banner.alt_text = f"بنر تستی بلاگ {index}"
banner.sort_order = index banner.sort_order = index
banner.is_active = True banner.is_active = True
banner.is_deleted = False
banner.deleted_at = None
set_image_field( set_image_field(
banner, banner,
"image", "image",
@@ -298,6 +309,8 @@ class Command(BaseCommand):
post.content = make_markdown(spec["title"]) post.content = make_markdown(spec["title"])
post.excerpt = f"خلاصه تستی برای نوشته «{spec['title']}» که برای بررسی کارت‌ها و سئوی بلاگ استفاده می‌شود." post.excerpt = f"خلاصه تستی برای نوشته «{spec['title']}» که برای بررسی کارت‌ها و سئوی بلاگ استفاده می‌شود."
post.status = Post.StatusChoices.PUBLISHED post.status = Post.StatusChoices.PUBLISHED
post.is_deleted = False
post.deleted_at = None
post.category = categories[spec["category"]] post.category = categories[spec["category"]]
post.is_featured = index <= 2 post.is_featured = index <= 2
post.published_at = post.published_at or timezone.now() - timezone.timedelta(days=index * 3) post.published_at = post.published_at or timezone.now() - timezone.timedelta(days=index * 3)

View File

@@ -0,0 +1,43 @@
from io import StringIO
import pytest
from django.contrib.auth.models import Group
from django.core.management import call_command
from apps.blog.models import Category, Post, Tag
from apps.blog.permissions import BLOG_EDITOR_GROUP, BLOG_SUPERVISOR_GROUP
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_category_and_tag_seed_commands_support_dry_run_and_idempotence():
output = StringIO()
call_command("seed_blog_categories", dry_run=True, stdout=output)
call_command("seed_blog_tags", dry_run=True, stdout=output)
assert Category.objects.count() == 0
assert Tag.objects.count() == 0
call_command("seed_blog_categories", stdout=output)
call_command("seed_blog_tags", stdout=output)
counts = Category.objects.count(), Tag.objects.count()
assert counts[0] > 0 and counts[1] > 0
call_command("seed_blog_categories", stdout=output)
call_command("seed_blog_tags", stdout=output)
assert (Category.objects.count(), Tag.objects.count()) == counts
def test_role_sync_creates_editor_and_supervisor_permissions():
call_command("sync_blog_roles", stdout=StringIO())
editor = Group.objects.get(name=BLOG_EDITOR_GROUP)
supervisor = Group.objects.get(name=BLOG_SUPERVISOR_GROUP)
assert editor.permissions.filter(codename="add_post").exists()
assert supervisor.permissions.filter(codename="review_blog_post").exists()
def test_mock_seed_creates_posts_and_can_reset(tmp_path, settings, mocker):
settings.MEDIA_ROOT = str(tmp_path)
mocker.patch("apps.blog.management.commands.seed_blog_mock_data.set_image_field")
call_command("seed_blog_mock_data", stdout=StringIO())
assert Post.objects.filter(slug__startswith="mock-").count() == 5
call_command("seed_blog_mock_data", reset=True, stdout=StringIO())
assert Post.objects.filter(slug__startswith="mock-").count() == 5

View File

@@ -0,0 +1,309 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.blog.models import Category, Comment, Like, Post, PostAsset, SavedPost, Tag
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_public_blog_lists_empty_content(client):
response = client.get("/api/blog/posts")
assert response.status_code == 200
assert response.json() == []
def test_admin_blog_requires_authentication(client):
assert client.get("/api/blog/admin/posts").status_code == 401
@pytest.fixture
def blog_content(user_factory):
author = user_factory()
category = Category.objects.create(name="Events")
child = Category.objects.create(name="News", parent=category)
tag = Tag.objects.create(name="Featured")
first = Post.objects.create(title="First story", content="**First** story", author=author,
category=child, status=Post.StatusChoices.PUBLISHED, is_featured=True)
first.tags.add(tag)
second = Post.objects.create(title="Second story", content="Second content", author=author,
category=category, status=Post.StatusChoices.PUBLISHED)
draft = Post.objects.create(title="Private draft", content="Draft", author=author)
return author, category, child, tag, first, second, draft
def test_public_filters_posts_and_recommendations(client, blog_content):
author, category, child, tag, first, second, draft = blog_content
assert {post["slug"] for post in client.get("/api/blog/posts").json()} == {first.slug, second.slug}
assert {post["slug"] for post in client.get("/api/blog/posts", {"category": category.slug}).json()} == {first.slug, second.slug}
assert [post["slug"] for post in client.get("/api/blog/posts", {"tag": tag.slug}).json()] == [first.slug]
assert [post["slug"] for post in client.get("/api/blog/posts", {"search": "Second"}).json()] == [second.slug]
assert [post["slug"] for post in client.get("/api/blog/posts", {"featured": "true"}).json()] == [first.slug]
assert {post["slug"] for post in client.get("/api/blog/posts", {"author": author.username}).json()} == {first.slug, second.slug}
assert client.get("/api/blog/posts", {"category": "unknown"}).json() == []
assert client.get(f"/api/blog/posts/{draft.slug}").status_code == 404
assert client.get(f"/api/blog/posts/{first.slug}").json()["title"] == first.title
assert [post["slug"] for post in client.get(f"/api/blog/posts/{first.slug}/recommended").json()] == [second.slug]
filters = client.get("/api/blog/filters").json()
assert filters["categories"][0]["children"][0]["slug"] == child.slug
assert filters["tags"][0]["post_count"] == 1
assert filters["authors"][0]["post_count"] == 2
def test_public_taxonomy_and_comment_visibility(client, blog_content, user_factory):
author, category, child, tag, post, _, _ = blog_content
member = user_factory()
approved = Comment.objects.create(post=post, author=member, content="Visible")
hidden = Comment.objects.create(post=post, author=member, content="Hidden", is_hidden=True)
assert [row["slug"] for row in client.get("/api/blog/categories").json()] == [category.slug, child.slug]
assert client.get(f"/api/blog/categories/{category.slug}").json()["name"] == category.name
assert client.get(f"/api/blog/tags/{tag.slug}").json()["name"] == tag.name
assert [row["id"] for row in client.get(f"/api/blog/posts/{post.slug}/comments").json()] == [approved.id]
assert hidden.id != approved.id
def test_blog_interactions_and_activity(client, blog_content, user_factory, auth_headers, mocker):
_, _, _, _, post, _, _ = blog_content
member = user_factory()
headers = auth_headers(member)
mocker.patch("apps.blog.api.views.notify_user")
assert client.post(f"/api/blog/posts/{post.slug}/like", **headers).json()["liked"] is True
assert Like.objects.filter(post=post, user=member).exists()
assert client.post(f"/api/blog/posts/{post.slug}/save", **headers).json()["saved"] is True
assert SavedPost.objects.filter(post=post, user=member).exists()
response = client.post(f"/api/blog/posts/{post.slug}/comments", data={"content": "A useful comment"},
content_type="application/json", **headers)
assert response.status_code == 201, response.content
comment = Comment.objects.get(pk=response.json()["id"])
assert comment.content == "A useful comment"
assert client.get(f"/api/blog/posts/{post.slug}/interaction", **headers).json()["comments_count"] == 1
activity = client.get("/api/blog/me/activity", **headers).json()
assert activity["liked_posts"][0]["id"] == post.id
assert activity["saved_posts"][0]["id"] == post.id
assert activity["comments"][0]["id"] == comment.id
assert client.put(f"/api/blog/comments/{comment.id}", data={"content": "Edited"},
content_type="application/json", **headers).status_code == 200
comment.refresh_from_db()
assert comment.content == "Edited"
assert client.post(f"/api/blog/posts/{post.slug}/like", **headers).json()["liked"] is False
assert not Like.objects.filter(post=post, user=member).exists()
def test_blog_editor_review_and_moderation(client, blog_content, user_factory, auth_headers, mocker):
author, _, _, _, _, _, _ = blog_content
editor = user_factory(is_staff=True)
member = user_factory()
editor_headers = auth_headers(editor)
member_headers = auth_headers(member)
mocker.patch("apps.blog.api.views.notify_user")
assert client.get("/api/blog/admin/posts", **member_headers).status_code == 403
created = client.post("/api/blog/admin/posts", data={"title": "Reviewed story", "content": "Body"},
content_type="application/json", **editor_headers)
assert created.status_code == 201, created.content
post = Post.objects.get(pk=created.json()["id"])
assert post.author == editor and post.status == Post.StatusChoices.DRAFT
submitted = client.post(f"/api/blog/admin/posts/{post.id}/submit", **editor_headers)
assert submitted.status_code == 200
post.refresh_from_db()
assert post.status == Post.StatusChoices.SUBMITTED
invalid = client.post(f"/api/blog/admin/posts/{post.id}/review", data={"action": "nope"},
content_type="application/json", **editor_headers)
assert invalid.status_code == 400
reviewed = client.post(f"/api/blog/admin/posts/{post.id}/review", data={"action": "publish"},
content_type="application/json", **editor_headers)
assert reviewed.status_code == 200, reviewed.content
post.refresh_from_db()
assert post.status == Post.StatusChoices.PUBLISHED and post.published_by == editor
assert client.get(f"/api/blog/posts/{post.slug}").status_code == 200
response = client.post(f"/api/blog/posts/{post.slug}/comments", data={"content": "Review me"},
content_type="application/json", **member_headers)
comment = Comment.objects.get(pk=response.json()["id"])
assert client.post(f"/api/blog/comments/{comment.id}/hide", data={"note": "spam"},
content_type="application/json", **member_headers).status_code == 403
assert client.post(f"/api/blog/comments/{comment.id}/hide", data={"note": "spam"},
content_type="application/json", **editor_headers).status_code == 200
comment.refresh_from_db()
assert comment.is_hidden
assert client.get(f"/api/blog/posts/{post.slug}/comments").json() == []
assert client.post(f"/api/blog/comments/{comment.id}/unhide", **editor_headers).status_code == 200
comment.refresh_from_db()
assert not comment.is_hidden
assert client.post(f"/api/blog/comments/{comment.id}/delete", data={"note": "remove"},
content_type="application/json", **editor_headers).status_code == 200
assert Comment.deleted_objects.filter(pk=comment.pk).exists()
assert client.post(f"/api/blog/deleted/comments/{comment.id}/restore", **editor_headers).status_code == 200
assert Comment.objects.filter(pk=comment.pk).exists()
def test_blog_taxonomy_admin_crud_and_restore(client, user_factory, auth_headers):
admin = user_factory(is_superuser=True, is_staff=True)
member = user_factory()
admin_headers = auth_headers(admin)
assert client.get("/api/blog/admin/categories", **auth_headers(member)).status_code == 403
root = client.post("/api/blog/admin/categories", data={"name": "Research"},
content_type="application/json", **admin_headers)
assert root.status_code == 201, root.content
category_id = root.json()["id"]
assert Category.objects.filter(pk=category_id).exists()
child = client.post("/api/blog/admin/categories", data={"name": "Reports", "parent_id": category_id},
content_type="application/json", **admin_headers)
assert child.status_code == 201, child.content
invalid = client.put(f"/api/blog/admin/categories/{category_id}",
data={"name": "Research", "parent_id": child.json()["id"]},
content_type="application/json", **admin_headers)
assert invalid.status_code == 400
assert Category.objects.get(pk=category_id).parent_id is None
tag = client.post("/api/blog/admin/tags", data={"name": "Python"},
content_type="application/json", **admin_headers)
assert tag.status_code == 201, tag.content
tag_id = tag.json()["id"]
assert client.put(f"/api/blog/admin/tags/{tag_id}", data={"name": "Django"},
content_type="application/json", **admin_headers).status_code == 200
assert Tag.objects.get(pk=tag_id).name == "Django"
assert client.delete(f"/api/blog/admin/tags/{tag_id}", **admin_headers).status_code == 200
assert Tag.deleted_objects.filter(pk=tag_id).exists()
assert client.post(f"/api/blog/deleted/tags/{tag_id}/restore", **admin_headers).status_code == 200
assert Tag.objects.filter(pk=tag_id).exists()
def test_blog_post_admin_listing_update_assets_and_soft_delete(client, blog_content, user_factory,
auth_headers, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
author, _, _, _, post, _, draft = blog_content
admin = user_factory(is_superuser=True, is_staff=True)
member = user_factory()
headers = auth_headers(admin)
assert client.get("/api/blog/admin/posts", **headers).status_code == 200
assert client.get(f"/api/blog/admin/posts/{post.pk}", **auth_headers(member)).status_code == 403
assert client.get(f"/api/blog/admin/posts/{post.pk}", **headers).json()["id"] == post.pk
updated = client.put(f"/api/blog/admin/posts/{draft.pk}", data='{"title":"New title","content":"Changed"}',
content_type="application/json", **headers)
assert updated.status_code == 200, updated.content
draft.refresh_from_db()
assert draft.title == "New title" and draft.content == "Changed"
assert client.get(f"/api/blog/admin/posts/{post.pk}/assets", **headers).json() == []
invalid = client.post(f"/api/blog/admin/posts/{post.pk}/assets",
{"file": SimpleUploadedFile("unsafe.exe", b"data", content_type="application/octet-stream")},
**headers)
assert invalid.status_code == 400
asset = client.post(f"/api/blog/admin/posts/{post.pk}/assets",
{"file": SimpleUploadedFile("notes.txt", b"notes", content_type="text/plain"), "title": "Notes"},
**headers)
assert asset.status_code == 201, asset.content
asset_id = asset.json()["id"]
assert PostAsset.objects.filter(pk=asset_id, post=post).exists()
assert len(client.get(f"/api/blog/admin/posts/{post.pk}/assets", **headers).json()) == 1
assert client.delete(f"/api/blog/admin/posts/{post.pk}/assets/{asset_id}", **headers).status_code == 200
assert not PostAsset.objects.filter(pk=asset_id).exists()
assert client.delete(f"/api/blog/posts/{post.slug}", **auth_headers(author)).status_code == 403
assert client.delete(f"/api/blog/posts/{post.slug}", **headers).status_code == 200
assert Post.deleted_objects.filter(pk=post.pk).exists()
assert client.get("/api/blog/deleted/posts", **headers).json()[0]["id"] == post.pk
assert client.post(f"/api/blog/deleted/posts/{post.pk}/restore", **headers).status_code == 200
assert Post.objects.filter(pk=post.pk).exists()
def test_blog_comment_reply_and_edit_authorization(client, blog_content, user_factory, auth_headers, mocker):
_, _, _, _, post, _, _ = blog_content
first_user = user_factory()
second_user = user_factory()
notify = mocker.patch("apps.blog.api.views.notify_user")
root = client.post(f"/api/blog/posts/{post.slug}/comments", data='{"content":"Root"}',
content_type="application/json", **auth_headers(first_user))
assert root.status_code == 201, root.content
root_id = root.json()["id"]
reply = client.post(f"/api/blog/posts/{post.slug}/comments",
data=f'{{"content":"Reply","parent_id":{root_id}}}',
content_type="application/json", **auth_headers(second_user))
assert reply.status_code == 201, reply.content
assert Comment.objects.get(pk=reply.json()["id"]).parent_id == root_id
assert notify.call_count >= 2
assert client.put(f"/api/blog/comments/{root_id}", data='{"content":"Changed"}',
content_type="application/json", **auth_headers(second_user)).status_code == 403
comments = client.get(f"/api/blog/posts/{post.slug}/comments").json()
assert comments[0]["replies"][0]["id"] == reply.json()["id"]
def test_blog_taxonomy_deletion_and_validation(client, auth_headers, user_factory):
admin = user_factory(is_superuser=True, is_staff=True)
headers = auth_headers(admin)
category = Category.objects.create(name="Delete category")
tag = Tag.objects.create(name="Delete tag")
assert client.post("/api/blog/admin/categories", data='{"name":" "}',
content_type="application/json", **headers).status_code == 400
assert client.post("/api/blog/admin/tags", data='{"name":" "}',
content_type="application/json", **headers).status_code == 400
assert client.delete(f"/api/blog/admin/categories/{category.pk}", **headers).status_code == 200
assert client.get("/api/blog/deleted/categories", **headers).json()[0]["id"] == category.pk
assert client.post(f"/api/blog/deleted/categories/{category.pk}/restore", **headers).status_code == 200
assert Category.objects.filter(pk=category.pk).exists()
assert client.post("/api/blog/deleted/categories/999999/restore", **headers).status_code == 400
assert client.get(f"/api/blog/tags/{tag.slug}").json()["id"] == tag.pk
def test_blog_featured_image_and_compatibility_routes(client, blog_content, user_factory,
auth_headers, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
_, _, _, _, post, _, _ = blog_content
admin = user_factory(is_staff=True, is_superuser=True)
headers = auth_headers(admin)
assert client.get("/api/blog/admin/writers", **headers).status_code == 200
assert client.get("/api/blog/admin/writers", **auth_headers(user_factory())).status_code == 403
invalid = client.post(f"/api/blog/admin/posts/{post.pk}/featured-image",
{"file": SimpleUploadedFile("notes.txt", b"text", content_type="text/plain")}, **headers)
assert invalid.status_code == 400
uploaded = client.post(f"/api/blog/admin/posts/{post.pk}/featured-image",
{"file": SimpleUploadedFile("cover.png", b"image", content_type="image/png")}, **headers)
assert uploaded.status_code == 200, uploaded.content
post.refresh_from_db()
assert post.featured_image
assert client.delete(f"/api/blog/admin/posts/{post.pk}/featured-image", **headers).status_code == 200
post.refresh_from_db()
assert not post.featured_image
compatible = client.post("/api/blog/posts", data='{"title":"Compat","content":"Body"}',
content_type="application/json", **headers)
assert compatible.status_code == 201, compatible.content
compat_post = Post.objects.get(pk=compatible.json()["id"])
update = client.put(f"/api/blog/posts/{compat_post.slug}", data='{"title":"Compat edited","content":"New body"}',
content_type="application/json", **headers)
assert update.status_code == 200, update.content
compat_post.refresh_from_db()
assert compat_post.title == "Compat edited"
def test_blog_review_changes_and_taxonomy_parent_validation(client, blog_content, user_factory, auth_headers):
_, _, _, _, _, _, draft = blog_content
admin = user_factory(is_staff=True)
headers = auth_headers(admin)
changed = client.post(f"/api/blog/admin/posts/{draft.pk}/review",
data='{"action":"request_changes","note":"Revise details"}',
content_type="application/json", **headers)
assert changed.status_code == 200, changed.content
draft.refresh_from_db()
assert draft.status == Post.StatusChoices.CHANGES_REQUESTED and draft.review_note == "Revise details"
archived = client.post(f"/api/blog/admin/posts/{draft.pk}/review", data='{"action":"archive"}',
content_type="application/json", **headers)
assert archived.status_code == 200
draft.refresh_from_db()
assert draft.status == Post.StatusChoices.ARCHIVED
root = Category.objects.create(name="Root for validation")
child = Category.objects.create(name="Child for validation", parent=root)
invalid = client.put(f"/api/blog/admin/categories/{child.pk}",
data=f'{{"name":"Child for validation","parent_id":{child.pk}}}',
content_type="application/json", **headers)
assert invalid.status_code == 400
child.refresh_from_db()
assert child.parent_id == root.pk
def test_blog_filters_accept_multiple_tag_values_and_page_results(client, blog_content):
_, _, _, tag, first, second, _ = blog_content
tagged = client.get("/api/blog/posts", {"tag": f"{tag.slug},unknown,{tag.slug}"})
assert tagged.status_code == 200
assert [row["id"] for row in tagged.json()] == [first.pk]
first_page = client.get("/api/blog/posts", {"page": 1, "limit": 1}).json()
second_page = client.get("/api/blog/posts", {"page": 2, "limit": 1}).json()
assert len(first_page) == len(second_page) == 1
assert {first_page[0]["id"], second_page[0]["id"]} == {first.pk, second.pk}
assert client.get("/api/blog/posts", {"page": 3, "limit": 1}).json() == []
assert [row["id"] for row in client.get(f"/api/blog/posts/{second.slug}/recommended").json()] == [first.pk]

View File

@@ -0,0 +1,33 @@
from types import SimpleNamespace
import pytest
from django.contrib import admin
from apps.blog.admin import CommentAdmin, PostAdmin
from apps.blog.models import Comment, Post
pytestmark = pytest.mark.unit
def test_blog_admin_preview_shortens_long_comment():
model_admin = CommentAdmin(Comment, admin.site)
assert model_admin.content_preview(SimpleNamespace(content="short")) == "short"
assert model_admin.content_preview(SimpleNamespace(content="x" * 51)) == "x" * 50 + "..."
def test_post_admin_actions_update_status_and_featured_flag(mocker):
model_admin = PostAdmin(Post, admin.site)
mocker.patch.object(model_admin, "message_user")
queryset = mocker.Mock()
queryset.count.return_value = 1
user = SimpleNamespace(id=1)
request = SimpleNamespace(user=user)
model_admin.submit_for_review(request, queryset)
queryset.update.assert_called_with(status="submitted")
model_admin.request_changes(request, queryset)
queryset.update.assert_called_with(status="changes_requested", reviewed_by=user)
model_admin.make_published(request, queryset)
queryset.update.assert_called_with(status="published", published_by=user)
model_admin.make_featured(request, queryset)
queryset.update.assert_called_with(is_featured=True)

View File

@@ -0,0 +1,69 @@
from types import SimpleNamespace
import pytest
from apps.blog.permissions import (
_has_any_perm,
can_access_blog_admin,
can_edit_post,
can_manage_post_assets,
can_moderate_blog_comments,
can_review_blog_posts,
can_write_blog_posts,
)
pytestmark = pytest.mark.unit
def user(*permissions, staff=False, superuser=False, authenticated=True):
return SimpleNamespace(
id=1,
is_authenticated=authenticated,
is_staff=staff,
is_superuser=superuser,
has_perm=lambda permission: permission in permissions,
)
def test_anonymous_user_has_no_blog_permissions():
visitor = user(authenticated=False)
post = SimpleNamespace(author_id=1, status="draft")
assert not can_access_blog_admin(visitor)
assert not can_write_blog_posts(visitor)
assert not can_review_blog_posts(visitor)
assert not can_moderate_blog_comments(visitor)
assert not can_edit_post(visitor, post)
assert not can_manage_post_assets(visitor, post)
def test_staff_can_manage_posts_and_assets():
staff = user(staff=True)
post = SimpleNamespace(author_id=2, status="archived")
assert can_access_blog_admin(staff)
assert can_edit_post(staff, post)
assert can_manage_post_assets(staff, post)
def test_author_can_edit_draft_but_not_archived_post():
author = user("blog.add_post", "blog.upload_blog_asset")
draft = SimpleNamespace(author_id=1, status="draft")
archived = SimpleNamespace(author_id=1, status="archived")
assert can_edit_post(author, draft)
assert can_manage_post_assets(author, draft)
assert not can_edit_post(author, archived)
def test_review_and_moderation_require_their_permissions():
reviewer = user("blog.review_blog_post")
moderator = user("blog.moderate_blog_comment")
assert can_review_blog_posts(reviewer)
assert not can_moderate_blog_comments(reviewer)
assert can_moderate_blog_comments(moderator)
def test_permission_set_helper_checks_authentication_and_membership():
assert not _has_any_perm(None, {"blog.add_post"})
assert not _has_any_perm(user(authenticated=False), {"blog.add_post"})
assert _has_any_perm(user("blog.add_post"), {"blog.add_post"})
assert _has_any_perm(user(superuser=True), {"blog.add_post"})

View File

@@ -0,0 +1,15 @@
import pytest
from apps.blog.management.commands.seed_blog_categories import console_safe as category_console_safe
from apps.blog.management.commands.seed_blog_mock_data import make_image_bytes, make_markdown
from apps.blog.management.commands.seed_blog_tags import console_safe as tag_console_safe
pytestmark = pytest.mark.unit
def test_seed_helpers_produce_readable_markdown_and_image():
assert "# Workshop" in make_markdown("Workshop")
assert make_image_bytes("Banner", 120, 80, (20, 30, 40)).startswith(b"\xff\xd8")
assert category_console_safe("دسته") == "دسته"
assert tag_console_safe("برچسب") == "برچسب"

View File

@@ -93,7 +93,7 @@ def generate_certificates(request, event_id: int, payload: CertificateGeneration
UserCertificateOut( UserCertificateOut(
id=certificate.id, id=certificate.id,
user_id=certificate.user_id, user_id=certificate.user_id,
user_name=certificate.user.get_full_name() or certificate.user.email, user_name=certificate.user.get_full_name() or certificate.user.email or certificate.user.username,
event_id=certificate.event_id, event_id=certificate.event_id,
title=certificate.title, title=certificate.title,
certificate_id=str(certificate.certificate_id), certificate_id=str(certificate.certificate_id),
@@ -125,7 +125,7 @@ def verify_certificate(request, certificate_code):
certificate_id=str(certificate.certificate_id), certificate_id=str(certificate.certificate_id),
certificate_code=certificate.code, certificate_code=certificate.code,
user_id=certificate.user_id, user_id=certificate.user_id,
user_name=certificate.user.get_full_name() or certificate.user.email, user_name=certificate.user.get_full_name() or certificate.user.email or certificate.user.username,
event_id=certificate.event_id, event_id=certificate.event_id,
event_title=certificate.event.title, event_title=certificate.event.title,
title=certificate.title, title=certificate.title,

View File

@@ -0,0 +1,151 @@
import pytest
import json
from io import BytesIO
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
from apps.certificates.models import CertificateTemplate, Skill, UserCertificate
from apps.events.models import Registration
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_certificate_requires_confirmed_registration(user_factory, event_factory):
user = user_factory()
event = event_factory()
template = CertificateTemplate.objects.create(event=event, image="")
with pytest.raises(ValidationError):
template.award_certificate(user=user, title="Award", score=80)
assert not UserCertificate.objects.exists()
def test_confirmed_attendee_receives_certificate(user_factory, event_factory, mocker):
mocker.patch("apps.events.tasks.send_registration_confirmation_notification.delay")
user = user_factory()
event = event_factory()
template = CertificateTemplate.objects.create(event=event, image="")
Registration.objects.create(event=event, user=user, status=Registration.StatusChoices.CONFIRMED)
certificate = template.award_certificate(user=user, title="Award", score=80)
assert certificate.score_label == "Perfect"
assert UserCertificate.objects.filter(user=user, event=event).count() == 1
assert len(certificate.code) == 10
def test_certificate_skill_ids_are_deduplicated_and_validated(user_factory, event_factory):
template = CertificateTemplate.objects.create(event=event_factory(), image="")
skill = Skill.objects.create(name="Python")
template.skills.add(skill)
assert template._resolve_skill_ids(None) == [skill.id]
assert template._resolve_skill_ids([skill.id, str(skill.id), None, "bad"]) == [skill.id]
assert template._resolve_skill_ids([]) == []
with pytest.raises(ValidationError):
template._resolve_skill_ids([999999])
def test_certificate_batch_rejects_empty_or_missing_users(event_factory):
template = CertificateTemplate.objects.create(event=event_factory(), image="")
with pytest.raises(ValidationError):
template.generate_certificates([])
with pytest.raises(ValidationError):
template.generate_certificates([{"score": 80}])
with pytest.raises(ValidationError):
template.generate_certificates([{"user_id": 999999, "score": 80}])
def test_certificate_api_staff_generation_and_public_verification(client, user_factory, event_factory, auth_headers, mocker):
mocker.patch("apps.events.tasks.send_registration_confirmation_notification.delay")
staff = user_factory(is_staff=True)
attendee = user_factory()
event = event_factory()
template = CertificateTemplate.objects.create(event=event, image="")
Registration.objects.create(event=event, user=attendee, status=Registration.StatusChoices.CONFIRMED)
path = f"/api/certificates/templates/{event.pk}"
assert client.get(path).status_code == 401
assert client.get(path, **auth_headers(attendee)).status_code == 403
response = client.get(path, **auth_headers(staff))
assert response.status_code == 200
assert response.json()["event_id"] == event.pk
generation = client.post(
f"{path}/generate",
data=json.dumps({"entries": [{"user_id": attendee.pk, "score": 84}]}),
content_type="application/json",
**auth_headers(staff),
)
assert generation.status_code == 200
assert generation.json()["certificates"][0]["user_name"] == attendee.username
certificate = UserCertificate.objects.get(user=attendee, event=event)
verified = client.get(f"/api/certificates/verify/{certificate.code}")
assert verified.status_code == 200
assert verified.json()["user_name"] == attendee.username
assert verified.json()["score"] == 84
def test_certificate_api_rejects_unregistered_attendee(client, user_factory, event_factory, auth_headers):
staff = user_factory(is_staff=True)
attendee = user_factory()
event = event_factory()
CertificateTemplate.objects.create(event=event, image="")
response = client.post(
f"/api/certificates/templates/{event.pk}/generate",
data=json.dumps({"entries": [{"user_id": attendee.pk, "score": 75}]}),
content_type="application/json",
**auth_headers(staff),
)
assert response.status_code == 400
assert not UserCertificate.objects.exists()
def test_batch_generation_updates_existing_awards_and_skills(user_factory, event_factory, mocker):
mocker.patch("apps.events.tasks.send_registration_confirmation_notification.delay")
user = user_factory()
event = event_factory()
template = CertificateTemplate.objects.create(event=event, image="")
skill = Skill.objects.create(name="Testing")
template.skills.add(skill)
Registration.objects.create(event=event, user=user, status=Registration.StatusChoices.ATTENDED)
first = template.generate_certificates([{"user_id": user.pk, "score": 24}], default_title="Initial")[0]
assert first.title == "Initial" and first.score_label == "Fair"
assert list(first.skills.all()) == [skill]
second = template.generate_certificates([{"user_id": user.pk, "score": 49, "skill_ids": []}],
default_title="Updated")[0]
assert second.pk == first.pk and second.title == "Updated" and second.score_label == "Good"
assert not second.skills.exists()
assert UserCertificate.objects.filter(user=user, event=event).count() == 1
def test_certificate_duplicate_code_is_regenerated(user_factory, event_factory, mocker):
mocker.patch("apps.events.tasks.send_registration_confirmation_notification.delay")
user = user_factory()
another = user_factory()
event = event_factory()
template = CertificateTemplate.objects.create(event=event, image="")
for attendee in (user, another):
Registration.objects.create(event=event, user=attendee, status=Registration.StatusChoices.CONFIRMED)
first = template.award_certificate(user=user, title="First", score=55)
second = template.award_certificate(user=another, title="Second", score=60)
second.code = first.code
second.save()
assert second.code != first.code
assert second.score_label == "Very Good"
def test_certificate_template_renders_award_image(user_factory, event_factory, mocker, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
mocker.patch("apps.events.tasks.send_registration_confirmation_notification.delay")
user = user_factory(first_name="Alice", last_name="Smith")
event = event_factory(title="Workshop")
buffer = BytesIO()
Image.new("RGB", (800, 500), "white").save(buffer, format="PNG")
template = CertificateTemplate.objects.create(
event=event,
image=SimpleUploadedFile("template.png", buffer.getvalue(), content_type="image/png"),
)
Registration.objects.create(event=event, user=user, status=Registration.StatusChoices.CONFIRMED)
certificate = template.award_certificate(user=user, title="Award", score=99)
certificate.refresh_from_db()
assert certificate.image and certificate.image.storage.exists(certificate.image.name)
with Image.open(certificate.image.path) as rendered:
assert rendered.size == (800, 500)

View File

@@ -0,0 +1,20 @@
import pytest
from django.core.exceptions import ValidationError
from apps.certificates.models import CertificateTemplate, UserCertificate, _generate_certificate_code
pytestmark = pytest.mark.unit
@pytest.mark.parametrize("score", [None, "invalid", -1, 101])
def test_invalid_certificate_scores_are_rejected(score):
with pytest.raises(ValidationError):
CertificateTemplate()._validate_score(score)
def test_valid_scores_and_short_codes():
assert CertificateTemplate()._validate_score("75") == 75
assert len(_generate_certificate_code()) == 10
assert UserCertificate(score=75).score_label == "Perfect"
assert UserCertificate(score=200).score_label == "Unknown"

View File

@@ -13,9 +13,9 @@ class AnnouncementSchema(ModelSchema):
author: AuthorSchema author: AuthorSchema
content_html: str content_html: str
class Config: class Meta:
model = Announcement model = Announcement
model_fields = [ fields = [
"id", "id",
"title", "title",
"content", "content",
@@ -64,9 +64,9 @@ class AnnouncementUpdateSchema(Schema):
class PushDeviceSchema(ModelSchema): class PushDeviceSchema(ModelSchema):
user: AuthorSchema user: AuthorSchema
class Config: class Meta:
model = PushNotificationDevice model = PushNotificationDevice
model_fields = ["id", "device_token", "device_type", "is_active", "created_at"] fields = ["id", "device_token", "device_type", "is_active", "created_at"]
class PushDeviceCreateSchema(Schema): class PushDeviceCreateSchema(Schema):

View File

@@ -17,6 +17,7 @@ from apps.communications.api.schemas import (
PushDeviceUpdateSchema, PushDeviceUpdateSchema,
) )
from apps.communications.models import Announcement, AnnouncementPriority, AnnouncementType, PushNotificationDevice from apps.communications.models import Announcement, AnnouncementPriority, AnnouncementType, PushNotificationDevice
from core.api.schemas import ErrorSchema
from core.authentication import jwt_auth from core.authentication import jwt_auth
communications_router = Router() communications_router = Router()
@@ -31,7 +32,7 @@ def _is_staff_user(user) -> bool:
def list_announcements(request, published_only: bool = True): def list_announcements(request, published_only: bool = True):
queryset = Announcement.objects.select_related("author").filter(is_deleted=False) queryset = Announcement.objects.select_related("author").filter(is_deleted=False)
if published_only: if published_only:
queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now()) queryset = queryset.filter(is_published=True).filter(Q(publish_date__isnull=True) | Q(publish_date__lte=timezone.now()))
items = [] items = []
for announcement in queryset.order_by("-created_at"): for announcement in queryset.order_by("-created_at"):
@@ -51,22 +52,35 @@ def list_announcements(request, published_only: bool = True):
return items return items
@communications_router.get("/announcements/{announcement_id}/", response=AnnouncementSchema) @communications_router.get("/announcements/stats/", response={200: AnnouncementStatsSchema, 403: ErrorSchema}, auth=jwt_auth)
def get_announcement_stats(request):
user = request.auth
if not _is_staff_user(user):
return 403, {"error": "Permission denied"}
return Announcement.objects.filter(is_deleted=False).aggregate(
total_announcements=Count("id"),
published_announcements=Count("id", filter=Q(is_published=True)),
draft_announcements=Count("id", filter=Q(is_published=False)),
urgent_announcements=Count("id", filter=Q(priority="urgent")),
)
@communications_router.get("/announcements/{announcement_id}/", response={200: AnnouncementSchema, 404: ErrorSchema})
def get_announcement(request, announcement_id: int): def get_announcement(request, announcement_id: int):
announcement = get_object_or_404( announcement = get_object_or_404(
Announcement.objects.select_related("author").filter(is_deleted=False), Announcement.objects.select_related("author").filter(is_deleted=False),
id=announcement_id, id=announcement_id,
) )
if not announcement.is_published and not _is_staff_user(getattr(request, "auth", None)): if not announcement.is_published and not _is_staff_user(getattr(request, "auth", None)):
return {"error": "Announcement not found"}, 404 return 404, {"error": "Announcement not found"}
return announcement return announcement
@communications_router.post("/announcements/", response=AnnouncementSchema, auth=jwt_auth) @communications_router.post("/announcements/", response={200: AnnouncementSchema, 403: ErrorSchema}, auth=jwt_auth)
def create_announcement(request, payload: AnnouncementCreateSchema): def create_announcement(request, payload: AnnouncementCreateSchema):
user = request.auth user = request.auth
if not _is_staff_user(user): if not _is_staff_user(user):
return {"error": "Permission denied"}, 403 return 403, {"error": "Permission denied"}
announcement = Announcement.objects.create( announcement = Announcement.objects.create(
author=user, author=user,
@@ -81,11 +95,11 @@ def create_announcement(request, payload: AnnouncementCreateSchema):
return announcement return announcement
@communications_router.put("/announcements/{announcement_id}/", response=AnnouncementSchema, auth=jwt_auth) @communications_router.put("/announcements/{announcement_id}/", response={200: AnnouncementSchema, 403: ErrorSchema}, auth=jwt_auth)
def update_announcement(request, announcement_id: int, payload: AnnouncementCreateSchema): def update_announcement(request, announcement_id: int, payload: AnnouncementCreateSchema):
user = request.auth user = request.auth
if not _is_staff_user(user): if not _is_staff_user(user):
return {"error": "Permission denied"}, 403 return 403, {"error": "Permission denied"}
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False) announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
announcement.title = payload.title announcement.title = payload.title
@@ -99,30 +113,16 @@ def update_announcement(request, announcement_id: int, payload: AnnouncementCrea
return announcement return announcement
@communications_router.delete("/announcements/{announcement_id}/", response=MessageResponseSchema, auth=jwt_auth) @communications_router.delete("/announcements/{announcement_id}/", response={200: MessageResponseSchema, 403: ErrorSchema}, auth=jwt_auth)
def delete_announcement(request, announcement_id: int): def delete_announcement(request, announcement_id: int):
user = request.auth user = request.auth
if not _is_staff_user(user): if not _is_staff_user(user):
return {"error": "Permission denied"}, 403 return 403, {"error": "Permission denied"}
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False) announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
announcement.soft_delete() announcement.delete()
return {"message": "Announcement deleted successfully"} return {"message": "Announcement deleted successfully"}
@communications_router.get("/announcements/stats/", response=AnnouncementStatsSchema, auth=jwt_auth)
def get_announcement_stats(request):
user = request.auth
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
stats = Announcement.objects.filter(is_deleted=False).aggregate(
total_announcements=Count("id"),
published_announcements=Count("id", filter=Q(is_published=True)),
draft_announcements=Count("id", filter=Q(is_published=False)),
urgent_announcements=Count("id", filter=Q(priority="urgent")),
)
return stats
@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth) @communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth)
def register_push_device(request, payload: PushDeviceCreateSchema): def register_push_device(request, payload: PushDeviceCreateSchema):
user = request.auth user = request.auth
@@ -138,7 +138,7 @@ def register_push_device(request, payload: PushDeviceCreateSchema):
return device return device
@communications_router.delete("/push-devices/", response=MessageResponseSchema, auth=jwt_auth) @communications_router.delete("/push-devices/", response={200: MessageResponseSchema, 404: MessageResponseSchema}, auth=jwt_auth)
def unregister_push_device(request, device_token: str): def unregister_push_device(request, device_token: str):
user = request.auth user = request.auth
try: try:
@@ -146,7 +146,7 @@ def unregister_push_device(request, device_token: str):
device.delete() device.delete()
return {"message": "Device unregistered successfully"} return {"message": "Device unregistered successfully"}
except PushNotificationDevice.DoesNotExist: except PushNotificationDevice.DoesNotExist:
return {"message": "Device not found"}, 404 return 404, {"message": "Device not found"}
@communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth) @communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth)

View File

@@ -0,0 +1,29 @@
import pytest
import json
from apps.communications.models import Announcement
from apps.users.models import User
from core.authentication import create_jwt_token
@pytest.mark.django_db
@pytest.mark.integration
class TestAnnouncementDeliveryTests:
def setup_method(self):
self.staff = User.objects.create_user(username='announcement_staff', mobile='09193000001', password='password', is_staff=True)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {create_jwt_token(self.staff)}'}
def test_publishing_announcement_does_not_send_manual_notifications(self, mocker):
notify_user = mocker.patch('apps.notifications.services.notify_user')
response = self.client.post('/api/communications/announcements/', json.dumps({'title': 'News', 'content': '# Story', 'is_published': True}), content_type='application/json', **self.auth)
assert response.status_code == 200
assert Announcement.objects.filter(title='News').exists()
notify_user.assert_not_called()
def test_newsletter_category_and_routes_are_removed(self):
response = self.client.post('/api/communications/announcements/', json.dumps({'title': 'News', 'content': 'Story', 'announcement_type': 'newsletter'}), content_type='application/json', **self.auth)
assert response.status_code == 422
assert self.client.post('/api/communications/newsletter/subscribe/').status_code == 404
@pytest.fixture(autouse=True)
def _pytest_client(self, client):
self.client = client

View File

@@ -0,0 +1,66 @@
import json
import pytest
from apps.communications.models import Announcement, PushNotificationDevice
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def _send(client, method, path, headers, payload=None):
kwargs = dict(headers)
if payload is not None:
kwargs.update(data=json.dumps(payload), content_type="application/json")
return getattr(client, method)(path, **kwargs)
def test_announcement_staff_crud_and_public_listing(client, user_factory, auth_headers):
staff = user_factory(is_staff=True)
member = user_factory()
staff_headers = auth_headers(staff)
member_headers = auth_headers(member)
payload = {"title": "Meeting", "content": "Welcome", "is_published": True}
assert _send(client, "post", "/api/communications/announcements/", member_headers, payload).status_code == 403
created = _send(client, "post", "/api/communications/announcements/", staff_headers, payload)
assert created.status_code == 200, created.content
item = Announcement.objects.get(title="Meeting")
listing = client.get("/api/communications/announcements/").json()
assert listing["items"][0]["id"] == item.id
assert client.get(f"/api/communications/announcements/{item.id}/").json()["title"] == "Meeting"
stats = _send(client, "get", "/api/communications/announcements/stats/", staff_headers)
assert stats.status_code == 200, stats.content
assert stats.json()["published_announcements"] == 1
updated = _send(client, "put", f"/api/communications/announcements/{item.id}/", staff_headers,
{"title": "Revised", "content": "New", "is_published": False})
assert updated.status_code == 200, updated.content
item.refresh_from_db()
assert item.title == "Revised" and not item.is_published
assert client.get("/api/communications/announcements/").json()["items"] == []
deleted = _send(client, "delete", f"/api/communications/announcements/{item.id}/", staff_headers)
assert deleted.status_code == 200, deleted.content
assert Announcement.deleted_objects.filter(pk=item.pk).exists()
def test_push_device_registration_update_and_removal(client, user_factory, auth_headers):
user = user_factory()
other = user_factory()
headers = auth_headers(user)
path = "/api/communications/push-devices/"
payload = {"device_token": "device-1", "device_type": "web"}
assert client.get(path).status_code == 401
first = _send(client, "post", path, headers, payload)
assert first.status_code == 200, first.content
device = PushNotificationDevice.objects.get(user=user, device_token="device-1")
device.is_active = False
device.save()
assert _send(client, "post", path, headers, payload).status_code == 200
device.refresh_from_db()
assert device.is_active
assert [row["id"] for row in _send(client, "get", path, headers).json()] == [device.id]
assert _send(client, "put", f"{path}{device.id}/", auth_headers(other), {"is_active": False}).status_code == 404
assert _send(client, "put", f"{path}{device.id}/", headers, {"is_active": False}).status_code == 200
device.refresh_from_db()
assert not device.is_active
assert _send(client, "delete", path + "?device_token=device-1", headers).status_code == 200
assert not PushNotificationDevice.objects.filter(pk=device.pk).exists()

View File

@@ -0,0 +1,23 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.communications.tasks import send_event_reminders
from apps.events.models import Registration
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_reminder_targets_confirmed_attendees_only(event_factory, user_factory, mocker):
notify = mocker.patch("apps.communications.tasks.notify_user")
now = timezone.now()
event = event_factory(start_time=now + timedelta(hours=24), end_time=now + timedelta(hours=26))
confirmed = user_factory()
pending = user_factory()
Registration.objects.create(event=event, user=confirmed, status=Registration.StatusChoices.CONFIRMED)
Registration.objects.create(event=event, user=pending, status=Registration.StatusChoices.PENDING)
assert send_event_reminders.run() == "Event reminders sent to 1 users"
notify.assert_called_once()
assert notify.call_args.args[0] == confirmed.id

View File

@@ -1,32 +0,0 @@
import json
from unittest import mock
from django.test import TestCase
from apps.communications.models import Announcement
from apps.users.models import User
from core.authentication import create_jwt_token
class AnnouncementDeliveryTests(TestCase):
def setUp(self):
self.staff = User.objects.create_user(
username="announcement_staff", mobile="09193000001", password="password", is_staff=True,
)
self.auth = {"HTTP_AUTHORIZATION": f"Bearer {create_jwt_token(self.staff)}"}
@mock.patch("apps.notifications.services.notify_user")
def test_publishing_announcement_does_not_send_manual_notifications(self, notify_user):
response = self.client.post("/api/communications/announcements/", json.dumps({
"title": "News", "content": "# Story", "is_published": True,
}), content_type="application/json", **self.auth)
self.assertEqual(response.status_code, 200)
self.assertTrue(Announcement.objects.filter(title="News").exists())
notify_user.assert_not_called()
def test_newsletter_category_and_routes_are_removed(self):
response = self.client.post("/api/communications/announcements/", json.dumps({
"title": "News", "content": "Story", "announcement_type": "newsletter",
}), content_type="application/json", **self.auth)
self.assertEqual(response.status_code, 422)
self.assertEqual(self.client.post("/api/communications/newsletter/subscribe/").status_code, 404)

View File

@@ -0,0 +1,76 @@
from types import SimpleNamespace
import pytest
from apps.communications.push_notifications import PushNotificationService
pytestmark = pytest.mark.unit
def test_push_delivery_passes_payload_to_gateway(mocker):
gateway = mocker.patch("apps.communications.push_notifications.webpush")
service = PushNotificationService()
assert service.send_notification({"endpoint": "https://push.test"}, {"title": "News"})
assert gateway.call_args.kwargs["ttl"] == 86400
assert '"title": "News"' in gateway.call_args.kwargs["data"]
def test_push_batch_counts_success_and_failures(mocker):
service = PushNotificationService()
mocker.patch.object(service, "send_notification", side_effect=[True, False])
devices = [SimpleNamespace(endpoint="a", p256dh_key="key", auth_key="auth"), SimpleNamespace(endpoint="b", p256dh_key="key", auth_key="auth")]
assert service.send_to_multiple(devices, {"title": "News"}) == {"sent": 1, "failed": 1}
def test_push_delivery_handles_gateway_failure(mocker):
gateway = mocker.patch("apps.communications.push_notifications.webpush", side_effect=RuntimeError("offline"))
service = PushNotificationService()
assert not service.send_notification({"endpoint": "https://push.test"}, {"title": "News"})
gateway.assert_called_once()
def test_push_announcement_and_reminder_build_expected_payloads(mocker):
service = PushNotificationService()
sender = mocker.patch.object(service, "send_to_multiple", return_value={"sent": 1, "failed": 0})
device = SimpleNamespace(endpoint="push", p256dh_key="key", auth_key="auth")
announcement = SimpleNamespace(id=7, title="Notice", content="x" * 110)
assert service.send_announcement_notification(announcement, [device]) == {"sent": 1, "failed": 0}
assert sender.call_args.args[0] == [device]
assert sender.call_args.args[1]["body"] == "x" * 100 + "..."
event = SimpleNamespace(id=9, title="Workshop")
service.send_event_reminder_notification(event, [device])
assert sender.call_args.args[1]["data"]["url"] == "/events/9/"
@pytest.mark.parametrize("audience,expected", [("all", {"is_active": True}),
("members", {"user__is_member": True, "is_active": True}),
("committee", {"user__is_committee_member": True, "is_active": True})])
def test_push_announcement_selects_audience(mocker, audience, expected):
service = PushNotificationService()
devices = mocker.patch("apps.communications.push_notifications.PushNotificationDevice.objects.filter")
sender = mocker.patch.object(service, "send_to_multiple", return_value={"sent": 0, "failed": 0})
announcement = SimpleNamespace(id=1, title="Alert", content="Short", audience=audience)
service.send_announcement_notification(announcement)
devices.assert_called_once_with(**expected)
sender.assert_called_once()
def test_push_reminder_selects_registered_devices(mocker):
service = PushNotificationService()
registrations = mocker.patch("apps.communications.push_notifications.Registration.objects.filter")
registrations.return_value.values_list.return_value = [1, 2]
devices = mocker.patch("apps.communications.push_notifications.PushNotificationDevice.objects.filter")
mocker.patch.object(service, "send_to_multiple", return_value={"sent": 0, "failed": 0})
event = SimpleNamespace(id=3, title="Event")
service.send_event_reminder_notification(event)
registrations.assert_called_once_with(event=event, status="confirmed")
devices.assert_called_once_with(user_id__in=[1, 2], is_active=True)
def test_invalid_push_subscription_is_removed(mocker):
service = PushNotificationService()
deleted = mocker.patch("apps.communications.push_notifications.PushNotificationDevice.objects.filter")
service._remove_invalid_subscription({"endpoint": "https://push.test"})
deleted.assert_called_once_with(endpoint="https://push.test")
deleted.return_value.delete.assert_called_once()

View File

@@ -0,0 +1,13 @@
import pytest
from apps.communications.resources import AnnouncementResource, PushNotificationDeviceResource
pytestmark = pytest.mark.unit
def test_communication_exports_reference_users_by_username():
announcements = AnnouncementResource()
devices = PushNotificationDeviceResource()
assert announcements.fields["author"].widget.field == "username"
assert devices.fields["user"].widget.field == "username"

View File

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

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)

View File

@@ -17,9 +17,9 @@ class GallerySchema(ModelSchema):
absolute_image_preview_url: Optional[str] = None absolute_image_preview_url: Optional[str] = None
absolute_image_blur_url: Optional[str] = None absolute_image_blur_url: Optional[str] = None
class Config: class Meta:
model = Gallery model = Gallery
model_fields = ['id', 'title', 'description', 'image', 'alt_text', fields = ['id', 'title', 'description', 'image', 'alt_text',
'width', 'height', 'is_public', 'created_at'] 'width', 'height', 'is_public', 'created_at']
@staticmethod @staticmethod

View File

@@ -109,14 +109,14 @@ def delete_image(request, image_id: int):
return 200, {"message": "Image deleted successfully"} return 200, {"message": "Image deleted successfully"}
@gallery_router.get("/deleted/images", response=List[GallerySchema], auth=jwt_auth) @gallery_router.get("/deleted/images", response={200: List[GallerySchema], 403: ErrorSchema}, auth=jwt_auth)
def list_deleted_gallery_images(request): def list_deleted_gallery_images(request):
"""List all soft-deleted gallery images (Admin/Committee only)""" """List all soft-deleted gallery images (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser): if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"} return 403, {"error": "Permission denied"}
return Gallery.deleted_objects.all().select_related('uploaded_by') return Gallery.deleted_objects.all().select_related('uploaded_by')
@gallery_router.post("/deleted/images/{image_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) @gallery_router.post("/deleted/images/{image_id}/restore", response={200: MessageSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
def restore_gallery_image(request, image_id: int): def restore_gallery_image(request, image_id: int):
"""Restore a soft-deleted gallery image (Admin/Committee only)""" """Restore a soft-deleted gallery image (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser): if not (request.auth.is_staff or request.auth.is_superuser):

View File

@@ -0,0 +1,49 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.gallery.models import Gallery
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_gallery_list_is_public_and_upload_requires_auth(client):
response = client.get("/api/gallery/images")
assert response.status_code == 200
assert response.json() == []
assert client.post("/api/gallery/images").status_code == 401
def test_gallery_upload_visibility_update_delete_and_restore(client, user_factory, auth_headers, mocker, tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
queued = mocker.patch("apps.gallery.api.views.process_uploaded_image.delay")
staff = user_factory(is_staff=True)
member = user_factory()
headers = auth_headers(staff)
denied = client.post("/api/gallery/images", {"file": SimpleUploadedFile("file.png", b"image", content_type="image/png")},
**auth_headers(member))
assert denied.status_code == 400
invalid = client.post("/api/gallery/images", {"file": SimpleUploadedFile("file.txt", b"text", content_type="text/plain")}, **headers)
assert invalid.status_code == 400
uploaded = client.post("/api/gallery/images", {"file": SimpleUploadedFile("file.png", b"image", content_type="image/png")}, **headers)
assert uploaded.status_code == 201, uploaded.content
image = Gallery.objects.get(pk=uploaded.json()["id"])
queued.assert_called_once_with(image.pk)
assert client.get(f"/api/gallery/images/{image.pk}").status_code == 200
assert [row["id"] for row in client.get("/api/gallery/images").json()] == [image.pk]
assert client.put(f"/api/gallery/images/{image.pk}", data='{"title":"Blocked"}',
content_type="application/json", **auth_headers(member)).status_code == 400
updated = client.put(f"/api/gallery/images/{image.pk}", data='{"title":"Private","is_public":false}',
content_type="application/json", **headers)
assert updated.status_code == 200, updated.content
image.refresh_from_db()
assert image.title == "Private" and not image.is_public
assert client.get("/api/gallery/images").json() == []
assert client.get("/api/gallery/images", {"public_only": "false"}).json()[0]["id"] == image.pk
assert client.get(f"/api/gallery/images/{image.pk}").status_code == 404
assert client.delete(f"/api/gallery/images/{image.pk}", **headers).status_code == 200
assert Gallery.deleted_objects.filter(pk=image.pk).exists()
assert client.get("/api/gallery/deleted/images", **auth_headers(member)).status_code == 403
assert client.get("/api/gallery/deleted/images", **headers).json()[0]["id"] == image.pk
assert client.post(f"/api/gallery/deleted/images/{image.pk}/restore", **headers).status_code == 200
assert Gallery.objects.filter(pk=image.pk).exists()

View File

@@ -0,0 +1,28 @@
import pytest
from django.core.files.base import ContentFile
from apps.gallery.models import Gallery
from apps.gallery.tasks import process_uploaded_image
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_gallery_processing_updates_metadata(user_factory, tmp_path, settings, mocker):
settings.MEDIA_ROOT = str(tmp_path)
gallery = Gallery(title="Photo", uploaded_by=user_factory())
gallery._defer_image_processing = True
gallery.image.save("gallery/photo.jpg", ContentFile(b"image data"))
result = mocker.Mock(file_size=10, width=20, height=30)
process = mocker.patch("apps.gallery.tasks.safe_process_public_image", return_value=result)
assert process_uploaded_image.run(gallery.pk) == "Processed image: Photo"
gallery.refresh_from_db()
assert (gallery.file_size, gallery.width, gallery.height) == (10, 20, 30)
process.assert_called_once()
def test_gallery_processing_missing_image_and_id(user_factory):
empty = Gallery.objects.create(title="Empty", uploaded_by=user_factory())
assert process_uploaded_image.run(empty.pk) is None
with pytest.raises(Gallery.DoesNotExist):
process_uploaded_image.run(999999)

View File

@@ -0,0 +1,41 @@
from types import SimpleNamespace
import pytest
from apps.gallery.admin import GalleryAdmin
pytestmark = pytest.mark.unit
def test_gallery_admin_metadata_and_previews():
admin = GalleryAdmin.__new__(GalleryAdmin)
empty = SimpleNamespace(image=None, file_size=0, width=0, height=0)
assert admin.image_preview(empty) == "No Image"
assert admin.image_preview_large(empty) == "No Image"
assert admin.file_size_display(empty) == "Unknown"
assert admin.dimensions(empty) == "Unknown"
image = SimpleNamespace(image=SimpleNamespace(url="/image.png"), file_size=1048576,
file_size_mb=1, width=100, height=80)
assert "/image.png" in admin.image_preview(image)
assert "/image.png" in admin.image_preview_large(image)
assert admin.file_size_display(image) == "1 MB"
assert admin.dimensions(image) == "100 × 80"
def test_gallery_admin_bulk_actions(mocker):
admin = GalleryAdmin.__new__(GalleryAdmin)
message = mocker.patch.object(admin, "message_user")
queryset = mocker.Mock()
queryset.count.return_value = 2
admin.make_public(None, queryset)
queryset.update.assert_called_with(is_public=True)
admin.make_private(None, queryset)
queryset.update.assert_called_with(is_public=False)
first = mocker.Mock()
second = mocker.Mock()
queryset.__iter__ = mocker.Mock(return_value=iter([first, second]))
admin.restore_images(None, queryset)
first.restore.assert_called_once()
second.restore.assert_called_once()
assert message.call_count == 3

View File

@@ -0,0 +1,17 @@
from types import SimpleNamespace
import pytest
from apps.gallery.api.schemas import GalleryCreateSchema, GallerySchema
pytestmark = pytest.mark.unit
def test_gallery_create_defaults_to_public():
assert GalleryCreateSchema(title="Photo").is_public
def test_gallery_image_resolver_handles_missing_image():
request = SimpleNamespace(build_absolute_uri=lambda url: f"https://example.test{url}")
assert GallerySchema.resolve_absolute_image_url(SimpleNamespace(image=None), {"request": request}) is None

View File

@@ -0,0 +1,51 @@
import json
import pytest
from apps.notifications import services
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_notification_api_lifecycle_uses_authenticated_redis_namespace(client, user_factory, auth_headers,
fake_notification_redis, monkeypatch):
monkeypatch.setattr(services, "redis_client", fake_notification_redis)
owner = user_factory()
other = user_factory()
item = services.RedisNotificationStore.add(str(owner.pk), {"type": "event", "title": "Workshop"})
path = "/api/notifications/"
assert client.get(path).status_code == 401
assert client.get(path, **auth_headers(other)).json()["notifications"] == []
listing = client.get(path, {"status": "unread", "limit": 1}, **auth_headers(owner))
assert listing.status_code == 200 and listing.json()["unread_count"] == 1
assert listing.json()["notifications"][0]["id"] == item["id"]
assert client.get(path, {"status": "invalid"}, **auth_headers(owner)).status_code == 400
assert client.post(path + "mark-seen", data=json.dumps({"id": "unknown"}),
content_type="application/json", **auth_headers(owner)).status_code == 404
seen = client.post(path + "mark-seen", data=json.dumps({"id": item["id"]}),
content_type="application/json", **auth_headers(owner))
assert seen.status_code == 200 and seen.json()["unread_count"] == 0
assert client.get(path, {"status": "read"}, **auth_headers(owner)).json()["count"] == 1
assert client.post(path + "mark-all-read", **auth_headers(owner)).json()["marked_read"] == 1
assert services.RedisNotificationStore.unread_count(str(owner.pk)) == 0
assert client.delete(path + item["id"], **auth_headers(owner)).json()["deleted"]
assert services.RedisNotificationStore.get(str(owner.pk), item["id"]) is None
def test_stream_token_endpoint_and_sse_auth(client, user_factory, auth_headers, settings):
user = user_factory()
headers = auth_headers(user)
settings.NOTIFICATIONS_ENABLED = False
assert client.post("/api/notifications/stream-token", **headers).status_code == 503
assert client.get("/api/notifications/stream/").status_code == 503
settings.NOTIFICATIONS_ENABLED = True
issued = client.post("/api/notifications/stream-token", **headers)
assert issued.status_code == 200, issued.content
token = issued.json()["token"]
assert client.get("/api/notifications/stream/").status_code == 401
assert client.get("/api/notifications/stream/", {"token": "bad"}).status_code == 401
response = client.get("/api/notifications/stream/", {"token": token})
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
response.close()

View File

@@ -0,0 +1,111 @@
import pytest
import json
from types import SimpleNamespace
from django.contrib import admin
from django.test import RequestFactory
from apps.notifications.api.views import get_notification, list_notifications
from apps.notifications.services import RedisNotificationStore
from apps.notifications.tasks import send_manual_notification
from apps.users.admin import ManualNotificationForm, UserAdmin
from apps.users.models import User
from core.authentication import create_jwt_token
@pytest.mark.django_db
@pytest.mark.integration
class TestManualNotificationTests:
def test_form_requires_title_and_plain_digest_but_allows_optional_body(self):
assert ManualNotificationForm({'title': 'Title', 'digest': 'Intro'}).is_valid()
assert not ManualNotificationForm({'title': '', 'digest': 'Intro'}).is_valid()
def test_task_sends_only_to_selected_active_users(self, mocker):
notify_user = mocker.patch('apps.notifications.tasks.notify_user')
active = User.objects.create_user(username='active', mobile='09190000001', password='password')
inactive = User.objects.create_user(username='inactive', mobile='09190000002', password='password', is_active=False)
sent = send_manual_notification.run([active.pk, inactive.pk], 'Title', 'Digest', '# Body')
assert sent == 1
notify_user.assert_called_once_with(active.pk, {'type': 'manual', 'title': 'Title', 'message': 'Digest', 'body_markdown': '# Body', 'level': 'info'})
def test_admin_action_queues_valid_composer_for_selected_users(self, mocker):
delay = mocker.patch('apps.users.admin.send_manual_notification.delay')
staff = User.objects.create_superuser(username='staff', mobile='09190000003', password='password')
recipient = User.objects.create_user(username='recipient', mobile='09190000004', password='password')
request = RequestFactory().post('/admin/users/user/', {'confirm_send': '1', 'title': 'Title', 'digest': 'Digest', 'body_markdown': '**Body**'})
request.user = staff
model_admin = UserAdmin(User, admin.site)
mocker.patch.object(model_admin, 'message_user')
model_admin.send_notification(request, User.objects.filter(pk=recipient.pk))
delay.assert_called_once_with([recipient.pk], 'Title', 'Digest', '**Body**')
def test_account_email_identity_is_true_when_an_email_is_supplied(self):
user = User.objects.create_user(username='emailuser', mobile='09190000005', password='password')
assert not user.is_email_verified
user.email = 'person@example.com'
user.save(update_fields=['email'])
user.refresh_from_db()
assert user.is_email_verified
def test_admin_changelist_uses_the_composer_before_queuing(self, mocker):
delay = mocker.patch('apps.users.admin.send_manual_notification.delay')
staff = User.objects.create_superuser(username='adminstaff', mobile='09190000008', password='password')
recipient = User.objects.create_user(username='target', mobile='09190000009', password='password')
self.client.force_login(staff)
action_data = {'action': 'send_notification', '_selected_action': str(recipient.pk), 'index': '0'}
path = '/admin/users/user/'
preview = self.client.post(path, action_data)
assert preview.status_code == 200
assert 'توضیح کوتاه' in preview.content.decode()
assert not delay.called
result = self.client.post(path, {**action_data, 'confirm_send': '1', 'title': 'Title', 'digest': 'Digest', 'body_markdown': '# Body'})
assert result.status_code in (200, 302)
delay.assert_called_once_with([recipient.pk], 'Title', 'Digest', '# Body')
@pytest.fixture(autouse=True)
def _pytest_client(self, client):
self.client = client
@pytest.mark.django_db
@pytest.mark.integration
class TestNotificationAPIIntegrationTests:
def test_detail_requires_auth_and_returns_body_only_to_recipient(self, mocker):
get = mocker.patch('apps.notifications.api.views.RedisNotificationStore.get')
owner = User.objects.create_user(username='owner', mobile='09190000006', password='password')
other = User.objects.create_user(username='other', mobile='09190000007', password='password')
detail = RedisNotificationStore._normalize_notification({'id': 'one', 'title': 'Title', 'message': 'Digest', 'body_markdown': '# Private', 'action_url': '/events/workshop'})
get.side_effect = lambda user_id, _: detail if user_id == str(owner.pk) else None
path = '/api/notifications/one'
assert self.client.get(path).status_code == 401
owner_response = self.client.get(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(owner)}')
assert owner_response.status_code == 200
assert owner_response.json()['body_markdown'] == '# Private'
assert owner_response.json()['action_url'] == '/events/workshop'
other_response = self.client.get(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(other)}')
assert other_response.status_code == 404
def test_list_excludes_full_body_and_uses_read_filter(self, mocker):
list_notifications_store = mocker.patch('apps.notifications.api.views.RedisNotificationStore.list')
_count = mocker.patch('apps.notifications.api.views.RedisNotificationStore.unread_count', return_value=1)
user = User.objects.create_user(username='reader', mobile='09190000010', password='password')
full = RedisNotificationStore._normalize_notification({'id': 'one', 'title': 'Title', 'message': 'Digest', 'body_markdown': '# Private'})
list_notifications_store.return_value = ([full], 1)
response = self.client.get('/api/notifications/?status=unread', HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(user)}')
assert response.status_code == 200
item = response.json()['notifications'][0]
assert item['has_body']
assert 'body_markdown' not in item
assert list_notifications_store.call_args.kwargs['read_filter'] == 'unread'
def test_delete_only_removes_the_authenticated_users_notification(self, mocker):
delete = mocker.patch('apps.notifications.api.views.RedisNotificationStore.delete')
_count = mocker.patch('apps.notifications.api.views.RedisNotificationStore.unread_count', return_value=0)
owner = User.objects.create_user(username='delete_owner', mobile='09190000011', password='password')
other = User.objects.create_user(username='delete_other', mobile='09190000012', password='password')
delete.side_effect = lambda user_id, _: user_id == str(owner.pk)
path = '/api/notifications/one'
assert self.client.delete(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(other)}').status_code == 404
assert self.client.delete(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(owner)}').status_code == 200
@pytest.fixture(autouse=True)
def _pytest_client(self, client):
self.client = client

View File

@@ -0,0 +1,38 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.notifications import services
pytestmark = pytest.mark.integration
def test_fake_redis_store_add_filter_mark_and_delete(fake_notification_redis, monkeypatch):
monkeypatch.setattr(services, "redis_client", fake_notification_redis)
first = services.RedisNotificationStore.add("user-1", {"type": "news", "title": "One", "body_markdown": "# Long body"})
second = services.RedisNotificationStore.add("user-1", {"type": "event", "title": "Two"})
assert services.RedisNotificationStore.unread_count("user-1") == 2
assert services.RedisNotificationStore.list("user-1", type_filter="news")[1] == 1
assert services.RedisNotificationStore.get("user-2", first["id"]) is None
marked = services.RedisNotificationStore.mark_seen("user-1", first["id"])
assert marked["unread_count"] == 1
assert services.RedisNotificationStore.list("user-1", read_filter="read")[1] == 1
assert services.RedisNotificationStore.mark_all_seen("user-1") == 2
assert services.RedisNotificationStore.unread_count("user-1") == 0
assert services.RedisNotificationStore.delete("user-1", second["id"])
assert not services.RedisNotificationStore.delete("user-1", second["id"])
assert services.RedisNotificationStore.get("user-1", second["id"]) is None
def test_fake_redis_store_cleans_expired_and_corrupt_records(fake_notification_redis, monkeypatch):
monkeypatch.setattr(services, "redis_client", fake_notification_redis)
old = (timezone.now() - timedelta(days=90)).isoformat()
expired = services.RedisNotificationStore.add("user-1", {"created_at": old, "title": "Old"})
recent = services.RedisNotificationStore.add("user-1", {"title": "Recent"})
assert services.RedisNotificationStore.cleanup_expired(retention_days=30) == 1
assert services.RedisNotificationStore.get("user-1", expired["id"]) is None
fake_notification_redis.hset(services.RedisNotificationStore._data_key("user-1"), recent["id"], "broken json")
assert services.RedisNotificationStore.list("user-1") == ([], 0)
assert services.RedisNotificationStore.get("user-1", recent["id"]) is None

View File

@@ -1,170 +0,0 @@
import json
from types import SimpleNamespace
from unittest import mock
from django.contrib import admin
from django.test import RequestFactory, SimpleTestCase, TestCase
from apps.notifications.api.views import get_notification, list_notifications
from apps.notifications.services import RedisNotificationStore
from apps.notifications.tasks import send_manual_notification
from apps.users.admin import ManualNotificationForm, UserAdmin
from apps.users.models import User
from core.authentication import create_jwt_token
class NotificationStoreTests(SimpleTestCase):
def test_summary_hides_full_body_and_advertises_it(self):
notification = RedisNotificationStore._normalize_notification({
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Full body",
})
summary = RedisNotificationStore.summary(notification)
self.assertTrue(summary["has_body"])
self.assertNotIn("body_markdown", summary)
self.assertEqual(notification["body_markdown"], "# Full body")
@mock.patch("apps.notifications.services.redis_client")
def test_read_filter_applies_before_pagination(self, redis_client):
redis_client.zrevrange.return_value = ["one", "two", "three"]
redis_client.pipeline.return_value.execute.return_value = [
json.dumps({"id": "one", "is_seen": False}),
json.dumps({"id": "two", "is_seen": True}),
json.dumps({"id": "three", "is_seen": False}),
]
unread, count = RedisNotificationStore.list("user", limit=1, offset=1, read_filter="unread")
self.assertEqual(count, 2)
self.assertEqual([item["id"] for item in unread], ["three"])
read, count = RedisNotificationStore.list("user", read_filter="read")
self.assertEqual(count, 1)
self.assertEqual(read[0]["id"], "two")
@mock.patch("apps.notifications.api.views.RedisNotificationStore")
def test_detail_is_scoped_to_authenticated_recipient(self, store):
store.get.side_effect = lambda user_id, _: {"id": "one", "body_markdown": "# Secret"} if user_id == "1" else None
owner = SimpleNamespace(auth=SimpleNamespace(id=1))
other = SimpleNamespace(auth=SimpleNamespace(id=2))
self.assertEqual(get_notification(owner, "one")[0], 200)
self.assertEqual(get_notification(other, "one")[0], 404)
store.get.assert_any_call("2", "one")
@mock.patch("apps.notifications.api.views.RedisNotificationStore")
def test_list_returns_summaries_and_rejects_unknown_status(self, store):
request = SimpleNamespace(auth=SimpleNamespace(id=1))
store.list.return_value = ([{"id": "one", "body_markdown": "# Secret"}], 1)
store.summary.side_effect = RedisNotificationStore.summary
store.unread_count.return_value = 1
result = list_notifications(request, status="unread")
self.assertNotIn("body_markdown", result["notifications"][0])
self.assertEqual(store.list.call_args.kwargs["read_filter"], "unread")
self.assertEqual(list_notifications(request, status="invalid").status_code, 400)
@mock.patch.object(RedisNotificationStore, "_publish_event")
@mock.patch.object(RedisNotificationStore, "unread_count", return_value=0)
@mock.patch.object(RedisNotificationStore, "get")
@mock.patch("apps.notifications.services.redis_client")
def test_marking_old_ephemeral_notification_read_keeps_it_visible(self, redis_client, get, _count, _publish):
get.return_value = {"id": "one", "is_seen": False, "delete_on_seen": True, "body_markdown": "# Body"}
result = RedisNotificationStore.mark_seen("user", "one")
self.assertFalse(result["deleted"])
self.assertTrue(result["notification"]["is_seen"])
redis_client.hset.assert_called_once()
redis_client.pipeline.assert_not_called()
class ManualNotificationTests(TestCase):
def test_form_requires_title_and_plain_digest_but_allows_optional_body(self):
self.assertTrue(ManualNotificationForm({"title": "Title", "digest": "Intro"}).is_valid())
self.assertFalse(ManualNotificationForm({"title": "", "digest": "Intro"}).is_valid())
@mock.patch("apps.notifications.tasks.notify_user")
def test_task_sends_only_to_selected_active_users(self, notify_user):
active = User.objects.create_user(username="active", mobile="09190000001", password="password")
inactive = User.objects.create_user(username="inactive", mobile="09190000002", password="password", is_active=False)
sent = send_manual_notification.run([active.pk, inactive.pk], "Title", "Digest", "# Body")
self.assertEqual(sent, 1)
notify_user.assert_called_once_with(active.pk, {
"type": "manual", "title": "Title", "message": "Digest", "body_markdown": "# Body", "level": "info",
})
@mock.patch("apps.users.admin.send_manual_notification.delay")
def test_admin_action_queues_valid_composer_for_selected_users(self, delay):
staff = User.objects.create_superuser(username="staff", mobile="09190000003", password="password")
recipient = User.objects.create_user(username="recipient", mobile="09190000004", password="password")
request = RequestFactory().post("/admin/users/user/", {
"confirm_send": "1", "title": "Title", "digest": "Digest", "body_markdown": "**Body**",
})
request.user = staff
model_admin = UserAdmin(User, admin.site)
with mock.patch.object(model_admin, "message_user"):
model_admin.send_notification(request, User.objects.filter(pk=recipient.pk))
delay.assert_called_once_with([recipient.pk], "Title", "Digest", "**Body**")
def test_account_email_identity_is_true_when_an_email_is_supplied(self):
user = User.objects.create_user(username="emailuser", mobile="09190000005", password="password")
self.assertFalse(user.is_email_verified)
user.email = "person@example.com"
user.save(update_fields=["email"])
user.refresh_from_db()
self.assertTrue(user.is_email_verified)
@mock.patch("apps.users.admin.send_manual_notification.delay")
def test_admin_changelist_uses_the_composer_before_queuing(self, delay):
staff = User.objects.create_superuser(username="adminstaff", mobile="09190000008", password="password")
recipient = User.objects.create_user(username="target", mobile="09190000009", password="password")
self.client.force_login(staff)
action_data = {"action": "send_notification", "_selected_action": str(recipient.pk), "index": "0"}
path = "/admin/users/user/"
preview = self.client.post(path, action_data)
self.assertEqual(preview.status_code, 200)
self.assertContains(preview, "توضیح کوتاه")
self.assertFalse(delay.called)
result = self.client.post(path, {
**action_data, "confirm_send": "1", "title": "Title", "digest": "Digest", "body_markdown": "# Body",
})
self.assertIn(result.status_code, (200, 302))
delay.assert_called_once_with([recipient.pk], "Title", "Digest", "# Body")
class NotificationAPIIntegrationTests(TestCase):
@mock.patch("apps.notifications.api.views.RedisNotificationStore.get")
def test_detail_requires_auth_and_returns_body_only_to_recipient(self, get):
owner = User.objects.create_user(username="owner", mobile="09190000006", password="password")
other = User.objects.create_user(username="other", mobile="09190000007", password="password")
detail = RedisNotificationStore._normalize_notification({
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Private",
"action_url": "/events/workshop",
})
get.side_effect = lambda user_id, _: detail if user_id == str(owner.pk) else None
path = "/api/notifications/one"
self.assertEqual(self.client.get(path).status_code, 401)
owner_response = self.client.get(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(owner)}")
self.assertEqual(owner_response.status_code, 200)
self.assertEqual(owner_response.json()["body_markdown"], "# Private")
self.assertEqual(owner_response.json()["action_url"], "/events/workshop")
other_response = self.client.get(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(other)}")
self.assertEqual(other_response.status_code, 404)
@mock.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=1)
@mock.patch("apps.notifications.api.views.RedisNotificationStore.list")
def test_list_excludes_full_body_and_uses_read_filter(self, list_notifications_store, _count):
user = User.objects.create_user(username="reader", mobile="09190000010", password="password")
full = RedisNotificationStore._normalize_notification({
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Private",
})
list_notifications_store.return_value = ([full], 1)
response = self.client.get("/api/notifications/?status=unread", HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(user)}")
self.assertEqual(response.status_code, 200)
item = response.json()["notifications"][0]
self.assertTrue(item["has_body"])
self.assertNotIn("body_markdown", item)
self.assertEqual(list_notifications_store.call_args.kwargs["read_filter"], "unread")
@mock.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=0)
@mock.patch("apps.notifications.api.views.RedisNotificationStore.delete")
def test_delete_only_removes_the_authenticated_users_notification(self, delete, _count):
owner = User.objects.create_user(username="delete_owner", mobile="09190000011", password="password")
other = User.objects.create_user(username="delete_other", mobile="09190000012", password="password")
delete.side_effect = lambda user_id, _: user_id == str(owner.pk)
path = "/api/notifications/one"
self.assertEqual(self.client.delete(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(other)}").status_code, 404)
self.assertEqual(self.client.delete(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(owner)}").status_code, 200)

View File

@@ -0,0 +1,64 @@
import pytest
import json
from types import SimpleNamespace
from django.contrib import admin
from django.test import RequestFactory
from apps.notifications.api.views import get_notification, list_notifications
from apps.notifications.services import RedisNotificationStore
from apps.notifications.tasks import send_manual_notification
from apps.users.admin import ManualNotificationForm, UserAdmin
from apps.users.models import User
from core.authentication import create_jwt_token
@pytest.mark.unit
class TestNotificationStoreTests:
def test_summary_hides_full_body_and_advertises_it(self):
notification = RedisNotificationStore._normalize_notification({'id': 'one', 'title': 'Title', 'message': 'Digest', 'body_markdown': '# Full body'})
summary = RedisNotificationStore.summary(notification)
assert summary['has_body']
assert 'body_markdown' not in summary
assert notification['body_markdown'] == '# Full body'
def test_read_filter_applies_before_pagination(self, mocker):
redis_client = mocker.patch('apps.notifications.services.redis_client')
redis_client.zrevrange.return_value = ['one', 'two', 'three']
redis_client.pipeline.return_value.execute.return_value = [json.dumps({'id': 'one', 'is_seen': False}), json.dumps({'id': 'two', 'is_seen': True}), json.dumps({'id': 'three', 'is_seen': False})]
unread, count = RedisNotificationStore.list('user', limit=1, offset=1, read_filter='unread')
assert count == 2
assert [item['id'] for item in unread] == ['three']
read, count = RedisNotificationStore.list('user', read_filter='read')
assert count == 1
assert read[0]['id'] == 'two'
def test_detail_is_scoped_to_authenticated_recipient(self, mocker):
store = mocker.patch('apps.notifications.api.views.RedisNotificationStore')
store.get.side_effect = lambda user_id, _: {'id': 'one', 'body_markdown': '# Secret'} if user_id == '1' else None
owner = SimpleNamespace(auth=SimpleNamespace(id=1))
other = SimpleNamespace(auth=SimpleNamespace(id=2))
assert get_notification(owner, 'one')[0] == 200
assert get_notification(other, 'one')[0] == 404
store.get.assert_any_call('2', 'one')
def test_list_returns_summaries_and_rejects_unknown_status(self, mocker):
store = mocker.patch('apps.notifications.api.views.RedisNotificationStore')
request = SimpleNamespace(auth=SimpleNamespace(id=1))
store.list.return_value = ([{'id': 'one', 'body_markdown': '# Secret'}], 1)
store.summary.side_effect = RedisNotificationStore.summary
store.unread_count.return_value = 1
result = list_notifications(request, status='unread')
assert 'body_markdown' not in result['notifications'][0]
assert store.list.call_args.kwargs['read_filter'] == 'unread'
assert list_notifications(request, status='invalid').status_code == 400
def test_marking_old_ephemeral_notification_read_keeps_it_visible(self, mocker):
redis_client = mocker.patch('apps.notifications.services.redis_client')
get = mocker.patch.object(RedisNotificationStore, 'get')
_count = mocker.patch.object(RedisNotificationStore, 'unread_count', return_value=0)
_publish = mocker.patch.object(RedisNotificationStore, '_publish_event')
get.return_value = {'id': 'one', 'is_seen': False, 'delete_on_seen': True, 'body_markdown': '# Body'}
result = RedisNotificationStore.mark_seen('user', 'one')
assert not result['deleted']
assert result['notification']['is_seen']
redis_client.hset.assert_called_once()
redis_client.pipeline.assert_not_called()

View File

@@ -0,0 +1,28 @@
import json
import pytest
from apps.notifications.api.views import NotificationStreamView
from apps.notifications.services import RedisNotificationStore
pytestmark = pytest.mark.unit
def test_sse_stream_sends_snapshot_update_and_closes_pubsub(mocker, settings):
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 60
pubsub = mocker.Mock()
pubsub.get_message.side_effect = [{"type": "message", "data": json.dumps({"event": "notification", "data": {"id": "one"}})}]
mocker.patch("apps.notifications.api.views.RedisNotificationStore.get_pubsub", return_value=pubsub)
mocker.patch("apps.notifications.api.views.RedisNotificationStore.list", return_value=([{"id": "one"}], 1))
mocker.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=1)
mocker.patch("apps.notifications.api.views.RedisNotificationStore.summary", return_value={"id": "one"})
stream = NotificationStreamView()._build_stream("42")
assert next(stream).startswith("retry:")
assert "connected" in next(stream)
assert "unread_count" in next(stream)
assert '"id": "one"' in next(stream)
stream.close()
pubsub.subscribe.assert_called_once_with(RedisNotificationStore._channel_key("42"))
pubsub.unsubscribe.assert_called_once()
pubsub.close.assert_called_once()

View File

@@ -0,0 +1,22 @@
import pytest
from django.core import signing
from apps.notifications.api.views import _format_sse_event, _issue_stream_token_for_user, _safe_int, _validate_stream_token
pytestmark = pytest.mark.unit
def test_stream_token_round_trip_and_invalid_token():
token = _issue_stream_token_for_user("42")
assert _validate_stream_token(token) == "42"
with pytest.raises(signing.BadSignature):
_validate_stream_token("wrong")
with pytest.raises(signing.BadSignature):
_validate_stream_token(None)
def test_sse_formatter_and_safe_integer_limits():
assert _safe_int("-5", 10) == 0
assert _safe_int("oops", 10) == 10
assert 'event: connected\ndata: {"ready": true}\n\n' == _format_sse_event("connected", {"ready": True})

View File

@@ -0,0 +1,87 @@
import json
import pytest
from apps.events.models import Registration
from apps.payments.models import DiscountCode, Payment
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def _send(client, method, path, headers, payload=None):
kwargs = dict(headers)
if payload is not None:
kwargs.update(data=json.dumps(payload), content_type="application/json")
return getattr(client, method)(path, **kwargs)
def test_discount_code_admin_crud_and_restore(client, user_factory, auth_headers, event_factory):
staff = user_factory(is_staff=True)
admin = user_factory(is_staff=True, is_superuser=True)
member = user_factory()
event = event_factory(price=10000)
path = "/api/payments/admin/discount-codes"
payload = {"code": "pytest10", "type": "percent", "value": 10, "applicable_event_ids": [event.pk]}
assert _send(client, "get", path, auth_headers(member)).status_code == 403
assert _send(client, "post", path, auth_headers(member), payload).status_code == 403
created = _send(client, "post", path, auth_headers(staff), payload)
assert created.status_code == 201, created.content
code = DiscountCode.objects.get(pk=created.json()["id"])
assert code.code == "PYTEST10" and list(code.applicable_events.all()) == [event]
assert _send(client, "post", path, auth_headers(staff), payload).status_code == 400
assert _send(client, "get", path + "?search=PYTEST10", auth_headers(staff)).json()["count"] == 1
changed = _send(client, "put", f"{path}/{code.pk}", auth_headers(staff), {**payload, "value": 20})
assert changed.status_code == 200, changed.content
code.refresh_from_db()
assert code.value == 20
assert _send(client, "delete", f"{path}/{code.pk}", auth_headers(staff)).status_code == 403
assert _send(client, "delete", f"{path}/{code.pk}", auth_headers(admin)).status_code == 200
assert DiscountCode.deleted_objects.filter(pk=code.pk).exists()
assert _send(client, "post", f"{path}/{code.pk}/restore", auth_headers(admin)).status_code == 200
assert DiscountCode.objects.filter(pk=code.pk).exists()
@pytest.mark.parametrize("gateway_result,expected", [({"errors": {"code": -9}}, 502), (None, 502)])
def test_payment_create_gateway_failures_leave_no_active_payment(client, user_factory, auth_headers, event_factory,
mocker, settings, gateway_result, expected):
settings.ZARINPAL_MERCHANT_ID = "test"
settings.ZARINPAL_REQUEST_URL = "https://gateway.example/request"
user = user_factory()
event = event_factory(price=25000)
gateway = mocker.patch("apps.payments.api.views.requests.post")
if gateway_result is None:
gateway.side_effect = RuntimeError("offline")
else:
gateway.return_value.json.return_value = gateway_result
response = _send(client, "post", "/api/payments/create", auth_headers(user),
{"event_id": event.pk, "description": "Ticket"})
assert response.status_code == expected
assert not Payment.objects.filter(user=user, event=event).exists()
assert Registration.objects.filter(user=user, event=event).exists()
@pytest.mark.parametrize("gateway_result", [None, {"data": {"code": -1}}])
def test_payment_callback_verification_failures_notify_once(client, user_factory, event_factory, mocker,
settings, gateway_result):
settings.ZARINPAL_MERCHANT_ID = "test"
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
settings.FRONTEND_CALLBACK_URL = "https://frontend.example/result"
user = user_factory()
event = event_factory(price=25000)
registration = Registration.objects.create(event=event, user=user, status=Registration.StatusChoices.PENDING)
payment = Payment.objects.create(user=user, event=event, registration=registration, amount=25000,
base_amount=25000, authority="AUTH-FAIL", status=Payment.OrderStatusChoices.PENDING)
gateway = mocker.patch("apps.payments.api.views.requests.post")
notify = mocker.patch("apps.payments.api.views.notify_user")
if gateway_result is None:
gateway.side_effect = RuntimeError("offline")
else:
gateway.return_value.json.return_value = gateway_result
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"})
assert response.status_code == 302 and "status=failed" in response.url
payment.refresh_from_db()
registration.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.FAILED
assert registration.status == Registration.StatusChoices.PENDING
notify.assert_called_once()

View File

@@ -1,11 +1,9 @@
import pytest
import uuid import uuid
from datetime import timedelta from datetime import timedelta
from types import SimpleNamespace from types import SimpleNamespace
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from django.contrib.admin import AdminSite from django.contrib.admin import AdminSite
from apps.payments.admin import DiscountCodeAdmin from apps.payments.admin import DiscountCodeAdmin
from apps.payments.models import DiscountCode, Payment from apps.payments.models import DiscountCode, Payment
@@ -14,50 +12,32 @@ from apps.events.models import Event
from apps.users.models import User from apps.users.models import User
from ninja.errors import HttpError from ninja.errors import HttpError
class PaymentTestMixin: class PaymentTestMixin:
@staticmethod @staticmethod
def _create_user(**overrides): def _create_user(**overrides):
data = { data = {'username': f'user_{uuid.uuid4().hex[:6]}', 'email': f'user_{uuid.uuid4().hex[:6]}@example.com', 'mobile': f'09{uuid.uuid4().int % 1000000000:09d}', 'password': 'Test!1234'}
"username": f"user_{uuid.uuid4().hex[:6]}",
"email": f"user_{uuid.uuid4().hex[:6]}@example.com",
"password": "Test!1234",
}
data.update(overrides) data.update(overrides)
return User.objects.create_user(**data) return User.objects.create_user(**data)
@staticmethod @staticmethod
def _create_event(**overrides): def _create_event(**overrides):
now = timezone.now() now = timezone.now()
defaults = { defaults = {'title': 'Sample', 'description': 'Desc', '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]}', 'price': 100000, 'capacity': 10, 'status': Event.StatusChoices.PUBLISHED}
"title": "Sample",
"description": "Desc",
"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]}",
"price": 100000,
"capacity": 10,
"status": Event.StatusChoices.PUBLISHED,
}
defaults.update(overrides) defaults.update(overrides)
return Event.objects.create(**defaults) return Event.objects.create(**defaults)
@staticmethod @staticmethod
def _discount_code(**overrides): def _discount_code(**overrides):
defaults = { defaults = {'code': f'CODE{uuid.uuid4().hex[:4]}', 'value': 50, 'is_active': True, 'type': DiscountCode.Type.PERCENT}
"code": f"CODE{uuid.uuid4().hex[:4]}",
"value": 50,
"is_active": True,
"type": DiscountCode.Type.PERCENT,
}
defaults.update(overrides) defaults.update(overrides)
return DiscountCode.objects.create(**defaults) return DiscountCode.objects.create(**defaults)
@pytest.mark.django_db
@pytest.mark.integration
class TestDiscountCodeModelTests(PaymentTestMixin):
class DiscountCodeModelTests(TestCase, PaymentTestMixin): def setup_method(self):
def setUp(self):
self.event = self._create_event() self.event = self._create_event()
self.user = self._create_user(is_email_verified=True) self.user = self._create_user(is_email_verified=True)
@@ -65,124 +45,98 @@ class DiscountCodeModelTests(TestCase, PaymentTestMixin):
event = self._create_event(price=0) event = self._create_event(price=0)
code = self._discount_code() code = self._discount_code()
code.applicable_events.add(event) code.applicable_events.add(event)
self.assertEqual(code.calculate_discount(event, self.user), (0, 0)) assert code.calculate_discount(event, self.user) == (0, 0)
def test_inactive_raises_error(self): def test_inactive_raises_error(self):
code = self._discount_code(is_active=False) code = self._discount_code(is_active=False)
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
with self.assertRaises(HttpError): with pytest.raises(HttpError):
code.calculate_discount(self.event, self.user) code.calculate_discount(self.event, self.user)
def test_start_date_validation(self): def test_start_date_validation(self):
code = self._discount_code(starts_at=timezone.now() + timedelta(days=1)) code = self._discount_code(starts_at=timezone.now() + timedelta(days=1))
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
with self.assertRaises(HttpError): with pytest.raises(HttpError):
code.calculate_discount(self.event, self.user) code.calculate_discount(self.event, self.user)
def test_end_date_validation(self): def test_end_date_validation(self):
code = self._discount_code(ends_at=timezone.now() - timedelta(days=1)) code = self._discount_code(ends_at=timezone.now() - timedelta(days=1))
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
with self.assertRaises(HttpError): with pytest.raises(HttpError):
code.calculate_discount(self.event, self.user) code.calculate_discount(self.event, self.user)
def test_applicable_events_enforcement(self): def test_applicable_events_enforcement(self):
code = self._discount_code() code = self._discount_code()
other_event = self._create_event() other_event = self._create_event()
code.applicable_events.add(other_event) code.applicable_events.add(other_event)
with self.assertRaises(HttpError): with pytest.raises(HttpError):
code.calculate_discount(self.event, self.user) code.calculate_discount(self.event, self.user)
def test_min_amount_guard(self): def test_min_amount_guard(self):
code = self._discount_code(min_amount=200000) code = self._discount_code(min_amount=200000)
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
with self.assertRaises(HttpError): with pytest.raises(HttpError):
code.calculate_discount(self.event, self.user) code.calculate_discount(self.event, self.user)
def test_usage_limit_total(self): def test_usage_limit_total(self):
code = self._discount_code(usage_limit_total=1) code = self._discount_code(usage_limit_total=1)
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
Payment.objects.create( Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, discount_amount=0, status=Payment.OrderStatusChoices.PAID, discount_code=code)
user=self.user, with pytest.raises(HttpError):
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
discount_amount=0,
status=Payment.OrderStatusChoices.PAID,
discount_code=code,
)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user) code.calculate_discount(self.event, self.user)
def test_usage_limit_per_user(self): def test_usage_limit_per_user(self):
code = self._discount_code(usage_limit_per_user=1) code = self._discount_code(usage_limit_per_user=1)
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
Payment.objects.create( Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, discount_amount=0, status=Payment.OrderStatusChoices.PENDING, discount_code=code)
user=self.user, with pytest.raises(HttpError):
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
discount_amount=0,
status=Payment.OrderStatusChoices.PENDING,
discount_code=code,
)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user) code.calculate_discount(self.event, self.user)
def test_final_price_below_min_post_discount(self): def test_final_price_below_min_post_discount(self):
event = self._create_event(price=15000) event = self._create_event(price=15000)
code = self._discount_code(value=80) code = self._discount_code(value=80)
code.applicable_events.add(event) code.applicable_events.add(event)
with self.assertRaises(HttpError): with pytest.raises(HttpError):
code.calculate_discount(event, self.user) code.calculate_discount(event, self.user)
def test_fixed_discount_type(self): def test_fixed_discount_type(self):
code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000) code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000)
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
final, disc = code.calculate_discount(self.event, self.user) final, disc = code.calculate_discount(self.event, self.user)
self.assertEqual(disc, 5000) assert disc == 5000
self.assertEqual(final, self.event.price - 5000) assert final == self.event.price - 5000
@pytest.mark.django_db
@pytest.mark.integration
class TestPaymentModelAndResourceTests(PaymentTestMixin):
class PaymentModelAndResourceTests(TestCase, PaymentTestMixin): def setup_method(self):
def setUp(self):
self.event = self._create_event() self.event = self._create_event()
self.user = self._create_user(is_email_verified=True) self.user = self._create_user(is_email_verified=True)
def test_payment_clean_validates_amount(self): def test_payment_clean_validates_amount(self):
payment = Payment( payment = Payment(user=self.user, event=self.event, base_amount=1000, amount=500, discount_amount=400, status=Payment.OrderStatusChoices.INIT)
user=self.user, with pytest.raises(ValidationError):
event=self.event,
base_amount=1000,
amount=500,
discount_amount=400,
status=Payment.OrderStatusChoices.INIT,
)
with self.assertRaises(ValidationError):
payment.full_clean() payment.full_clean()
def test_payment_resource_defers_user_event(self): def test_payment_resource_defers_user_event(self):
payment = Payment.objects.create( payment = Payment.objects.create(user=self.user, event=self.event, base_amount=1000, amount=1000, discount_amount=0, status=Payment.OrderStatusChoices.INIT)
user=self.user,
event=self.event,
base_amount=1000,
amount=1000,
discount_amount=0,
status=Payment.OrderStatusChoices.INIT,
)
resource = PaymentResource() resource = PaymentResource()
user_cell = resource.fields["user"].widget.clean(self.user.username, None) user_cell = resource.fields['user'].widget.clean(self.user.username, None)
self.assertEqual(user_cell, self.user) assert user_cell == self.user
event_cell = resource.fields["event"].widget.clean(self.event.title, None) event_cell = resource.fields['event'].widget.clean(self.event.title, None)
self.assertEqual(event_cell, self.event) assert event_cell == self.event
def test_discount_resource_expands_events(self): def test_discount_resource_expands_events(self):
resource = DiscountResource() resource = DiscountResource()
widget = resource.fields["event"].widget widget = resource.fields['event'].widget
self.assertEqual(widget.separator, "||") assert widget.separator == '||'
@pytest.mark.django_db
@pytest.mark.integration
class TestDiscountCodeAdminTests(PaymentTestMixin):
class DiscountCodeAdminTests(TestCase, PaymentTestMixin): def setup_method(self):
def setUp(self):
self.admin = DiscountCodeAdmin(DiscountCode, AdminSite()) self.admin = DiscountCodeAdmin(DiscountCode, AdminSite())
def test_deactivate_codes_action(self): def test_deactivate_codes_action(self):
@@ -191,4 +145,4 @@ class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None)) request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None))
self.admin.deactivate_codes(request, queryset) self.admin.deactivate_codes(request, queryset)
code.refresh_from_db() code.refresh_from_db()
self.assertFalse(code.is_active) assert not code.is_active

View File

@@ -1,282 +1,137 @@
import pytest
import json import json
from datetime import timedelta from datetime import timedelta
from unittest import mock
from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from core.authentication import create_jwt_token from core.authentication import create_jwt_token
from apps.events.models import Event, Registration from apps.events.models import Event, Registration
from apps.payments.models import Payment, DiscountCode from apps.payments.models import Payment, DiscountCode
from apps.users.models import User from apps.users.models import User
@pytest.mark.django_db
@override_settings( @pytest.mark.integration
ZARINPAL_MERCHANT_ID="MID", class TestPaymentsAPIIntegrationTests:
ZARINPAL_REQUEST_URL="https://zarinpal/request", password = 'PaymentPass!123'
ZARINPAL_STARTPAY="https://zarinpal/start/",
ZARINPAL_VERIFY_URL="https://zarinpal/verify",
ZARINPAL_CALLBACK_URL="https://frontend/callback",
)
class PaymentsAPIIntegrationTests(TestCase):
password = "PaymentPass!123"
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.user = User.objects.create_user( cls.user = User.objects.create_user(username='pay_user', email='pay.user@example.com', mobile='09123456789', password=cls.password)
username="pay_user",
email="pay.user@example.com",
password=cls.password,
)
cls.user.is_email_verified = True cls.user.is_email_verified = True
cls.user.save(update_fields=["is_email_verified"]) cls.user.save(update_fields=['is_email_verified'])
def setUp(self): def setup_method(self):
super().setUp() type(self).setUpTestData()
self.event = Event.objects.create( self.event = Event.objects.create(title='Pay Event', description='Payment event', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=2), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), slug='pay-event', price=50000, capacity=10, status=Event.StatusChoices.PUBLISHED)
title="Pay Event",
description="Payment event",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=2),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
slug="pay-event",
price=50000,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
self.token = create_jwt_token(self.user) self.token = create_jwt_token(self.user)
def _headers(self): def _headers(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"} return {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
def _create_paid_event(self): def _create_paid_event(self):
return Event.objects.create( return Event.objects.create(title='Paid Event', description='Paid', 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=2), slug=f'paid-{timezone.now().timestamp()}', price=20000, capacity=5, status=Event.StatusChoices.PUBLISHED)
title="Paid Event",
description="Paid",
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=2),
slug=f"paid-{timezone.now().timestamp()}",
price=20000,
capacity=5,
status=Event.StatusChoices.PUBLISHED,
)
def _create_discount_code(self, event): def _create_discount_code(self, event):
code = DiscountCode.objects.create( code = DiscountCode.objects.create(code='DISC50', value=50, is_active=True)
code="DISC50",
value=50,
is_active=True,
)
code.applicable_events.add(event) code.applicable_events.add(event)
return code return code
def test_create_payment_for_free_event(self): def test_create_payment_for_free_event(self):
free = Event.objects.create( free = Event.objects.create(title='Free', description='Zero', 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), slug='free-event', price=0, capacity=10, status=Event.StatusChoices.PUBLISHED)
title="Free", response = self.client.post('/api/payments/create', data=json.dumps({'event_id': free.id, 'description': 'Free registration'}), content_type='application/json', **self._headers())
description="Zero", assert response.status_code == 200
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),
slug="free-event",
price=0,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": free.id,
"description": "Free registration",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertEqual(data["amount"], 0) assert data['amount'] == 0
self.assertIsNone(data["start_pay_url"]) assert data['start_pay_url'] is None
@mock.patch("apps.payments.api.views.requests.post") def test_create_payment_with_discount(self, mocker):
def test_create_payment_with_discount(self, mock_post): mock_post = mocker.patch('apps.payments.api.views.requests.post')
mock_response = mock.Mock() mock_response = mocker.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}} mock_response.json.return_value = {'data': {'code': 100, 'authority': 'AUTH'}}
mock_post.return_value = mock_response mock_post.return_value = mock_response
code = self._create_discount_code(self.event) code = self._create_discount_code(self.event)
response = self.client.post( response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Pay with discount', 'discount_code': code.code}), content_type='application/json', **self._headers())
"/api/payments/create", assert response.status_code == 200
data=json.dumps(
{
"event_id": self.event.id,
"description": "Pay with discount",
"discount_code": code.code,
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["discount_amount"], self.event.price // 2) assert payload['discount_amount'] == self.event.price // 2
self.assertEqual(payload["amount"], self.event.price // 2) assert payload['amount'] == self.event.price // 2
self.assertIn("start_pay_url", payload) assert 'start_pay_url' in payload
payment = Payment.objects.get(user=self.user, event=self.event) payment = Payment.objects.get(user=self.user, event=self.event)
self.assertEqual(payment.discount_code, code) assert payment.discount_code == code
@mock.patch("apps.payments.api.views.requests.post") def test_callback_success_marks_paid(self, mocker):
def test_callback_success_marks_paid(self, mock_post): mock_post = mocker.patch('apps.payments.api.views.requests.post')
payment = Payment.objects.create( payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH123')
user=self.user, mock_resp = mocker.Mock()
event=self.event, mock_resp.json.return_value = {'data': {'code': 100, 'ref_id': 'REF', 'card_pan': '123', 'card_hash': 'ABC'}}
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH123",
)
mock_resp = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}}
mock_post.return_value = mock_resp mock_post.return_value = mock_resp
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'})
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH123", "Status": "OK"},
)
payment.refresh_from_db() payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID) assert payment.status == Payment.OrderStatusChoices.PAID
self.assertTrue("status=success" in response.url) assert 'status=success' in response.url
@mock.patch("apps.payments.api.views.requests.post") def test_callback_failure_redirects_failed(self, mocker):
def test_callback_failure_redirects_failed(self, mock_post): mock_post = mocker.patch('apps.payments.api.views.requests.post')
payment = Payment.objects.create( payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH456')
user=self.user, mock_resp = mocker.Mock()
event=self.event, mock_resp.json.return_value = {'data': {'code': 101, 'ref_id': 'REF'}}
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH456",
)
mock_resp = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}}
mock_post.return_value = mock_resp mock_post.return_value = mock_resp
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'})
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH456", "Status": "OK"},
)
payment.refresh_from_db() payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID) assert payment.status == Payment.OrderStatusChoices.PAID
self.assertTrue("status=success" in response.url) assert 'status=success' in response.url
def test_callback_missing_authority_returns_error(self): def test_callback_missing_authority_returns_error(self):
response = self.client.get("/api/payments/callback", {"Status": "OK"}) response = self.client.get('/api/payments/callback', {'Status': 'OK'})
self.assertEqual(response.status_code, 400) assert response.status_code == 400
def test_callback_not_ok_cancels(self): def test_callback_not_ok_cancels(self):
payment = Payment.objects.create( payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789')
user=self.user, response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH789",
)
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH789", "Status": "NOK"},
)
payment.refresh_from_db() payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED) assert payment.status == Payment.OrderStatusChoices.CANCELED
self.assertIn("status=failed", response.url) assert 'status=failed' in response.url
@mock.patch("apps.payments.api.views.requests.post", side_effect=RuntimeError("down")) def test_create_payment_gateway_failure(self, mocker):
def test_create_payment_gateway_failure(self, mock_post): mock_post = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
response = self.client.post( response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Gateway fail'}), content_type='application/json', **self._headers())
"/api/payments/create", assert response.status_code == 502
data=json.dumps( assert not Payment.objects.filter(user=self.user).exists()
{
"event_id": self.event.id,
"description": "Gateway fail",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 502)
self.assertFalse(Payment.objects.filter(user=self.user).exists())
def test_create_payment_when_already_paid(self): def test_create_payment_when_already_paid(self):
Payment.objects.create( Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)
user=self.user, response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Duplicate'}), content_type='application/json', **self._headers())
event=self.event, assert response.status_code == 400
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
)
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Duplicate"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 400)
@mock.patch("apps.payments.api.views.requests.post") def test_registration_final_price_none_updates(self, mocker):
def test_registration_final_price_none_updates(self, mock_post): mock_post = mocker.patch('apps.payments.api.views.requests.post')
registration = Registration.objects.create( registration = Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.PENDING, final_price=None)
event=self.event, mock_response = mocker.Mock()
user=self.user, mock_response.json.return_value = {'data': {'code': 100, 'authority': 'AUTH'}}
status=Registration.StatusChoices.PENDING,
final_price=None,
)
mock_response = mock.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
mock_post.return_value = mock_response mock_post.return_value = mock_response
response = self.client.post( response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Update'}), content_type='application/json', **self._headers())
"/api/payments/create", assert response.status_code == 200
data=json.dumps({"event_id": self.event.id, "description": "Update"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
registration.refresh_from_db() registration.refresh_from_db()
if registration.final_price is None: if registration.final_price is None:
self.fail("final_price should be populated") pytest.fail('final_price should be populated')
def test_coupon_check_success_and_errors(self): def test_coupon_check_success_and_errors(self):
code = DiscountCode.objects.create(code="PAYCO", value=20, is_active=True, type=DiscountCode.Type.PERCENT) code = DiscountCode.objects.create(code='PAYCO', value=20, is_active=True, type=DiscountCode.Type.PERCENT)
code.applicable_events.add(self.event) code.applicable_events.add(self.event)
missing = self.client.post('/api/payments/coupon/check', data=json.dumps({'event_id': self.event.id}), content_type='application/json', **self._headers())
assert missing.status_code == 422
invalid = self.client.post('/api/payments/coupon/check', data=json.dumps({'event_id': self.event.id, 'code': 'INVALID'}), content_type='application/json', **self._headers())
assert invalid.status_code == 404
success = self.client.post('/api/payments/coupon/check', data=json.dumps({'event_id': self.event.id, 'code': code.code}), content_type='application/json', **self._headers())
assert success.status_code == 200
assert 'final_price' in success.json()
# missing code @pytest.fixture(autouse=True)
missing = self.client.post( def _pytest_client(self, client):
"/api/payments/coupon/check", self.client = client
data=json.dumps({"event_id": self.event.id}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(missing.status_code, 422)
# invalid code @pytest.fixture(autouse=True)
invalid = self.client.post( def _pytest_settings(self, settings):
"/api/payments/coupon/check", settings.ZARINPAL_MERCHANT_ID = 'MID'
data=json.dumps({"event_id": self.event.id, "code": "INVALID"}), settings.ZARINPAL_REQUEST_URL = 'https://zarinpal/request'
content_type="application/json", settings.ZARINPAL_STARTPAY = 'https://zarinpal/start/'
**self._headers(), settings.ZARINPAL_VERIFY_URL = 'https://zarinpal/verify'
) settings.ZARINPAL_CALLBACK_URL = 'https://frontend/callback'
self.assertEqual(invalid.status_code, 404)
success = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id, "code": code.code}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(success.status_code, 200)
self.assertIn("final_price", success.json())

View File

@@ -0,0 +1,21 @@
import pytest
from pydantic import ValidationError
from apps.payments.api.schemas import CreatePaymentIn, DiscountCodeWriteSchema
pytestmark = pytest.mark.unit
def test_payment_request_requires_event_and_description():
with pytest.raises(ValidationError):
CreatePaymentIn(event_id=1)
payload = CreatePaymentIn(event_id=1, description="Workshop")
assert payload.discount_code is None
def test_discount_write_defaults_to_active_percentage_code():
payload = DiscountCodeWriteSchema(code="SAVE", value=10)
assert payload.type == "percent"
assert payload.is_active
assert payload.applicable_event_ids == []

View File

@@ -0,0 +1,171 @@
import json
from io import BytesIO
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
from apps.users.models import Major, University, User
from apps.users.services import auth
from core.authentication import create_jwt_token
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def _post(client, path, payload, headers=None):
return client.post(path, data=json.dumps(payload), content_type="application/json", **(headers or {}))
def test_otp_registration_login_refresh_and_reset(client, fake_redis, monkeypatch, mocker):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
sms = mocker.patch("apps.users.services.auth.send_critical_sms.delay")
mobile = "09197770001"
assert _post(client, "/api/auth/otp/send", {"mobile": "bad", "mode": "register"}).status_code == 400
sent = _post(client, "/api/auth/otp/send", {"mobile": mobile, "mode": "register"})
assert sent.status_code == 200, sent.content
code = fake_redis.get(auth._otp_key("register", mobile)).decode()
sms.assert_called_with(mobile, auth.SMS_KIND_REGISTER, code)
assert _post(client, "/api/auth/otp/verify-register", {"mobile": mobile, "code": "00000"}).status_code == 400
assert _post(client, "/api/auth/otp/verify-register", {"mobile": mobile, "code": code}).status_code == 200
assert fake_redis.get(auth._otp_key("register", mobile)) is None
registration = {"username": "otp_member", "mobile": mobile, "password": "StrongPass123!", "code": code}
assert _post(client, "/api/auth/register", registration).status_code == 201
member = User.objects.get(username="otp_member")
assert member.is_mobile_verified
assert _post(client, "/api/auth/register", registration).status_code == 400
assert _post(client, "/api/auth/login", {"identifier": mobile, "password": "wrong"}).status_code == 401
tokens = _post(client, "/api/auth/login", {"identifier": mobile, "password": "StrongPass123!"}).json()
assert tokens["access_token"] and tokens["refresh_token"]
assert _post(client, "/api/auth/refresh", {"refresh_token": tokens["access_token"]}).status_code == 401
assert _post(client, "/api/auth/refresh", {"refresh_token": tokens["refresh_token"]}).status_code == 200
assert _post(client, "/api/auth/otp/send", {"mobile": mobile, "mode": "reset_password"}).status_code == 200
reset_code = fake_redis.get(auth._otp_key("reset_password", mobile)).decode()
reset = _post(client, "/api/auth/reset-password", {"mobile": mobile, "code": reset_code, "new_password": "NewStrongPass123!"})
assert reset.status_code == 200, reset.content
member.refresh_from_db()
assert member.check_password("NewStrongPass123!")
def test_profile_and_authorization_roles(client, user_factory, auth_headers):
admin = user_factory(is_superuser=True, is_staff=True)
member = user_factory(email="member@example.com")
headers = auth_headers(member)
assert client.get("/api/auth/profile", **headers).json()["username"] == member.username
invalid = client.put("/api/auth/profile", data=json.dumps({"major": "missing-code"}),
content_type="application/json", **headers)
assert invalid.status_code == 400
assert client.put("/api/auth/profile", data=json.dumps({"first_name": "New", "email": " NEW@EXAMPLE.COM "}),
content_type="application/json", **headers).status_code == 200
member.refresh_from_db()
assert member.first_name == "New" and member.email == "new@example.com"
assert client.get("/api/auth/users", **headers).status_code == 403
admin_headers = auth_headers(admin)
assert client.get("/api/auth/users", {"search": member.username}, **admin_headers).json()[0]["id"] == member.id
assert client.get(f"/api/auth/users/{member.pk}", **admin_headers).json()["id"] == member.pk
assert client.get("/api/auth/roles", **headers).status_code == 403
assert client.get("/api/auth/roles", **admin_headers).status_code == 200
assert client.put(f"/api/auth/users/{admin.pk}/authorization", data=json.dumps({"groups": [], "is_staff": False}),
content_type="application/json", **admin_headers).status_code == 400
changed = client.put(f"/api/auth/users/{member.pk}/authorization",
data=json.dumps({"groups": ["blog_editor"], "is_staff": True}),
content_type="application/json", **admin_headers)
assert changed.status_code == 200, changed.content
member.refresh_from_db()
assert member.is_staff and member.groups.filter(name="blog_editor").exists()
assert client.get("/api/auth/check-username", {"username": member.username}).json()["exists"]
assert client.get("/api/auth/check-mobile", {"mobile": member.mobile}).json()["exists"]
def test_profile_picture_upload_validation_and_delete(client, user_factory, auth_headers, settings, tmp_path):
settings.MEDIA_ROOT = tmp_path
user = user_factory()
headers = auth_headers(user)
path = "/api/auth/profile/picture"
assert client.post(path, **headers).status_code == 400
invalid = client.post(path, {"file": SimpleUploadedFile("bad.txt", b"text", content_type="text/plain")}, **headers)
assert invalid.status_code == 400
image_bytes = BytesIO()
Image.new("RGB", (20, 20), "blue").save(image_bytes, format="PNG")
uploaded = client.post(path, {"file": SimpleUploadedFile("avatar.png", image_bytes.getvalue(), content_type="image/png")}, **headers)
assert uploaded.status_code == 200, uploaded.content
user.refresh_from_db()
assert user.profile_picture
assert client.delete(path, **headers).status_code == 200
user.refresh_from_db()
assert not user.profile_picture
def test_user_admin_filters_soft_delete_and_restore(client, user_factory, auth_headers):
admin = user_factory(is_superuser=True, is_staff=True)
staff = user_factory(is_staff=True)
member = user_factory(username="filter_member", student_id="1234567890", is_active=False)
headers = auth_headers(admin)
path = "/api/auth/users"
assert [row["id"] for row in client.get(path, {"search": "filter_member"}, **headers).json()] == [member.pk]
assert [row["id"] for row in client.get(path, {"student_id": "1234567890"}, **headers).json()] == [member.pk]
assert all(row["is_staff"] for row in client.get(path, {"role": "staff"}, **headers).json())
assert all(row["is_superuser"] for row in client.get(path, {"role": "superuser"}, **headers).json())
assert [row["id"] for row in client.get(path, {"is_active": "false"}, **headers).json()] == [member.pk]
assert client.get(path + "/deleted", **auth_headers(staff)).status_code == 200
member.delete()
assert client.get(path + "/deleted", **auth_headers(staff)).json()[0]["id"] == member.pk
restored = client.post(f"{path}/{member.pk}/restore", **auth_headers(staff))
assert restored.status_code == 200, restored.content
assert User.objects.filter(pk=member.pk).exists()
assert client.post(f"{path}/{member.pk}/restore", **auth_headers(staff)).status_code == 400
def test_mobile_otp_login_and_verification_api(client, user_factory, auth_headers, fake_redis, monkeypatch, mocker):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
mocker.patch("apps.users.services.auth.send_critical_sms.delay")
user = user_factory(is_mobile_verified=False)
assert _post(client, "/api/auth/login/otp", {"mobile": user.mobile, "code": "12345"}).status_code == 400
assert _post(client, "/api/auth/otp/send", {"mobile": user.mobile, "mode": "login"}).status_code == 200
code = fake_redis.get(auth._otp_key("login", user.mobile)).decode()
assert _post(client, "/api/auth/login/otp", {"mobile": user.mobile, "code": code}).status_code == 200
user.refresh_from_db()
assert user.is_mobile_verified
new_mobile = "09197770078"
headers = auth_headers(user)
assert _post(client, "/api/auth/mobile/send-otp", {"mobile": new_mobile}, headers).status_code == 200
verify_code = fake_redis.get(auth._otp_key("verify_mobile", new_mobile)).decode()
assert _post(client, "/api/auth/mobile/verify", {"mobile": new_mobile, "code": verify_code}, headers).status_code == 200
user.refresh_from_db()
assert user.mobile == new_mobile
def test_profile_rejects_duplicate_email_and_resolves_education_codes(client, user_factory, auth_headers):
user = user_factory()
another = user_factory(email="taken@example.com")
major = Major.objects.create(code="TEST_PROFILE_MAJOR", name="Profile Major")
university = University.objects.create(code="TEST_PROFILE_UNI", name="Profile University")
headers = auth_headers(user)
path = "/api/auth/profile"
duplicate = client.put(path, data=json.dumps({"email": another.email}),
content_type="application/json", **headers)
assert duplicate.status_code == 400
user.refresh_from_db()
assert user.email != another.email
changed = client.put(path, data=json.dumps({"major": major.code, "university": university.code}),
content_type="application/json", **headers)
assert changed.status_code == 200, changed.content
user.refresh_from_db()
assert user.major == major and user.university == university
assert client.put(path, data=json.dumps({"major": None, "university": None}),
content_type="application/json", **headers).status_code == 200
user.refresh_from_db()
assert user.major is None and user.university is None
def test_authorization_rejects_unknown_group_and_refresh_bad_token(client, user_factory, auth_headers):
admin = user_factory(is_superuser=True, is_staff=True)
member = user_factory()
headers = auth_headers(admin)
path = f"/api/auth/users/{member.pk}/authorization"
invalid = client.put(path, data=json.dumps({"groups": ["unknown-role"], "is_staff": True}),
content_type="application/json", **headers)
assert invalid.status_code == 400
member.refresh_from_db()
assert not member.is_staff and not member.groups.exists()
assert _post(client, "/api/auth/refresh", {"refresh_token": "malformed"}).status_code == 401

View File

@@ -0,0 +1,20 @@
from io import StringIO
import pytest
from django.core.management import call_command
from apps.users.models import User
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_legacy_identity_audit_reports_placeholder_and_email_only_users(user_factory):
first = user_factory(email="old@noemail.local")
second = user_factory(email="person@example.com")
User.objects.filter(pk__in=[first.pk, second.pk]).update(mobile=None)
output = StringIO()
call_command("audit_legacy_email_identities", stdout=output)
report = output.getvalue()
assert "Placeholder emails: 1" in report
assert "Email-only users needing mobile bind: 2" in report

View File

@@ -0,0 +1,48 @@
import pytest
from apps.users.models import User, UserSocialAccount
from apps.users.services import auth
from apps.users.services import google_oauth as google
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_registration_consumes_verified_mobile_and_creates_account(fake_redis, monkeypatch):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
fake_redis.set(auth._verified_otp_key("register", "09190000001"), "1")
payload = auth.RegistrationPayload(
username="new_member", mobile="09190000001", password="StrongPass123!", code="12345",
email=" NEW@EXAMPLE.COM ",
)
user = auth.register_user(payload)
assert user.email == "new@example.com"
assert user.is_mobile_verified
assert user.check_password("StrongPass123!")
assert fake_redis.get(auth._verified_otp_key("register", "09190000001")) is None
def test_registration_rejects_duplicate_username(user_factory, fake_redis, monkeypatch):
user_factory(username="taken")
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
payload = auth.RegistrationPayload(username="taken", mobile="09190000002", password="StrongPass123!", code="12345")
with pytest.raises(auth.AuthServiceError) as error:
auth.register_user(payload)
assert error.value.field == "username"
assert User.objects.count() == 1
def test_google_claim_creates_account_and_link_without_live_gateway(mocker):
verify = mocker.patch("apps.users.services.google_oauth.verify_otp_code")
profile = google.GoogleProfile("google-1", "person@example.com", True, "Person", "Example", "")
flow = google.create_google_flow({
"status": "claim_required", "resolution": "new_account", "mobile": "09190000001",
"username": "google_member", "google_profile": google._profile_payload(profile),
})
result = google.verify_google_claim(flow, "12345")
assert result["status"] == "authenticated"
verify.assert_called_once()
user = User.objects.get(username="google_member")
assert user.is_mobile_verified
assert not user.has_usable_password()
assert UserSocialAccount.objects.filter(user=user, provider_user_id="google-1").exists()

View File

@@ -0,0 +1,89 @@
import pytest
from apps.users.models import Major, University, User
from apps.users.services import auth
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_otp_send_modes_and_login_otp(user_factory, fake_redis, monkeypatch, mocker):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
sms = mocker.patch("apps.users.services.auth.send_critical_sms.delay")
user = user_factory(is_mobile_verified=False)
with pytest.raises(auth.AuthServiceError):
auth.generate_and_send_otp("bad", "register")
with pytest.raises(auth.AuthServiceError):
auth.generate_and_send_otp(user.mobile, "register")
with pytest.raises(auth.AuthServiceError):
auth.generate_and_send_otp("09197770061", "login")
response = auth.generate_and_send_otp(user.mobile, "login")
assert response["expires_in_seconds"] == auth.OTP_EXPIRY_SECONDS
code = fake_redis.get(auth._otp_key("login", user.mobile)).decode()
sms.assert_called_with(user.mobile, auth.SMS_KIND_LOGIN, code)
tokens = auth.login_with_otp(user.mobile, code)
assert tokens["access_token"] and tokens["refresh_token"]
user.refresh_from_db()
assert user.is_mobile_verified
with pytest.raises(auth.AuthServiceError):
auth.login_with_otp(user.mobile, code)
def test_mobile_claim_conflict_and_verification(user_factory, fake_redis, monkeypatch, mocker):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
sms = mocker.patch("apps.users.services.auth.send_critical_sms.delay")
user = user_factory()
another = user_factory()
with pytest.raises(auth.AuthServiceError):
auth.send_authenticated_mobile_otp(user, another.mobile)
new_mobile = "09197770062"
auth.send_authenticated_mobile_otp(user, new_mobile)
code = fake_redis.get(auth._otp_key("verify_mobile", new_mobile)).decode()
sms.assert_called_with(new_mobile, auth.SMS_KIND_VERIFY_MOBILE, code)
assert auth.verify_authenticated_mobile_otp(user, new_mobile, code).mobile == new_mobile
user.refresh_from_db()
assert user.mobile == new_mobile and user.is_mobile_verified
assert auth.lookup_mobile_registration_state(new_mobile) == {"exists": True, "has_password": True}
assert auth.lookup_mobile_registration_state("09197770063") == {"exists": False, "has_password": False}
def test_registration_validates_profile_fields_and_duplicate_email(user_factory, fake_redis, monkeypatch):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
existing = user_factory(email="used@example.com")
payload = auth.RegistrationPayload(username="new_profile", mobile="09197770064", password="StrongPass123!", code="12345")
fake_redis.set(auth._verified_otp_key("register", payload.mobile), "1")
for changes, field in [({"student_id": "123"}, "student_id"),
({"email": existing.email}, "email"),
({"major": "invalid"}, "major"),
({"university": "invalid"}, "university")]:
fake_redis.set(auth._verified_otp_key("register", payload.mobile), "1")
current = auth.RegistrationPayload(**{**payload.__dict__, **changes})
with pytest.raises(auth.AuthServiceError) as error:
auth.register_user(current)
assert error.value.field == field
major = Major.objects.create(code="TEST_AUTH_MAJOR", name="Auth Major")
university = University.objects.create(code="TEST_AUTH_UNI", name="Auth University")
current = auth.RegistrationPayload(**{**payload.__dict__, "major": major.code, "university": university.code,
"student_id": "1234567890"})
fake_redis.set(auth._verified_otp_key("register", payload.mobile), "1")
created = auth.register_user(current)
assert created.major == major and created.university == university
assert created.student_id == "1234567890"
assert User.objects.filter(username=payload.username).count() == 1
def test_password_reset_rejects_reuse_and_inactive_login(user_factory, fake_redis, monkeypatch):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
user = user_factory(is_active=False)
with pytest.raises(auth.AuthServiceError) as error:
auth.login_with_password(user.mobile, "TestPass123!")
assert error.value.status_code == 401
user.is_active = True
user.save(update_fields=["is_active"])
with pytest.raises(auth.AuthServiceError):
auth.reset_password_with_otp("09197770065", "12345", "NewStrongPass123!")
fake_redis.set(auth._otp_key("reset_password", user.mobile), "12345")
with pytest.raises(auth.AuthServiceError) as error:
auth.reset_password_with_otp(user.mobile, "12345", "TestPass123!")
assert error.value.field == "password"
assert user.check_password("TestPass123!")

View File

@@ -0,0 +1,62 @@
import json
from urllib.parse import parse_qs, urlparse
import pytest
from apps.users.models import UserSocialAccount
from apps.users.services import google_oauth as google
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def _flow_from_redirect(response):
return parse_qs(urlparse(response.url).query)["flow"][0]
def test_google_oauth_start_callback_and_flow_lookup(client, settings, mocker):
settings.GOOGLE_OAUTH_CLIENT_ID = "client-id"
settings.GOOGLE_OAUTH_REDIRECT_URI = "https://backend.test/callback"
settings.GOOGLE_OAUTH_FRONTEND_CALLBACK_URL = "https://frontend.test/google"
start = client.get("/api/auth/oauth/google/start")
assert start.status_code == 302 and "accounts.google.com" in start.url
state = parse_qs(urlparse(start.url).query)["state"][0]
profile = google.GoogleProfile("google-555", "new@example.com", True, "New", "Person", "")
exchange = mocker.patch("apps.users.api.views.exchange_code_for_google_profile", return_value=profile)
callback = client.get("/api/auth/oauth/google/callback", {"state": state, "code": "auth-code"})
assert callback.status_code == 302
exchange.assert_called_once_with("auth-code")
flow = _flow_from_redirect(callback)
response = client.get("/api/auth/oauth/google/flow", {"flow": flow})
assert response.status_code == 200
assert response.json()["resolution"] == "new_account"
assert client.get("/api/auth/oauth/google/flow", {"flow": "unknown"}).status_code == 400
assert client.get("/api/auth/oauth/google/callback", {"state": state, "code": "replay"}).status_code == 302
cancelled = client.get("/api/auth/oauth/google/callback", {"error": "access_denied"})
assert google.get_google_flow(_flow_from_redirect(cancelled))["status"] == "error"
def test_google_api_complete_claim_and_linked_login(client, settings, mocker, user_factory):
settings.GOOGLE_OAUTH_FRONTEND_CALLBACK_URL = "https://frontend.test/google"
profile = google.GoogleProfile("google-556", "person@example.com", True, "Google", "Person", "")
flow = google.create_google_flow(google.build_pending_google_flow_payload(profile))
mocker.patch("apps.users.services.google_oauth.generate_and_send_otp")
mocker.patch("apps.users.services.google_oauth.verify_otp_code")
complete = client.post("/api/auth/oauth/google/complete", data=json.dumps({"flow": flow, "mobile": "09197770056", "username": "google_api"}),
content_type="application/json")
assert complete.status_code == 200, complete.content
assert complete.json()["status"] == "claim_required"
assert client.post("/api/auth/oauth/google/claim/send-otp", data=json.dumps({"flow": flow}),
content_type="application/json").status_code == 200
verified = client.post("/api/auth/oauth/google/claim/verify", data=json.dumps({"flow": flow, "code": "12345"}),
content_type="application/json")
assert verified.status_code == 200, verified.content
assert verified.json()["status"] == "authenticated"
social = UserSocialAccount.objects.get(provider_user_id="google-556")
assert social.user.username == "google_api"
mocker.patch("apps.users.api.views.consume_google_state")
mocker.patch("apps.users.api.views.exchange_code_for_google_profile", return_value=profile)
callback = client.get("/api/auth/oauth/google/callback", {"state": "fresh", "code": "code"})
assert callback.status_code == 302
linked = google.get_google_flow(_flow_from_redirect(callback))
assert linked["status"] == "authenticated" and linked["access_token"]

View File

@@ -0,0 +1,98 @@
import pytest
from apps.users.models import User, UserSocialAccount
from apps.users.services import google_oauth as google
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
@pytest.fixture
def google_profile():
return google.GoogleProfile("provider-123", "google-person@example.com", True, "Google", "Person", "")
def _start(profile, **overrides):
payload = google.build_pending_google_flow_payload(profile)
payload.update(overrides)
return google.create_google_flow(payload)
def test_new_google_account_requires_claim_and_links_after_otp(google_profile, mocker):
send = mocker.patch("apps.users.services.google_oauth.generate_and_send_otp")
verify = mocker.patch("apps.users.services.google_oauth.verify_otp_code")
flow = _start(google_profile)
pending = google.complete_google_signup(flow=flow, mobile="09197770011", username="google_new")
assert pending["status"] == "claim_required" and pending["resolution"] == "new_account"
assert not User.objects.filter(username="google_new").exists()
send.assert_called_once_with("09197770011", "google_claim")
assert google.send_google_claim_otp(flow)["message"]
assert send.call_count == 2
authenticated = google.verify_google_claim(flow, "12345")
assert authenticated["status"] == "authenticated" and authenticated["access_token"]
verify.assert_called_once_with(mobile="09197770011", code="12345", mode="google_claim")
user = User.objects.get(username="google_new")
assert user.is_mobile_verified and not user.has_usable_password()
assert UserSocialAccount.objects.filter(user=user, provider_user_id="provider-123").exists()
assert google.get_google_flow(flow)["status"] == "authenticated"
def test_existing_email_claim_links_matching_user(google_profile, user_factory, mocker):
existing = user_factory(email=google_profile.email, is_mobile_verified=False)
mocker.patch("apps.users.services.google_oauth.generate_and_send_otp")
mocker.patch("apps.users.services.google_oauth.verify_otp_code")
flow = _start(google_profile)
pending = google.complete_google_signup(flow=flow, mobile=existing.mobile)
assert pending["resolution"] == "existing_email_claim"
authenticated = google.verify_google_claim(flow, "12345")
assert authenticated["status"] == "authenticated"
existing.refresh_from_db()
assert existing.is_mobile_verified and existing.is_email_verified
assert UserSocialAccount.objects.get(provider_user_id=google_profile.provider_user_id).user_id == existing.pk
@pytest.mark.parametrize("values,expected_code", [
({"mobile": "bad", "username": "new"}, "google_flow_error"),
({"mobile": "09197770022"}, "google_missing_username"),
({"mobile": "09197770022", "username": "taken"}, "google_username_taken"),
({"mobile": "09197770022", "username": "new", "student_id": "123"}, "google_invalid_student_id"),
({"mobile": "09197770022", "username": "new", "major": "missing"}, "google_invalid_major"),
({"mobile": "09197770022", "username": "new", "university": "missing"}, "google_invalid_university"),
])
def test_google_signup_rejects_invalid_profile_data(google_profile, user_factory, mocker, values, expected_code):
if values.get("username") == "taken":
user_factory(username="taken")
send = mocker.patch("apps.users.services.google_oauth.generate_and_send_otp")
flow = _start(google_profile)
with pytest.raises(google.GoogleOAuthFlowError) as error:
google.complete_google_signup(flow=flow, **values)
assert error.value.code == expected_code
send.assert_not_called()
assert not UserSocialAccount.objects.exists()
def test_google_mobile_conflict_is_rejected(google_profile, user_factory, mocker):
conflict = user_factory(mobile="09197770033", email="other@example.com")
send = mocker.patch("apps.users.services.google_oauth.generate_and_send_otp")
flow = _start(google_profile)
with pytest.raises(google.GoogleOAuthFlowError) as error:
google.complete_google_signup(flow=flow, mobile=conflict.mobile, username="new_google")
assert error.value.code == "google_mobile_belongs_to_other_email"
send.assert_not_called()
def test_google_claim_rejects_missing_target_and_existing_email(google_profile, user_factory, mocker):
mocker.patch("apps.users.services.google_oauth.verify_otp_code")
missing = google.create_google_flow({"status": "claim_required", "resolution": "existing_email_claim",
"target_user_id": 999999, "mobile": "09197770044",
"google_profile": google._profile_payload(google_profile)})
with pytest.raises(google.GoogleOAuthFlowError) as error:
google.verify_google_claim(missing, "12345")
assert error.value.code == "google_email_claim_failed"
user_factory(email=google_profile.email)
new_flow = google.create_google_flow({"status": "claim_required", "resolution": "new_account",
"username": "fresh", "mobile": "09197770045",
"google_profile": google._profile_payload(google_profile)})
with pytest.raises(google.GoogleOAuthFlowError) as error:
google.verify_google_claim(new_flow, "12345")
assert error.value.code == "google_email_taken"

View File

@@ -0,0 +1,42 @@
import json
import pytest
from apps.users.models import Major, University
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
@pytest.mark.parametrize("name,model", [("majors", Major), ("universities", University)])
def test_meta_public_search_and_admin_lifecycle(client, user_factory, auth_headers, name, model):
staff = user_factory(is_staff=True)
admin = user_factory(is_staff=True, is_superuser=True)
member = user_factory()
path = f"/api/meta/{name}"
admin_path = f"/api/meta/admin/{name}"
assert client.get(path).status_code == 200
assert client.post(admin_path, data=json.dumps({"code": "TEST_META_CX", "name": "Unique Meta Choice"}),
content_type="application/json", **auth_headers(member)).status_code == 403
created = client.post(admin_path, data=json.dumps({"code": "TEST_META_CX", "name": "Unique Meta Choice"}),
content_type="application/json", **auth_headers(staff))
assert created.status_code == 201, created.content
item = model.objects.get(pk=created.json()["id"])
assert client.get(path, {"search": "Unique Meta Choice"}).json()["results"][0]["id"] == item.pk
assert client.get(path, {"search": "missing"}).json()["count"] == 0
assert any(row["id"] == item.pk for row in client.get(admin_path, {"search": "Unique Meta Choice"}, **auth_headers(staff)).json()["results"])
duplicate = client.post(admin_path, data=json.dumps({"code": "TEST_META_CX", "name": "Duplicate"}),
content_type="application/json", **auth_headers(staff))
assert duplicate.status_code == 400
updated = client.put(f"{admin_path}/{item.pk}", data=json.dumps({"code": "TEST_META_CX", "name": "Updated Unique Meta Choice", "is_active": False}),
content_type="application/json", **auth_headers(staff))
assert updated.status_code == 200, updated.content
item.refresh_from_db()
assert item.name == "Updated Unique Meta Choice" and not item.is_active
assert client.get(path, {"search": "TEST_META_CX"}).json()["results"] == []
assert client.get(admin_path, {"search": "TEST_META_CX"}, **auth_headers(staff)).json()["results"][0]["id"] == item.pk
assert client.delete(f"{admin_path}/{item.pk}", **auth_headers(staff)).status_code == 403
assert client.delete(f"{admin_path}/{item.pk}", **auth_headers(admin)).status_code == 200
assert model.deleted_objects.filter(pk=item.pk).exists()
assert client.post(f"{admin_path}/{item.pk}/restore", **auth_headers(admin)).status_code == 200
assert model.objects.filter(pk=item.pk).exists()

View File

@@ -0,0 +1,25 @@
import pytest
from apps.users.models import User
from apps.users.resources import UserResource
@pytest.mark.django_db
@pytest.mark.integration
class TestUserIdentityTests:
def test_email_is_optional_and_flags_follow_identity(self):
user = User.objects.create_user(username='mobile_only', mobile='09191110000', password='password')
assert user.email is None
assert not user.is_email_verified
user.email = ' PERSON@EXAMPLE.COM '
user.save(update_fields=['email'])
user.refresh_from_db()
assert user.email == 'person@example.com'
assert user.is_email_verified
user.email = None
user.save(update_fields=['email'])
user.refresh_from_db()
assert not user.is_email_verified
def test_display_name_prefers_mobile_when_email_absent(self):
user = User.objects.create_user(username='mobile_only', mobile='09191110001', password='password')
assert user.mobile in str(user)

View File

@@ -1,85 +1,72 @@
import pytest
import json import json
from unittest import mock
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from apps.users.models import User from apps.users.models import User
from core.authentication import create_jwt_token from core.authentication import create_jwt_token
@pytest.mark.django_db
@pytest.mark.integration
class TestUsersAPIIntegrationTests:
class UsersAPIIntegrationTests(TestCase): def setup_method(self):
def setUp(self): self.user = User.objects.create_user(username='mobile_user', mobile='09192000001', email='person@example.com', password='Sup3rSecure!123', is_mobile_verified=True)
self.user = User.objects.create_user(
username="mobile_user",
mobile="09192000001",
email="person@example.com",
password="Sup3rSecure!123",
is_mobile_verified=True,
)
def _auth(self): def _auth(self):
return {"HTTP_AUTHORIZATION": f"Bearer {create_jwt_token(self.user)}"} return {'HTTP_AUTHORIZATION': f'Bearer {create_jwt_token(self.user)}'}
def test_password_login_accepts_mobile_identity(self): def test_password_login_accepts_mobile_identity(self):
response = self.client.post("/api/auth/login", json.dumps({ response = self.client.post('/api/auth/login', json.dumps({'identifier': self.user.mobile, 'password': 'Sup3rSecure!123'}), content_type='application/json')
"identifier": self.user.mobile, assert response.status_code == 200
"password": "Sup3rSecure!123", assert 'access_token' in response.json()
}), content_type="application/json")
self.assertEqual(response.status_code, 200)
self.assertIn("access_token", response.json())
def test_profile_update_treats_supplied_email_as_identity(self): def test_profile_update_treats_supplied_email_as_identity(self):
response = self.client.put("/api/auth/profile", json.dumps({"email": "new@example.com"}), response = self.client.put('/api/auth/profile', json.dumps({'email': 'new@example.com'}), content_type='application/json', **self._auth())
content_type="application/json", **self._auth()) assert response.status_code == 200
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.email, "new@example.com") assert self.user.email == 'new@example.com'
self.assertTrue(self.user.is_email_verified) assert self.user.is_email_verified
def test_profile_update_clears_email_identity_flag(self): def test_profile_update_clears_email_identity_flag(self):
response = self.client.put("/api/auth/profile", json.dumps({"email": ""}), response = self.client.put('/api/auth/profile', json.dumps({'email': ''}), content_type='application/json', **self._auth())
content_type="application/json", **self._auth()) assert response.status_code == 200
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertIsNone(self.user.email) assert self.user.email is None
self.assertFalse(self.user.is_email_verified) assert not self.user.is_email_verified
def test_profile_requires_authentication_and_returns_account_identity(self): def test_profile_requires_authentication_and_returns_account_identity(self):
self.assertEqual(self.client.get("/api/auth/profile").status_code, 401) assert self.client.get('/api/auth/profile').status_code == 401
response = self.client.get("/api/auth/profile", **self._auth()) response = self.client.get('/api/auth/profile', **self._auth())
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertEqual(response.json()["mobile"], self.user.mobile) assert response.json()['mobile'] == self.user.mobile
def test_profile_rejects_duplicate_email(self): def test_profile_rejects_duplicate_email(self):
User.objects.create_user(username="second", mobile="09192000002", email="taken@example.com", password="password") User.objects.create_user(username='second', mobile='09192000002', email='taken@example.com', password='password')
response = self.client.put("/api/auth/profile", json.dumps({"email": "taken@example.com"}), response = self.client.put('/api/auth/profile', json.dumps({'email': 'taken@example.com'}), content_type='application/json', **self._auth())
content_type="application/json", **self._auth()) assert response.status_code == 400
self.assertEqual(response.status_code, 400)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.email, "person@example.com") assert self.user.email == 'person@example.com'
def test_profile_picture_rejects_non_image_upload(self): def test_profile_picture_rejects_non_image_upload(self):
image = SimpleUploadedFile("notes.txt", b"not an image", content_type="text/plain") image = SimpleUploadedFile('notes.txt', b'not an image', content_type='text/plain')
response = self.client.post("/api/auth/profile/picture", {"file": image}, **self._auth()) response = self.client.post('/api/auth/profile/picture', {'file': image}, **self._auth())
self.assertEqual(response.status_code, 400) assert response.status_code == 400
def test_check_username_reports_existing_user(self): def test_check_username_reports_existing_user(self):
response = self.client.get("/api/auth/check-username", {"username": self.user.username}) response = self.client.get('/api/auth/check-username', {'username': self.user.username})
self.assertEqual(response.status_code, 200) assert response.status_code == 200
self.assertTrue(response.json()["exists"]) assert response.json()['exists']
def test_legacy_email_auth_routes_are_gone(self): def test_legacy_email_auth_routes_are_gone(self):
self.assertEqual(self.client.get("/api/auth/verify-email/token").status_code, 404) assert self.client.get('/api/auth/verify-email/token').status_code == 404
self.assertEqual(self.client.post("/api/auth/resend-verification").status_code, 404) assert self.client.post('/api/auth/resend-verification').status_code == 404
self.assertEqual(self.client.post("/api/auth/request-password-reset").status_code, 404) assert self.client.post('/api/auth/request-password-reset').status_code == 404
@mock.patch("apps.users.api.views.reset_password_with_otp") def test_mobile_otp_password_reset_route_remains_available(self, mocker):
def test_mobile_otp_password_reset_route_remains_available(self, reset): reset = mocker.patch('apps.users.api.views.reset_password_with_otp')
response = self.client.post("/api/auth/reset-password", json.dumps({ response = self.client.post('/api/auth/reset-password', json.dumps({'mobile': self.user.mobile, 'code': '123456', 'new_password': 'AnotherSecurePass!123'}), content_type='application/json')
"mobile": self.user.mobile, assert response.status_code == 200
"code": "123456",
"new_password": "AnotherSecurePass!123",
}), content_type="application/json")
self.assertEqual(response.status_code, 200)
reset.assert_called_once() reset.assert_called_once()
@pytest.fixture(autouse=True)
def _pytest_client(self, client):
self.client = client

View File

@@ -0,0 +1,40 @@
import pytest
from apps.users.services import auth
pytestmark = pytest.mark.unit
def test_otp_normalizes_persian_digits_and_rejects_wrong_length():
assert auth._normalize_otp_code(" ۱۲۳۴۵ ") == "12345"
with pytest.raises(auth.AuthServiceError) as error:
auth._normalize_otp_code("1234")
assert error.value.field == "code"
def test_otp_is_single_use(fake_redis, monkeypatch):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
fake_redis.set(auth._otp_key("login", "09190000001"), "12345")
auth.verify_otp_code(mobile="09190000001", code="12345", mode="login")
assert fake_redis.get(auth._otp_key("login", "09190000001")) is None
with pytest.raises(auth.AuthServiceError):
auth.verify_otp_code(mobile="09190000001", code="12345", mode="login")
def test_otp_issue_stores_code_and_queues_sms(fake_redis, monkeypatch, mocker):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
send_sms = mocker.patch("apps.users.services.auth.send_critical_sms.delay")
result = auth._issue_otp("09190000001", "register")
stored = fake_redis.get(auth._otp_key("register", "09190000001"))
assert stored and len(stored) == 5
send_sms.assert_called_once_with("09190000001", auth.SMS_KIND_REGISTER, stored.decode())
assert result["expires_in_seconds"] == auth.OTP_EXPIRY_SECONDS
def test_verified_otp_is_consumed_once(fake_redis, monkeypatch):
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
key = auth._verified_otp_key("register", "09190000001")
fake_redis.set(key, "1")
assert auth._consume_verified_otp("09190000001", "register")
assert not auth._consume_verified_otp("09190000001", "register")

View File

@@ -0,0 +1,50 @@
import pytest
from apps.users.services import google_oauth as google
pytestmark = pytest.mark.unit
def test_google_state_and_flow_are_single_use_or_expire():
state = google.create_google_state()
google.consume_google_state(state)
with pytest.raises(google.GoogleOAuthFlowError):
google.consume_google_state(state)
flow = google.create_google_flow({"status": "collect_profile", "email": "person@example.com", "google_profile": {"secret": True}})
assert google.get_google_flow(flow) == {"status": "collect_profile", "email": "person@example.com"}
google.update_google_flow(flow, {"status": "authenticated", "access_token": "token"})
assert google.get_google_flow(flow)["access_token"] == "token"
with pytest.raises(google.GoogleOAuthFlowError):
google.get_google_flow("unknown")
def test_google_authorization_url_requires_configured_client(settings):
settings.GOOGLE_OAUTH_CLIENT_ID = "client-id"
settings.GOOGLE_OAUTH_REDIRECT_URI = "https://example.test/callback"
url = google.build_google_authorization_url()
assert "client_id=client-id" in url
assert "state=" in url
def test_google_profile_exchange_checks_verified_email(settings, mocker):
settings.GOOGLE_OAUTH_CLIENT_ID = "client-id"
settings.GOOGLE_OAUTH_CLIENT_SECRET = "secret"
settings.GOOGLE_OAUTH_REDIRECT_URI = "https://example.test/callback"
token = mocker.patch("apps.users.services.google_oauth.requests.post")
token.return_value.json.return_value = {"access_token": "access"}
profile = mocker.patch("apps.users.services.google_oauth.requests.get")
profile.return_value.json.return_value = {"sub": "google-1", "email": " PERSON@EXAMPLE.COM ", "email_verified": True}
result = google.exchange_code_for_google_profile("code")
assert result.provider_user_id == "google-1"
assert result.email == "person@example.com"
profile.return_value.json.return_value["email_verified"] = False
with pytest.raises(google.GoogleOAuthFlowError):
google.exchange_code_for_google_profile("code")
def test_google_avatar_extension_uses_safe_suffix():
profile = google.GoogleProfile("id", "person@example.com", True, "", "", "https://example.test/a.png?size=1")
assert google._avatar_file_extension(profile) == "png"
profile.avatar_url = "https://example.test/a.exe"
assert google._avatar_file_extension(profile) == "jpg"

View File

@@ -0,0 +1,42 @@
import pytest
from apps.users.email_identity import (
is_placeholder_email,
is_valid_mobile_number,
mask_mobile,
normalize_email_identity,
normalize_mobile_number,
)
pytestmark = pytest.mark.unit
@pytest.mark.parametrize(
("raw", "expected"),
[(None, None), (" ", None), (" Alice@EXAMPLE.COM ", "alice@example.com")],
)
def test_email_normalization(raw, expected):
assert normalize_email_identity(raw) == expected
@pytest.mark.parametrize(
("raw", "normalized", "valid"),
[
(None, None, False),
("+98 (0919) 000-0001", "9809190000001", False),
("0919-000-0001", "09190000001", True),
("123", "123", False),
],
)
def test_mobile_normalization_and_validation(raw, normalized, valid):
assert normalize_mobile_number(raw) == normalized
assert is_valid_mobile_number(raw) is valid
def test_masking_and_placeholder_identity():
assert mask_mobile(None) is None
assert mask_mobile("1234") == "1234"
assert mask_mobile("09190000001") == "09*****0001"
assert is_placeholder_email(" LEGACY@NOEMAIL.LOCAL ")
assert not is_placeholder_email("person@example.com")

View File

@@ -0,0 +1,10 @@
import pytest
from apps.users.models import User
from apps.users.resources import UserResource
@pytest.mark.unit
class TestUserResourceTests:
def test_email_identity_flag_remains_exportable(self):
resource = UserResource()
assert 'is_email_verified' in resource.fields

View File

@@ -0,0 +1,18 @@
from types import SimpleNamespace
import pytest
from apps.users.signals import ensure_username_on_registration
pytestmark = pytest.mark.unit
def test_missing_username_is_generated_only_for_new_account(mocker):
user = SimpleNamespace(username="", save=mocker.Mock())
ensure_username_on_registration(None, user, created=True)
assert len(user.username) == 10
user.save.assert_called_once_with(update_fields=["username"])
user.save.reset_mock()
ensure_username_on_registration(None, user, created=False)
user.save.assert_not_called()

View File

@@ -0,0 +1,27 @@
import pytest
from apps.users.tasks import send_critical_sms
pytestmark = pytest.mark.unit
def test_sms_skips_when_credentials_are_missing(settings, mocker):
settings.SMS_APIKEY = ""
settings.SMS_AUTH_OTP_TEMPLATE_ID = ""
post = mocker.patch("apps.users.tasks.requests.post")
result = send_critical_sms.run("09190000001", "auth_register_otp", "12345")
assert result["sent"] is False
post.assert_not_called()
def test_sms_uses_otp_template_and_message_template(settings, mocker):
settings.SMS_APIKEY = "test-key"
settings.SMS_AUTH_OTP_TEMPLATE_ID = "12"
settings.SMS_EVENT_CANCELLATION_TEMPLATE_ID = "13"
post = mocker.patch("apps.users.tasks.requests.post")
assert send_critical_sms.run("09190000001", "auth_register_otp", "12345")["sent"]
assert post.call_args.kwargs["json"]["parameters"] == [{"name": "OTP", "value": "12345"}]
assert send_critical_sms.run("09190000001", "event_cancellation", "Cancelled")["sent"]
assert post.call_args.kwargs["json"]["parameters"] == [{"name": "MESSAGE", "value": "Cancelled"}]
assert post.call_args.kwargs["json"]["templateId"] == 13

View File

@@ -1,30 +0,0 @@
from django.test import SimpleTestCase, TestCase
from apps.users.models import User
from apps.users.resources import UserResource
class UserIdentityTests(TestCase):
def test_email_is_optional_and_flags_follow_identity(self):
user = User.objects.create_user(username="mobile_only", mobile="09191110000", password="password")
self.assertIsNone(user.email)
self.assertFalse(user.is_email_verified)
user.email = " PERSON@EXAMPLE.COM "
user.save(update_fields=["email"])
user.refresh_from_db()
self.assertEqual(user.email, "person@example.com")
self.assertTrue(user.is_email_verified)
user.email = None
user.save(update_fields=["email"])
user.refresh_from_db()
self.assertFalse(user.is_email_verified)
def test_display_name_prefers_mobile_when_email_absent(self):
user = User.objects.create_user(username="mobile_only", mobile="09191110001", password="password")
self.assertIn(user.mobile, str(user))
class UserResourceTests(SimpleTestCase):
def test_email_identity_flag_remains_exportable(self):
resource = UserResource()
self.assertIn("is_email_verified", resource.fields)

View File

@@ -1,3 +1,5 @@
from tempfile import TemporaryDirectory
from .base import * from .base import *
# Lightweight defaults keep local/CI test runs isolated from production infra. # Lightweight defaults keep local/CI test runs isolated from production infra.
@@ -31,6 +33,9 @@ CACHES = {
CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True CELERY_TASK_EAGER_PROPAGATES = True
_test_static_root = TemporaryDirectory(prefix="backend-test-static-")
STATIC_ROOT = _test_static_root.name
# Tests should not enforce HTTPS-only cookies to simplify client simulations. # Tests should not enforce HTTPS-only cookies to simplify client simulations.
CSRF_COOKIE_SECURE = False CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False

View File

@@ -0,0 +1,9 @@
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_api_routes_are_wired(client):
assert client.get("/api/health").status_code == 200
assert client.get("/api/docs").status_code == 200

View File

@@ -0,0 +1,15 @@
from types import SimpleNamespace
import pytest
from config.services.unfold import environment_callback
pytestmark = pytest.mark.unit
def test_admin_environment_badge(settings):
settings.DEBUG = True
assert environment_callback(SimpleNamespace()) == ["Development", "warning"]
settings.DEBUG = False
assert environment_callback(SimpleNamespace()) == ["Production", "success"]

109
conftest.py Normal file
View File

@@ -0,0 +1,109 @@
"""Shared pytest fixtures and collection checks for the backend."""
import itertools
import fakeredis
import pytest
import requests
def pytest_collection_modifyitems(items):
for item in items:
parts = item.path.parts
expected = "unit" if "unit" in parts else "integration" if "integration" in parts else None
marked = [kind for kind in ("unit", "integration") if list(item.iter_markers(kind))]
if expected is None or marked != [expected]:
raise pytest.UsageError(
f"{item.nodeid}: place tests under unit/ or integration/ "
"and mark each test with exactly its directory's marker"
)
if expected == "unit" and list(item.iter_markers("django_db")):
raise pytest.UsageError(f"{item.nodeid}: unit tests cannot use the database")
@pytest.fixture(autouse=True)
def no_outbound_http(monkeypatch):
def refuse_request(*args, **kwargs):
raise AssertionError("Tests must fake outbound HTTP requests")
monkeypatch.setattr(requests.sessions.Session, "request", refuse_request)
@pytest.fixture(autouse=True)
def isolate_redis(monkeypatch):
from apps.notifications import services
from apps.users.services import auth
fake = fakeredis.FakeRedis(decode_responses=True)
otp_fake = fakeredis.FakeRedis(decode_responses=False)
monkeypatch.setattr(services, "redis_client", fake)
monkeypatch.setattr(auth, "get_redis_connection", lambda alias: otp_fake)
@pytest.fixture
def fake_redis():
return fakeredis.FakeRedis(decode_responses=False)
@pytest.fixture
def fake_notification_redis():
return fakeredis.FakeRedis(decode_responses=True)
@pytest.fixture
def user_factory(db):
from apps.users.models import User
sequence = itertools.count(1)
def create(**overrides):
index = next(sequence)
values = {
"username": f"test_user_{index}",
"mobile": f"0919000{index:04d}",
"password": "TestPass123!",
}
values.update(overrides)
return User.objects.create_user(**values)
return create
@pytest.fixture
def auth_headers():
from core.authentication import create_jwt_token
def headers(user):
return {"HTTP_AUTHORIZATION": f"Bearer {create_jwt_token(user)}"}
return headers
@pytest.fixture
def event_factory(db):
from datetime import timedelta
from django.utils import timezone
from apps.events.models import Event
sequence = itertools.count(1)
def create(**overrides):
now = timezone.now()
index = next(sequence)
values = {
"title": f"Test event {index}",
"slug": f"test-event-{index}",
"description": "Test event description",
"start_time": now + timedelta(days=2),
"end_time": now + timedelta(days=2, hours=2),
"registration_start_date": now - timedelta(days=1),
"registration_end_date": now + timedelta(days=1),
"capacity": 10,
"status": Event.StatusChoices.PUBLISHED,
}
values.update(overrides)
return Event.objects.create(**values)
return create

View File

@@ -0,0 +1,10 @@
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_health_endpoint_reports_database_connection(client):
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"

View File

@@ -0,0 +1,95 @@
import pytest
import io
from datetime import timedelta
from PIL import Image
from django.core.management import call_command
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import RequestFactory
from django.utils import timezone
from apps.blog.api.schemas import AuthorSchema, PostListSchema
from apps.blog.models import Post
from apps.events.api.schemas import EventGallerySchema, EventListSchema
from apps.events.models import Event
from apps.gallery.api.schemas import GallerySchema
from apps.gallery.models import Gallery
from apps.users.api.schemas import UserProfileSchema
from apps.users.models import User
from core.media import BLUR_VARIANT, PREVIEW_VARIANT, THUMBNAIL_VARIANT, delete_image_derivatives, derivative_name, process_public_image
@pytest.mark.django_db
@pytest.mark.integration
class TestMediaProcessingTests:
@pytest.fixture(autouse=True)
def _media_root(self, settings, tmp_path):
self.media_root = str(tmp_path)
settings.MEDIA_ROOT = self.media_root
settings.MEDIA_URL = '/media/'
def setup_method(self):
self.user = User.objects.create_user(username='media-user', email='media@example.com', mobile='09123456789', password='TestPass123!')
self.factory = RequestFactory()
def _upload(self, name: str, *, mode: str='RGB', fmt: str='JPEG') -> SimpleUploadedFile:
buffer = io.BytesIO()
Image.new(mode, (2400, 1800), color=(20, 80, 200)).save(buffer, format=fmt)
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type=f'image/{fmt.lower()}')
def test_process_public_image_generates_derivatives_for_jpeg(self):
gallery = Gallery.objects.create(title='JPEG image', uploaded_by=self.user, image=self._upload('photo.jpg', fmt='JPEG'))
result = process_public_image(gallery.image, 'gallery', force=True)
assert result.processed
assert gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT))
assert gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))
assert gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT))
def test_process_public_image_generates_derivatives_for_png(self):
gallery = Gallery.objects.create(title='PNG image', uploaded_by=self.user, image=self._upload('photo.png', mode='RGBA', fmt='PNG'))
result = process_public_image(gallery.image, 'gallery', force=True)
assert result.width == 2400
assert result.height == 1800
assert gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))
def test_delete_image_derivatives_removes_sidecars(self):
gallery = Gallery.objects.create(title='Delete derivatives', uploaded_by=self.user, image=self._upload('cleanup.jpg', fmt='JPEG'))
process_public_image(gallery.image, 'gallery', force=True)
delete_image_derivatives(gallery.image, 'gallery')
assert not gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT))
assert not gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))
assert not gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT))
def test_replacing_image_removes_previous_sidecars(self):
gallery = Gallery.objects.create(title='Replace derivatives', uploaded_by=self.user, image=self._upload('replace-first.jpg', fmt='JPEG'))
first_name = gallery.image.name
gallery.image = self._upload('replace-second.jpg', fmt='JPEG')
gallery.save()
assert not gallery.image.storage.exists(derivative_name(first_name, THUMBNAIL_VARIANT))
assert not gallery.image.storage.exists(derivative_name(first_name, PREVIEW_VARIANT))
assert not gallery.image.storage.exists(derivative_name(first_name, BLUR_VARIANT))
def test_backfill_command_is_idempotent(self):
gallery = Gallery(title='Backfill image', uploaded_by=self.user)
gallery._defer_image_processing = True
gallery.image = self._upload('backfill.jpg', fmt='JPEG')
gallery.save()
first = io.StringIO()
second = io.StringIO()
call_command('backfill_media_derivatives', stdout=first)
call_command('backfill_media_derivatives', stdout=second)
assert 'processed=1' in first.getvalue()
assert 'skipped=1' in second.getvalue()
def test_schema_resolvers_expose_derivative_urls(self):
self.user.profile_picture.save('profile.jpg', self._upload('profile.jpg', fmt='JPEG'))
gallery = Gallery.objects.create(title='Schema image', uploaded_by=self.user, image=self._upload('schema-gallery.jpg', fmt='JPEG'))
event = Event.objects.create(title='Schema event', description='Event description', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=2), featured_image=self._upload('schema-event.jpg', fmt='JPEG'))
post = Post.objects.create(title='Schema post', content='Post body', author=self.user, featured_image=self._upload('schema-post.jpg', fmt='JPEG'), status=Post.StatusChoices.PUBLISHED)
context = {'request': self.factory.get('/')}
assert derivative_name(event.featured_image.name, THUMBNAIL_VARIANT) in EventListSchema.resolve_absolute_featured_image_thumbnail_url(event, context)
assert derivative_name(event.featured_image.name, PREVIEW_VARIANT) in EventListSchema.resolve_absolute_featured_image_preview_url(event, context)
assert derivative_name(gallery.image.name, PREVIEW_VARIANT) in EventGallerySchema.resolve_absolute_image_preview_url(gallery, context)
assert derivative_name(gallery.image.name, BLUR_VARIANT) in GallerySchema.resolve_absolute_image_blur_url(gallery, context)
assert derivative_name(post.featured_image.name, THUMBNAIL_VARIANT) in PostListSchema.resolve_absolute_featured_image_thumbnail_url(post, context)
assert derivative_name(self.user.profile_picture.name, THUMBNAIL_VARIANT) in AuthorSchema.resolve_profile_picture_thumbnail_url(self.user, context)
assert derivative_name(self.user.profile_picture.name, PREVIEW_VARIANT) in UserProfileSchema.resolve_profile_picture_preview_url(self.user, context)

View File

@@ -0,0 +1,23 @@
import pytest
from apps.events.models import Event
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_soft_delete_hides_record_and_restore_reveals_it(event_factory):
event = event_factory()
event.delete()
assert not Event.objects.filter(pk=event.pk).exists()
assert Event.deleted_objects.filter(pk=event.pk).exists()
assert Event.all_objects.get_queryset().dead().filter(pk=event.pk).exists()
assert not Event.all_objects.get_queryset().alive().filter(pk=event.pk).exists()
event.restore()
assert Event.objects.filter(pk=event.pk).exists()
def test_hard_delete_removes_row(event_factory):
event = event_factory()
Event.all_objects.filter(pk=event.pk).hard_delete()
assert not Event.all_objects.filter(pk=event.pk).exists()

View File

@@ -0,0 +1,35 @@
from types import SimpleNamespace
import pytest
from django.contrib import admin
from apps.events.models import Event
from core.admin import BaseModelAdmin, SoftDeleteListFilter
pytestmark = pytest.mark.unit
def test_soft_delete_filter_selects_active_deleted_or_all(mocker):
filt = SoftDeleteListFilter(SimpleNamespace(GET={}), {}, Event, admin.site)
queryset = mocker.Mock()
queryset.model = Event
mocker.patch.object(filt, "value", return_value="0")
filt.queryset(None, queryset)
queryset.filter.assert_called_with(is_deleted=False)
filt.value.return_value = "1"
assert filt.queryset(None, queryset).model == Event
filt.value.return_value = None
assert filt.queryset(None, queryset) is queryset
def test_admin_delete_permission_and_restore_action(mocker):
model_admin = BaseModelAdmin(Event, admin.site)
assert not model_admin.has_delete_permission(SimpleNamespace(user=SimpleNamespace(is_superuser=False)))
assert model_admin.has_delete_permission(SimpleNamespace(user=SimpleNamespace(is_superuser=True)))
mocker.patch.object(model_admin, "message_user")
deleted = SimpleNamespace(is_deleted=True, restore=mocker.Mock())
active = SimpleNamespace(is_deleted=False, restore=mocker.Mock())
model_admin.restore_selected(SimpleNamespace(), [deleted, active])
deleted.restore.assert_called_once()
active.restore.assert_not_called()

View File

@@ -0,0 +1,24 @@
from datetime import datetime, timezone
import pytest
from core.choices import MajorChoices
from core.templatetags.jalali import fa_digits, jdate
pytestmark = pytest.mark.unit
def test_persian_digits_translate_numbers_without_changing_text():
assert fa_digits("Year 1404/09") == "Year ۱۴۰۴/۰۹"
assert fa_digits(12) == "۱۲"
def test_jalali_date_handles_missing_and_aware_values():
assert jdate(None) == ""
assert jdate(datetime(2025, 3, 21, tzinfo=timezone.utc), "%Y/%m/%d") == "1404/01/01"
def test_major_choice_public_values_are_stable():
assert MajorChoices.CE.value == "CE"
assert dict(MajorChoices.choices)["CE"]

View File

@@ -1,174 +0,0 @@
import io
import shutil
import tempfile
from datetime import timedelta
from PIL import Image
from django.core.management import call_command
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import RequestFactory
from django.test import TestCase, override_settings
from django.utils import timezone
from apps.blog.api.schemas import AuthorSchema, PostListSchema
from apps.blog.models import Post
from apps.events.api.schemas import EventGallerySchema, EventListSchema
from apps.events.models import Event
from apps.gallery.api.schemas import GallerySchema
from apps.gallery.models import Gallery
from apps.users.api.schemas import UserProfileSchema
from apps.users.models import User
from core.media import (
BLUR_VARIANT,
PREVIEW_VARIANT,
THUMBNAIL_VARIANT,
delete_image_derivatives,
derivative_name,
process_public_image,
)
class MediaProcessingTests(TestCase):
def setUp(self):
super().setUp()
self.media_root = tempfile.mkdtemp()
self.addCleanup(lambda: shutil.rmtree(self.media_root, ignore_errors=True))
self.override = override_settings(MEDIA_ROOT=self.media_root, MEDIA_URL="/media/")
self.override.enable()
self.addCleanup(self.override.disable)
self.user = User.objects.create_user(
username="media-user",
email="media@example.com",
password="TestPass123!",
)
self.factory = RequestFactory()
def _upload(self, name: str, *, mode: str = "RGB", fmt: str = "JPEG") -> SimpleUploadedFile:
buffer = io.BytesIO()
Image.new(mode, (2400, 1800), color=(20, 80, 200)).save(buffer, format=fmt)
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type=f"image/{fmt.lower()}")
def test_process_public_image_generates_derivatives_for_jpeg(self):
gallery = Gallery.objects.create(
title="JPEG image",
uploaded_by=self.user,
image=self._upload("photo.jpg", fmt="JPEG"),
)
result = process_public_image(gallery.image, "gallery", force=True)
self.assertTrue(result.processed)
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT)))
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT)))
def test_process_public_image_generates_derivatives_for_png(self):
gallery = Gallery.objects.create(
title="PNG image",
uploaded_by=self.user,
image=self._upload("photo.png", mode="RGBA", fmt="PNG"),
)
result = process_public_image(gallery.image, "gallery", force=True)
self.assertEqual(result.width, 2400)
self.assertEqual(result.height, 1800)
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
def test_delete_image_derivatives_removes_sidecars(self):
gallery = Gallery.objects.create(
title="Delete derivatives",
uploaded_by=self.user,
image=self._upload("cleanup.jpg", fmt="JPEG"),
)
process_public_image(gallery.image, "gallery", force=True)
delete_image_derivatives(gallery.image, "gallery")
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT)))
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT)))
def test_replacing_image_removes_previous_sidecars(self):
gallery = Gallery.objects.create(
title="Replace derivatives",
uploaded_by=self.user,
image=self._upload("replace-first.jpg", fmt="JPEG"),
)
first_name = gallery.image.name
gallery.image = self._upload("replace-second.jpg", fmt="JPEG")
gallery.save()
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, THUMBNAIL_VARIANT)))
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, PREVIEW_VARIANT)))
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, BLUR_VARIANT)))
def test_backfill_command_is_idempotent(self):
gallery = Gallery(title="Backfill image", uploaded_by=self.user)
gallery._defer_image_processing = True
gallery.image = self._upload("backfill.jpg", fmt="JPEG")
gallery.save()
first = io.StringIO()
second = io.StringIO()
call_command("backfill_media_derivatives", stdout=first)
call_command("backfill_media_derivatives", stdout=second)
self.assertIn("processed=1", first.getvalue())
self.assertIn("skipped=1", second.getvalue())
def test_schema_resolvers_expose_derivative_urls(self):
self.user.profile_picture.save("profile.jpg", self._upload("profile.jpg", fmt="JPEG"))
gallery = Gallery.objects.create(
title="Schema image",
uploaded_by=self.user,
image=self._upload("schema-gallery.jpg", fmt="JPEG"),
)
event = Event.objects.create(
title="Schema event",
description="Event description",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=2),
featured_image=self._upload("schema-event.jpg", fmt="JPEG"),
)
post = Post.objects.create(
title="Schema post",
content="Post body",
author=self.user,
featured_image=self._upload("schema-post.jpg", fmt="JPEG"),
status=Post.StatusChoices.PUBLISHED,
)
context = {"request": self.factory.get("/")}
self.assertIn(
derivative_name(event.featured_image.name, THUMBNAIL_VARIANT),
EventListSchema.resolve_absolute_featured_image_thumbnail_url(event, context),
)
self.assertIn(
derivative_name(event.featured_image.name, PREVIEW_VARIANT),
EventListSchema.resolve_absolute_featured_image_preview_url(event, context),
)
self.assertIn(
derivative_name(gallery.image.name, PREVIEW_VARIANT),
EventGallerySchema.resolve_absolute_image_preview_url(gallery, context),
)
self.assertIn(
derivative_name(gallery.image.name, BLUR_VARIANT),
GallerySchema.resolve_absolute_image_blur_url(gallery, context),
)
self.assertIn(
derivative_name(post.featured_image.name, THUMBNAIL_VARIANT),
PostListSchema.resolve_absolute_featured_image_thumbnail_url(post, context),
)
self.assertIn(
derivative_name(self.user.profile_picture.name, THUMBNAIL_VARIANT),
AuthorSchema.resolve_profile_picture_thumbnail_url(self.user, context),
)
self.assertIn(
derivative_name(self.user.profile_picture.name, PREVIEW_VARIANT),
UserProfileSchema.resolve_profile_picture_preview_url(self.user, context),
)

13
pytest.ini Normal file
View File

@@ -0,0 +1,13 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
python_files = test_*.py
testpaths = apps core config
markers =
unit: Isolated tests without database or HTTP requests.
integration: Database, HTTP, or management command tests.
addopts = --strict-markers --import-mode=importlib
filterwarnings =
# Django Ninja 1.4.3 still defines internal schemas with Pydantic class Config.
ignore:Support for class-based `config` is deprecated: pydantic.warnings.PydanticDeprecatedSince20
# Django 5.2 warns while Ninja builds schemas from model URLFields.
ignore:The default scheme will be changed from 'http' to 'https' in Django 6.0:django.utils.deprecation.RemovedInDjango60Warning

6
requirements-test.txt Normal file
View File

@@ -0,0 +1,6 @@
-r requirements.txt
pytest==9.1.1
pytest-django==4.14.0
pytest-mock==3.15.1
coverage==7.16.1
fakeredis==2.38.0

62
scripts/check_coverage.py Normal file
View File

@@ -0,0 +1,62 @@
"""Enforce branch-aware coverage for every eligible backend module."""
import argparse
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXCLUDED_NAMES = {"__init__.py", "apps.py"}
EXCLUDED_CONFIG = {
"config/asgi.py",
"config/wsgi.py",
"config/services/location.py",
"config/services/notifications.py",
"config/services/zarinpal.py",
}
def eligible_modules():
for source in ("apps", "core", "config"):
for path in (ROOT / source).rglob("*.py"):
relative = path.relative_to(ROOT).as_posix()
if path.name in EXCLUDED_NAMES or relative in EXCLUDED_CONFIG:
continue
if any(part in {"tests", "migrations"} for part in path.parts):
continue
if relative.startswith("config/settings/"):
continue
yield relative
def main():
parser = argparse.ArgumentParser()
parser.add_argument("coverage_json", type=Path)
parser.add_argument("--minimum", type=float, default=80.0)
args = parser.parse_args()
report = json.loads(args.coverage_json.read_text(encoding="utf-8"))
files = {name.replace("\\", "/"): data for name, data in report["files"].items()}
failures = []
for module in sorted(eligible_modules()):
data = files.get(module)
if data is None:
failures.append(f"{module}: absent from coverage report")
continue
summary = data["summary"]
total = summary["num_statements"] + summary["num_branches"]
if total == 0:
continue
covered = total - summary["missing_lines"] - summary["missing_branches"]
percent = covered * 100 / total
print(f"{percent:6.2f}% {module}")
if percent < args.minimum:
failures.append(f"{module}: {percent:.2f}% < {args.minimum:.2f}%")
if failures:
print("Coverage gate failed:")
for failure in failures:
print(f" {failure}")
raise SystemExit(1)
if __name__ == "__main__":
main()