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:
15
.coveragerc
15
.coveragerc
@@ -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
|
||||||
|
|||||||
19
.github/workflows/backend.yml
vendored
19
.github/workflows/backend.yml
vendored
@@ -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
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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`.
|
||||||
|
|||||||
40
apps/analytics/tests/integration/test_analytics.py
Normal file
40
apps/analytics/tests/integration/test_analytics.py
Normal 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
|
||||||
55
apps/analytics/tests/unit/test_helpers.py
Normal file
55
apps/analytics/tests/unit/test_helpers.py
Normal 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})())
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
43
apps/blog/tests/integration/test_commands.py
Normal file
43
apps/blog/tests/integration/test_commands.py
Normal 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
|
||||||
309
apps/blog/tests/integration/test_public_blog.py
Normal file
309
apps/blog/tests/integration/test_public_blog.py
Normal 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]
|
||||||
33
apps/blog/tests/unit/test_admin_actions.py
Normal file
33
apps/blog/tests/unit/test_admin_actions.py
Normal 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)
|
||||||
69
apps/blog/tests/unit/test_permissions.py
Normal file
69
apps/blog/tests/unit/test_permissions.py
Normal 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"})
|
||||||
15
apps/blog/tests/unit/test_seed_helpers.py
Normal file
15
apps/blog/tests/unit/test_seed_helpers.py
Normal 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("برچسب") == "برچسب"
|
||||||
@@ -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,
|
||||||
|
|||||||
151
apps/certificates/tests/integration/test_certificates.py
Normal file
151
apps/certificates/tests/integration/test_certificates.py
Normal 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)
|
||||||
20
apps/certificates/tests/unit/test_certificates.py
Normal file
20
apps/certificates/tests/unit/test_certificates.py
Normal 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"
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
29
apps/communications/tests/integration/test_announcements.py
Normal file
29
apps/communications/tests/integration/test_announcements.py
Normal 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
|
||||||
66
apps/communications/tests/integration/test_api.py
Normal file
66
apps/communications/tests/integration/test_api.py
Normal 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()
|
||||||
23
apps/communications/tests/integration/test_reminders.py
Normal file
23
apps/communications/tests/integration/test_reminders.py
Normal 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
|
||||||
@@ -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)
|
|
||||||
76
apps/communications/tests/unit/test_push.py
Normal file
76
apps/communications/tests/unit/test_push.py
Normal 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()
|
||||||
13
apps/communications/tests/unit/test_resources.py
Normal file
13
apps/communications/tests/unit/test_resources.py
Normal 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"
|
||||||
@@ -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',
|
||||||
|
|||||||
61
apps/events/tests/integration/test_event_changes.py
Normal file
61
apps/events/tests/integration/test_event_changes.py
Normal 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()
|
||||||
35
apps/events/tests/integration/test_event_models.py
Normal file
35
apps/events/tests/integration/test_event_models.py
Normal 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
|
||||||
@@ -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
|
|
||||||
|
|||||||
34
apps/events/tests/unit/test_admin_actions.py
Normal file
34
apps/events/tests/unit/test_admin_actions.py
Normal 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)
|
||||||
72
apps/events/tests/unit/test_event_notifications.py
Normal file
72
apps/events/tests/unit/test_event_notifications.py
Normal 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'])
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
49
apps/gallery/tests/integration/test_gallery.py
Normal file
49
apps/gallery/tests/integration/test_gallery.py
Normal 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()
|
||||||
28
apps/gallery/tests/integration/test_tasks.py
Normal file
28
apps/gallery/tests/integration/test_tasks.py
Normal 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)
|
||||||
41
apps/gallery/tests/unit/test_admin.py
Normal file
41
apps/gallery/tests/unit/test_admin.py
Normal 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
|
||||||
17
apps/gallery/tests/unit/test_schemas.py
Normal file
17
apps/gallery/tests/unit/test_schemas.py
Normal 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
|
||||||
51
apps/notifications/tests/integration/test_api_flows.py
Normal file
51
apps/notifications/tests/integration/test_api_flows.py
Normal 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()
|
||||||
@@ -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
|
||||||
38
apps/notifications/tests/integration/test_redis_store.py
Normal file
38
apps/notifications/tests/integration/test_redis_store.py
Normal 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
|
||||||
@@ -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)
|
|
||||||
64
apps/notifications/tests/unit/test_store.py
Normal file
64
apps/notifications/tests/unit/test_store.py
Normal 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()
|
||||||
28
apps/notifications/tests/unit/test_stream.py
Normal file
28
apps/notifications/tests/unit/test_stream.py
Normal 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()
|
||||||
22
apps/notifications/tests/unit/test_stream_token.py
Normal file
22
apps/notifications/tests/unit/test_stream_token.py
Normal 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})
|
||||||
87
apps/payments/tests/integration/test_payment_admin.py
Normal file
87
apps/payments/tests/integration/test_payment_admin.py
Normal 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()
|
||||||
@@ -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
|
||||||
@@ -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())
|
|
||||||
|
|||||||
21
apps/payments/tests/unit/test_schemas.py
Normal file
21
apps/payments/tests/unit/test_schemas.py
Normal 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 == []
|
||||||
171
apps/users/tests/integration/test_api_flows.py
Normal file
171
apps/users/tests/integration/test_api_flows.py
Normal 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
|
||||||
20
apps/users/tests/integration/test_audit_command.py
Normal file
20
apps/users/tests/integration/test_audit_command.py
Normal 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
|
||||||
48
apps/users/tests/integration/test_auth_flows.py
Normal file
48
apps/users/tests/integration/test_auth_flows.py
Normal 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()
|
||||||
89
apps/users/tests/integration/test_auth_service_edges.py
Normal file
89
apps/users/tests/integration/test_auth_service_edges.py
Normal 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!")
|
||||||
62
apps/users/tests/integration/test_google_api.py
Normal file
62
apps/users/tests/integration/test_google_api.py
Normal 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"]
|
||||||
98
apps/users/tests/integration/test_google_flows.py
Normal file
98
apps/users/tests/integration/test_google_flows.py
Normal 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"
|
||||||
42
apps/users/tests/integration/test_meta.py
Normal file
42
apps/users/tests/integration/test_meta.py
Normal 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()
|
||||||
25
apps/users/tests/integration/test_user_models.py
Normal file
25
apps/users/tests/integration/test_user_models.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
40
apps/users/tests/unit/test_auth_service.py
Normal file
40
apps/users/tests/unit/test_auth_service.py
Normal 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")
|
||||||
50
apps/users/tests/unit/test_google_oauth.py
Normal file
50
apps/users/tests/unit/test_google_oauth.py
Normal 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"
|
||||||
42
apps/users/tests/unit/test_identity.py
Normal file
42
apps/users/tests/unit/test_identity.py
Normal 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")
|
||||||
10
apps/users/tests/unit/test_resources.py
Normal file
10
apps/users/tests/unit/test_resources.py
Normal 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
|
||||||
18
apps/users/tests/unit/test_signals.py
Normal file
18
apps/users/tests/unit/test_signals.py
Normal 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()
|
||||||
27
apps/users/tests/unit/test_sms_tasks.py
Normal file
27
apps/users/tests/unit/test_sms_tasks.py
Normal 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
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
9
config/tests/integration/test_routing.py
Normal file
9
config/tests/integration/test_routing.py
Normal 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
|
||||||
15
config/tests/unit/test_environment.py
Normal file
15
config/tests/unit/test_environment.py
Normal 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
109
conftest.py
Normal 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
|
||||||
10
core/tests/integration/test_health.py
Normal file
10
core/tests/integration/test_health.py
Normal 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"
|
||||||
95
core/tests/integration/test_media.py
Normal file
95
core/tests/integration/test_media.py
Normal 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)
|
||||||
23
core/tests/integration/test_soft_delete.py
Normal file
23
core/tests/integration/test_soft_delete.py
Normal 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()
|
||||||
35
core/tests/unit/test_admin.py
Normal file
35
core/tests/unit/test_admin.py
Normal 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()
|
||||||
24
core/tests/unit/test_formatting.py
Normal file
24
core/tests/unit/test_formatting.py
Normal 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"]
|
||||||
@@ -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
13
pytest.ini
Normal 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
6
requirements-test.txt
Normal 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
62
scripts/check_coverage.py
Normal 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()
|
||||||
Reference in New Issue
Block a user