diff --git a/.coveragerc b/.coveragerc index 2cad10f..3ea6ca3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,23 +1,20 @@ [run] branch = True source = - users - api - utils - payments - communications - gallery - events - blog + apps + core config omit = */migrations/* */tests/* */__init__.py + */apps.py config/settings/* - config/urls.py config/wsgi.py config/asgi.py + config/services/location.py + config/services/notifications.py + config/services/zarinpal.py [report] skip_empty = True diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 06c27ea..3ddcc5c 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -27,15 +27,6 @@ jobs: --health-interval 10s --health-timeout 5s --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: SECRET_KEY: github-ci-secret-key DEBUG: "False" @@ -83,8 +74,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install coverage + pip install -r requirements-test.txt - name: Prepare environment file run: cp .env.test .env @@ -92,10 +82,13 @@ jobs: - name: Run migrations run: python manage.py migrate --noinput - - name: Run tests with coverage + - name: Run unit and integration tests with coverage 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 json -o coverage.json + python scripts/check_coverage.py coverage.json --minimum 80 deploy: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 034926f..248abd5 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,26 @@ python manage.py runserver ``` ## 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 - Domain routers live under `apps//api`. - Shared auth helpers live in `core/authentication.py`. diff --git a/apps/analytics/tests/integration/test_analytics.py b/apps/analytics/tests/integration/test_analytics.py new file mode 100644 index 0000000..a287db0 --- /dev/null +++ b/apps/analytics/tests/integration/test_analytics.py @@ -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 diff --git a/apps/analytics/tests/unit/test_helpers.py b/apps/analytics/tests/unit/test_helpers.py new file mode 100644 index 0000000..b03a807 --- /dev/null +++ b/apps/analytics/tests/unit/test_helpers.py @@ -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})()) diff --git a/apps/blog/api/schemas.py b/apps/blog/api/schemas.py index 1f9e629..a88146b 100644 --- a/apps/blog/api/schemas.py +++ b/apps/blog/api/schemas.py @@ -13,9 +13,9 @@ class CategorySchema(ModelSchema): created_at: Optional[datetime] = None parent_id: Optional[int] = None - class Config: + class Meta: model = Category - model_fields = ["id", "name", "slug", "description", "created_at"] + fields = ["id", "name", "slug", "description", "created_at"] @staticmethod def resolve_parent_id(obj): @@ -55,9 +55,9 @@ class CategoryFilterSchema(Schema): class TagSchema(ModelSchema): created_at: Optional[datetime] = None - class Config: + class Meta: model = Tag - model_fields = ["id", "name", "slug", "created_at"] + fields = ["id", "name", "slug", "created_at"] class AdminTagSchema(TagSchema): @@ -119,9 +119,9 @@ class PostAssetSchema(ModelSchema): markdown_link: Optional[str] = None uploaded_by: AuthorSchema - class Config: + class Meta: model = PostAsset - model_fields = [ + fields = [ "id", "file_type", "title", @@ -301,9 +301,9 @@ class CommentSchema(ModelSchema): deleted_at: Optional[datetime] = None hidden_replies_count: int = 0 - class Config: + class Meta: 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 def resolve_post_id(obj): @@ -359,9 +359,9 @@ class BlogProfileActivitySchema(Schema): class BlogBannerSchema(ModelSchema): image_url: str - class Config: + class Meta: model = BlogBanner - model_fields = ["id", "title", "alt_text", "url", "sort_order"] + fields = ["id", "title", "alt_text", "url", "sort_order"] @staticmethod def resolve_image_url(obj, context): diff --git a/apps/blog/management/commands/seed_blog_mock_data.py b/apps/blog/management/commands/seed_blog_mock_data.py index 67ca46a..af0534a 100644 --- a/apps/blog/management/commands/seed_blog_mock_data.py +++ b/apps/blog/management/commands/seed_blog_mock_data.py @@ -184,7 +184,7 @@ class Command(BaseCommand): def _seed_writers(self, editor_group: Group, password: str): writers = [] 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"], defaults={ "first_name": spec["first_name"], @@ -201,6 +201,8 @@ class Command(BaseCommand): user.bio = spec["bio"] user.is_active = True user.is_mobile_verified = True + user.is_deleted = False + user.deleted_at = None if created: user.set_password(password) set_image_field( @@ -218,19 +220,24 @@ class Command(BaseCommand): return writers def _seed_categories(self): - root, _ = Category.objects.get_or_create( + root, _ = Category.all_objects.get_or_create( slug="mock-بلاگ-انجمن", defaults={"name": "بلاگ انجمن", "description": "دسته اصلی محتوای تستی بلاگ"}, ) + root.is_deleted = False + root.deleted_at = None + root.save(update_fields=["is_deleted", "deleted_at"]) names = ["برنامه‌نویسی", "علوم کامپیوتر", "توسعه نرم‌افزار", "ابزارها", "هوش مصنوعی"] categories = {"بلاگ انجمن": root} for name in names: - category, _ = Category.objects.get_or_create( + category, _ = Category.all_objects.get_or_create( slug=f"mock-{name}", defaults={"name": name, "parent": root, "description": f"مطالب تستی درباره {name}"}, ) category.name = name category.parent = root + category.is_deleted = False + category.deleted_at = None category.save() categories[name] = category return categories @@ -238,8 +245,10 @@ class Command(BaseCommand): def _seed_tags(self): tags = {} 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.is_deleted = False + tag.deleted_at = None tag.save() tags[name] = tag return tags @@ -247,7 +256,7 @@ class Command(BaseCommand): def _seed_banners(self): colors = [(9, 80, 90), (120, 64, 24), (38, 70, 83)] 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}", defaults={ "url": f"https://east-guilan-ce.ir/blog?mock-banner={index}", @@ -260,6 +269,8 @@ class Command(BaseCommand): banner.alt_text = f"بنر تستی بلاگ {index}" banner.sort_order = index banner.is_active = True + banner.is_deleted = False + banner.deleted_at = None set_image_field( banner, "image", @@ -298,6 +309,8 @@ class Command(BaseCommand): post.content = make_markdown(spec["title"]) post.excerpt = f"خلاصه تستی برای نوشته «{spec['title']}» که برای بررسی کارت‌ها و سئوی بلاگ استفاده می‌شود." post.status = Post.StatusChoices.PUBLISHED + post.is_deleted = False + post.deleted_at = None post.category = categories[spec["category"]] post.is_featured = index <= 2 post.published_at = post.published_at or timezone.now() - timezone.timedelta(days=index * 3) diff --git a/apps/blog/tests/integration/test_commands.py b/apps/blog/tests/integration/test_commands.py new file mode 100644 index 0000000..9496b0b --- /dev/null +++ b/apps/blog/tests/integration/test_commands.py @@ -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 diff --git a/apps/blog/tests/integration/test_public_blog.py b/apps/blog/tests/integration/test_public_blog.py new file mode 100644 index 0000000..c142d09 --- /dev/null +++ b/apps/blog/tests/integration/test_public_blog.py @@ -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] diff --git a/apps/blog/tests/unit/test_admin_actions.py b/apps/blog/tests/unit/test_admin_actions.py new file mode 100644 index 0000000..18e7fec --- /dev/null +++ b/apps/blog/tests/unit/test_admin_actions.py @@ -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) diff --git a/apps/blog/tests/unit/test_permissions.py b/apps/blog/tests/unit/test_permissions.py new file mode 100644 index 0000000..31c118e --- /dev/null +++ b/apps/blog/tests/unit/test_permissions.py @@ -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"}) diff --git a/apps/blog/tests/unit/test_seed_helpers.py b/apps/blog/tests/unit/test_seed_helpers.py new file mode 100644 index 0000000..15a958b --- /dev/null +++ b/apps/blog/tests/unit/test_seed_helpers.py @@ -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("برچسب") == "برچسب" diff --git a/apps/certificates/api/views.py b/apps/certificates/api/views.py index bb9881d..aedeb29 100644 --- a/apps/certificates/api/views.py +++ b/apps/certificates/api/views.py @@ -93,7 +93,7 @@ def generate_certificates(request, event_id: int, payload: CertificateGeneration UserCertificateOut( id=certificate.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, title=certificate.title, certificate_id=str(certificate.certificate_id), @@ -125,7 +125,7 @@ def verify_certificate(request, certificate_code): certificate_id=str(certificate.certificate_id), certificate_code=certificate.code, 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_title=certificate.event.title, title=certificate.title, diff --git a/apps/certificates/tests/integration/test_certificates.py b/apps/certificates/tests/integration/test_certificates.py new file mode 100644 index 0000000..061f132 --- /dev/null +++ b/apps/certificates/tests/integration/test_certificates.py @@ -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) diff --git a/apps/certificates/tests/unit/test_certificates.py b/apps/certificates/tests/unit/test_certificates.py new file mode 100644 index 0000000..88e3502 --- /dev/null +++ b/apps/certificates/tests/unit/test_certificates.py @@ -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" diff --git a/apps/communications/api/schemas.py b/apps/communications/api/schemas.py index 48d4a76..f81ab56 100644 --- a/apps/communications/api/schemas.py +++ b/apps/communications/api/schemas.py @@ -13,9 +13,9 @@ class AnnouncementSchema(ModelSchema): author: AuthorSchema content_html: str - class Config: + class Meta: model = Announcement - model_fields = [ + fields = [ "id", "title", "content", @@ -64,9 +64,9 @@ class AnnouncementUpdateSchema(Schema): class PushDeviceSchema(ModelSchema): user: AuthorSchema - class Config: + class Meta: 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): diff --git a/apps/communications/api/views.py b/apps/communications/api/views.py index 56fb0c0..b43532b 100644 --- a/apps/communications/api/views.py +++ b/apps/communications/api/views.py @@ -17,6 +17,7 @@ from apps.communications.api.schemas import ( PushDeviceUpdateSchema, ) from apps.communications.models import Announcement, AnnouncementPriority, AnnouncementType, PushNotificationDevice +from core.api.schemas import ErrorSchema from core.authentication import jwt_auth communications_router = Router() @@ -31,7 +32,7 @@ def _is_staff_user(user) -> bool: def list_announcements(request, published_only: bool = True): queryset = Announcement.objects.select_related("author").filter(is_deleted=False) 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 = [] for announcement in queryset.order_by("-created_at"): @@ -51,22 +52,35 @@ def list_announcements(request, published_only: bool = True): 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): announcement = get_object_or_404( Announcement.objects.select_related("author").filter(is_deleted=False), id=announcement_id, ) 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 -@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): user = request.auth if not _is_staff_user(user): - return {"error": "Permission denied"}, 403 + return 403, {"error": "Permission denied"} announcement = Announcement.objects.create( author=user, @@ -81,11 +95,11 @@ def create_announcement(request, payload: AnnouncementCreateSchema): 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): user = request.auth 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.title = payload.title @@ -99,30 +113,16 @@ def update_announcement(request, announcement_id: int, payload: AnnouncementCrea 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): user = request.auth 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.soft_delete() + announcement.delete() 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) def register_push_device(request, payload: PushDeviceCreateSchema): user = request.auth @@ -138,7 +138,7 @@ def register_push_device(request, payload: PushDeviceCreateSchema): 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): user = request.auth try: @@ -146,7 +146,7 @@ def unregister_push_device(request, device_token: str): device.delete() return {"message": "Device unregistered successfully"} 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) diff --git a/apps/communications/tests/integration/test_announcements.py b/apps/communications/tests/integration/test_announcements.py new file mode 100644 index 0000000..314aa91 --- /dev/null +++ b/apps/communications/tests/integration/test_announcements.py @@ -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 diff --git a/apps/communications/tests/integration/test_api.py b/apps/communications/tests/integration/test_api.py new file mode 100644 index 0000000..77a1b20 --- /dev/null +++ b/apps/communications/tests/integration/test_api.py @@ -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() diff --git a/apps/communications/tests/integration/test_reminders.py b/apps/communications/tests/integration/test_reminders.py new file mode 100644 index 0000000..50aec95 --- /dev/null +++ b/apps/communications/tests/integration/test_reminders.py @@ -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 diff --git a/apps/communications/tests/test_announcements.py b/apps/communications/tests/test_announcements.py deleted file mode 100644 index 03d8808..0000000 --- a/apps/communications/tests/test_announcements.py +++ /dev/null @@ -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) diff --git a/apps/communications/tests/unit/test_push.py b/apps/communications/tests/unit/test_push.py new file mode 100644 index 0000000..74195e5 --- /dev/null +++ b/apps/communications/tests/unit/test_push.py @@ -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() diff --git a/apps/communications/tests/unit/test_resources.py b/apps/communications/tests/unit/test_resources.py new file mode 100644 index 0000000..67116ce --- /dev/null +++ b/apps/communications/tests/unit/test_resources.py @@ -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" diff --git a/apps/events/api/schemas.py b/apps/events/api/schemas.py index 3cd15b5..5a809be 100644 --- a/apps/events/api/schemas.py +++ b/apps/events/api/schemas.py @@ -22,9 +22,9 @@ class EventGallerySchema(ModelSchema): absolute_image_preview_url: Optional[str] = None absolute_image_blur_url: Optional[str] = None - class Config: + class Meta: model = Gallery - model_fields = ['id', 'title', 'description', 'image', 'alt_text', + fields = ['id', 'title', 'description', 'image', 'alt_text', 'width', 'height', 'is_public', 'created_at'] @staticmethod @@ -55,9 +55,9 @@ class EventSchema(ModelSchema): absolute_featured_image_thumbnail_url: Optional[str] = None absolute_featured_image_preview_url: Optional[str] = None - class Config: + class Meta: model = Event - model_fields = [ + fields = [ 'id', 'title', 'slug', 'description', 'featured_image', 'event_type', 'address', 'location', 'online_link', 'start_time', 'end_time', 'registration_start_date', 'registration_end_date', 'registration_success_markdown', @@ -179,9 +179,9 @@ class RegistrationSchema(ModelSchema): event: EventListSchema discount_code: str | None = None - class Config: + class Meta: model = Registration - model_fields = [ + fields = [ 'id', 'status', 'registered_at', diff --git a/apps/events/tests/integration/test_event_changes.py b/apps/events/tests/integration/test_event_changes.py new file mode 100644 index 0000000..c9269e4 --- /dev/null +++ b/apps/events/tests/integration/test_event_changes.py @@ -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() diff --git a/apps/events/tests/integration/test_event_models.py b/apps/events/tests/integration/test_event_models.py new file mode 100644 index 0000000..63b01f1 --- /dev/null +++ b/apps/events/tests/integration/test_event_models.py @@ -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 'Important' 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 diff --git a/apps/events/tests/integration/test_events.py b/apps/events/tests/integration/test_events.py index f4007a2..acdf6b4 100644 --- a/apps/events/tests/integration/test_events.py +++ b/apps/events/tests/integration/test_events.py @@ -1,117 +1,66 @@ +import pytest import io import json import tempfile import uuid from datetime import timedelta from types import SimpleNamespace - from PIL import Image from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase, override_settings from django.utils import timezone - from core.authentication import create_jwt_token -from apps.events.api.schemas import ( - EventSchema, - EventGallerySchema, - EventListSchema, - RegistrationSchema, - PaymentAdminSchema, - EventAdminDetailSchema, -) +from apps.events.api.schemas import EventSchema, EventGallerySchema, EventListSchema, RegistrationSchema, PaymentAdminSchema, EventAdminDetailSchema from apps.events.api.views import list_events from apps.events.models import Event, Registration from apps.gallery.models import Gallery from apps.payments.models import DiscountCode from apps.users.models import Major, University, User - MEDIA_ROOT = tempfile.mkdtemp() - -@override_settings(MEDIA_ROOT=MEDIA_ROOT) -class EventsAPIIntegrationTests(TestCase): - password = "TestPass123!" +@pytest.mark.django_db +@pytest.mark.integration +class TestEventsAPIIntegrationTests: + password = 'TestPass123!' @classmethod def setUpTestData(cls): - cls.user = User.objects.create_user( - username="event_user", - email="event.user@example.com", - mobile="09198000001", - password=cls.password, - ) + cls.user = User.objects.create_user(username='event_user', email='event.user@example.com', mobile='09198000001', password=cls.password) cls.user.is_email_verified = True - cls.user.save(update_fields=["is_email_verified"]) - - cls.staff = User.objects.create_user( - username="event_staff", - email="event.staff@example.com", - mobile="09198000002", - password=cls.password, - is_staff=True, - ) + cls.user.save(update_fields=['is_email_verified']) + cls.staff = User.objects.create_user(username='event_staff', email='event.staff@example.com', mobile='09198000002', password=cls.password, is_staff=True) cls.staff.is_email_verified = True - cls.staff.save(update_fields=["is_email_verified"]) - cls.major, _ = Major.objects.get_or_create(code="CS", defaults={"name": "Computer Science"}) - cls.university, _ = University.objects.get_or_create(code="UT", defaults={"name": "University of Tehran"}) + cls.staff.save(update_fields=['is_email_verified']) + cls.major, _ = Major.objects.get_or_create(code='CS', defaults={'name': 'Computer Science'}) + cls.university, _ = University.objects.get_or_create(code='UT', defaults={'name': 'University of Tehran'}) cls.user.major = cls.major cls.user.university = cls.university - cls.user.save(update_fields=["major", "university"]) + cls.user.save(update_fields=['major', 'university']) cls.staff.major = cls.major cls.staff.university = cls.university - cls.staff.save(update_fields=["major", "university"]) + cls.staff.save(update_fields=['major', 'university']) - def setUp(self): - super().setUp() + def setup_method(self): + type(self).setUpTestData() self.token = create_jwt_token(self.user) self.staff_token = create_jwt_token(self.staff) - - self.event = self._create_event( - title="Integration Event", - description="Integration description.", - status=Event.StatusChoices.PUBLISHED, - price=0, - ) - self.other_event = self._create_event( - title="Other Published", - description="Searchable", - status=Event.StatusChoices.PUBLISHED, - price=0, - ) + self.event = self._create_event(title='Integration Event', description='Integration description.', status=Event.StatusChoices.PUBLISHED, price=0) + self.other_event = self._create_event(title='Other Published', description='Searchable', status=Event.StatusChoices.PUBLISHED, price=0) def _auth_headers(self, token): - return {"HTTP_AUTHORIZATION": f"Bearer {token}"} + return {'HTTP_AUTHORIZATION': f'Bearer {token}'} def _create_event(self, **overrides): now = timezone.now() - defaults = { - "title": "Event Title", - "description": "Description", - "start_time": now, - "end_time": now + timedelta(hours=2), - "registration_start_date": now - timedelta(days=1), - "registration_end_date": now + timedelta(days=5), - "slug": f"event-{uuid.uuid4().hex[:6]}", - "location": "Campus", - "online_link": "https://meet.example.com", - "price": 0, - "capacity": 10, - "status": Event.StatusChoices.PUBLISHED, - } + defaults = {'title': 'Event Title', 'description': 'Description', 'start_time': now, 'end_time': now + timedelta(hours=2), 'registration_start_date': now - timedelta(days=1), 'registration_end_date': now + timedelta(days=5), 'slug': f'event-{uuid.uuid4().hex[:6]}', 'location': 'Campus', 'online_link': 'https://meet.example.com', 'price': 0, 'capacity': 10, 'status': Event.StatusChoices.PUBLISHED} defaults.update(overrides) return Event.objects.create(**defaults) def _create_gallery_image(self): buffer = io.BytesIO() - Image.new("RGB", (10, 10), color="blue").save(buffer, format="PNG") + Image.new('RGB', (10, 10), color='blue').save(buffer, format='PNG') buffer.seek(0) - file = SimpleUploadedFile("gallery.png", buffer.read(), content_type="image/png") - return Gallery.objects.create( - title="Gallery image", - description="desc", - image=file, - uploaded_by=self.user, - ) + file = SimpleUploadedFile('gallery.png', buffer.read(), content_type='image/png') + return Gallery.objects.create(title='Gallery image', description='desc', image=file, uploaded_by=self.user) def _create_paid_event(self): return self._create_event(price=30000, capacity=5) @@ -119,430 +68,230 @@ class EventsAPIIntegrationTests(TestCase): def _create_registration(self, event, user, status=Registration.StatusChoices.PENDING): return Registration.objects.create(event=event, user=user, status=status, final_price=event.price) - # Basic event endpoints ------------------------------------------------ - def test_list_events_filters_and_search(self): - # Act - response = self.client.get("/api/events/", {"status": "published", "search": "Searchable"}) + response = self.client.get('/api/events/', {'status': 'published', 'search': 'Searchable'}) data = response.json() - - # Assert - self.assertEqual(response.status_code, 200) - self.assertTrue(any(item["id"] == self.other_event.id for item in data)) + assert response.status_code == 200 + assert any((item['id'] == self.other_event.id for item in data)) def test_get_event_by_id_and_slug(self): - response_id = self.client.get(f"/api/events/{self.event.id}") - response_slug = self.client.get(f"/api/events/slug/{self.event.slug}") - - self.assertEqual(response_id.status_code, 200) - self.assertEqual(response_slug.status_code, 200) - self.assertEqual(response_id.json()["id"], self.event.id) - self.assertEqual(response_slug.json()["slug"], self.event.slug) + response_id = self.client.get(f'/api/events/{self.event.id}') + response_slug = self.client.get(f'/api/events/slug/{self.event.slug}') + assert response_id.status_code == 200 + assert response_slug.status_code == 200 + assert response_id.json()['id'] == self.event.id + assert response_slug.json()['slug'] == self.event.slug def test_create_update_and_delete_event(self): - payload = { - "title": "New Event", - "description": "Desc", - "start_time": (timezone.now() + timedelta(days=1)).isoformat(), - "end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(), - "event_type": Event.TypeChoices.ON_SITE, - "status": Event.StatusChoices.DRAFT, - "price": 5000, - } - created = self.client.post( - "/api/events/", - data=json.dumps(payload), - content_type="application/json", - **self._auth_headers(self.staff_token), - ) - self.assertEqual(created.status_code, 201) - event_id = created.json()["id"] - - updated = self.client.put( - f"/api/events/{event_id}", - data=json.dumps({"title": "Updated Event"}), - content_type="application/json", - **self._auth_headers(self.staff_token), - ) - self.assertEqual(updated.status_code, 200) - self.assertEqual(updated.json()["title"], "Updated Event") - - deleted = self.client.delete(f"/api/events/{event_id}", **self._auth_headers(self.staff_token)) - self.assertEqual(deleted.status_code, 200) + payload = {'title': 'New Event', 'description': 'Desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'status': Event.StatusChoices.DRAFT, 'price': 5000} + created = self.client.post('/api/events/', data=json.dumps(payload), content_type='application/json', **self._auth_headers(self.staff_token)) + assert created.status_code == 201 + event_id = created.json()['id'] + updated = self.client.put(f'/api/events/{event_id}', data=json.dumps({'title': 'Updated Event'}), content_type='application/json', **self._auth_headers(self.staff_token)) + assert updated.status_code == 200 + assert updated.json()['title'] == 'Updated Event' + deleted = self.client.delete(f'/api/events/{event_id}', **self._auth_headers(self.staff_token)) + assert deleted.status_code == 200 def test_admin_detail_and_registration_list_requires_staff(self): staff_headers = self._auth_headers(self.staff_token) user_headers = self._auth_headers(self.token) - _ = self._create_registration(self.event, self.user, status=Registration.StatusChoices.CONFIRMED) - - # Non staff forbidden - list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **user_headers) - self.assertEqual(list_resp.status_code, 403) - - # Staff allowed - list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **staff_headers) - detail_resp = self.client.get(f"/api/events/{self.event.id}/admin-detail", **staff_headers) - self.assertEqual(list_resp.status_code, 200) - self.assertEqual(detail_resp.status_code, 200) + list_resp = self.client.get(f'/api/events/{self.event.id}/admin-registrations', **user_headers) + assert list_resp.status_code == 403 + list_resp = self.client.get(f'/api/events/{self.event.id}/admin-registrations', **staff_headers) + detail_resp = self.client.get(f'/api/events/{self.event.id}/admin-detail', **staff_headers) + assert list_resp.status_code == 200 + assert detail_resp.status_code == 200 def test_list_events_filters_by_event_type_and_search(self): - event = self._create_event( - title="Special Search", - description="Unique discovery", - event_type=Event.TypeChoices.ONLINE, - status=Event.StatusChoices.PUBLISHED, - ) - response = self.client.get( - "/api/events/", - { - "event_type": Event.TypeChoices.ONLINE, - "search": "Unique discovery", - }, - ) - self.assertEqual(response.status_code, 200) - self.assertTrue(any(item["id"] == event.id for item in response.json())) + event = self._create_event(title='Special Search', description='Unique discovery', event_type=Event.TypeChoices.ONLINE, status=Event.StatusChoices.PUBLISHED) + response = self.client.get('/api/events/', {'event_type': Event.TypeChoices.ONLINE, 'search': 'Unique discovery'}) + assert response.status_code == 200 + assert any((item['id'] == event.id for item in response.json())) def test_list_events_handles_comma_status_parameter(self): - event = self._create_event( - title="Comma Event", - status=Event.StatusChoices.PUBLISHED, - ) - results = list_events( - None, - status=f"{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}", - event_type=None, - search=None, - limit=10, - offset=0, - ) - self.assertIn(event, list(results)) + event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED) + results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0) + assert event in list(results) def test_create_event_attaches_gallery_images(self): gallery = self._create_gallery_image() - payload = { - "title": "Gallery Event", - "description": "Gallery desc", - "start_time": (timezone.now() + timedelta(days=1)).isoformat(), - "end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(), - "event_type": Event.TypeChoices.ON_SITE, - "status": Event.StatusChoices.DRAFT, - "price": 5000, - "gallery_image_ids": [gallery.id], - } - response = self.client.post( - "/api/events/", - data=json.dumps(payload), - content_type="application/json", - **self._auth_headers(self.staff_token), - ) + payload = {'title': 'Gallery Event', 'description': 'Gallery desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'status': Event.StatusChoices.DRAFT, 'price': 5000, 'gallery_image_ids': [gallery.id]} + response = self.client.post('/api/events/', data=json.dumps(payload), content_type='application/json', **self._auth_headers(self.staff_token)) body = response.json() - self.assertEqual(response.status_code, 201) - self.assertTrue(body["gallery_images"]) - - updated = self.client.put( - f"/api/events/{body['id']}", - data=json.dumps( - { - "title": "Gallery Event Updated", - "gallery_image_ids": [gallery.id], - } - ), - content_type="application/json", - **self._auth_headers(self.staff_token), - ) - self.assertEqual(updated.status_code, 200) - self.assertEqual(updated.json()["slug"], "gallery-event-updated") - self.assertTrue(updated.json()["gallery_images"]) + assert response.status_code == 201 + assert body['gallery_images'] + updated = self.client.put(f'/api/events/{body['id']}', data=json.dumps({'title': 'Gallery Event Updated', 'gallery_image_ids': [gallery.id]}), content_type='application/json', **self._auth_headers(self.staff_token)) + assert updated.status_code == 200 + assert updated.json()['slug'] == 'gallery-event-updated' + assert updated.json()['gallery_images'] def test_admin_registration_filters_include_university_major_and_search(self): event = self.event self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED) headers = self._auth_headers(self.staff_token) - response = self.client.get( - f"/api/events/{event.id}/admin-registrations", - { - "university": self.user.university.code, - "major": self.user.major.code, - "search": self.user.username, - "status": [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING], - }, - **headers, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["count"], 1) + response = self.client.get(f'/api/events/{event.id}/admin-registrations', {'university': self.user.university.code, 'major': self.user.major.code, 'search': self.user.username, 'status': [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING]}, **headers) + assert response.status_code == 200 + assert response.json()['count'] == 1 def test_register_before_start_and_after_end_dates_fail(self): future_event = self._create_event(registration_start_date=timezone.now() + timedelta(days=1)) - future_response = self.client.post( - f"/api/events/{future_event.id}/register", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - self.assertEqual(future_response.status_code, 400) - + future_response = self.client.post(f'/api/events/{future_event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}') + assert future_response.status_code == 400 closed_event = self._create_event(registration_end_date=timezone.now() - timedelta(hours=1)) - closed_response = self.client.post( - f"/api/events/{closed_event.id}/register", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - self.assertEqual(closed_response.status_code, 400) + closed_response = self.client.post(f'/api/events/{closed_event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}') + assert closed_response.status_code == 400 def test_register_recreates_after_cancelled_registration(self): event = self._create_event(price=0) - Registration.objects.create( - event=event, - user=self.user, - status=Registration.StatusChoices.CANCELLED, - final_price=0, - ) - - response = self.client.post( - f"/api/events/{event.id}/register", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - content_type="application/json", - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED) + Registration.objects.create(event=event, user=self.user, status=Registration.StatusChoices.CANCELLED, final_price=0) + response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json') + assert response.status_code == 200 + assert response.json()['status'] == Registration.StatusChoices.CONFIRMED def test_register_updates_final_price_when_none(self): event = self._create_paid_event() - registration = Registration.objects.create( - event=event, - user=self.user, - status=Registration.StatusChoices.PENDING, - final_price=None, - ) - response = self.client.post( - f"/api/events/{event.id}/register", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - content_type="application/json", - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["final_price"], event.price) + registration = Registration.objects.create(event=event, user=self.user, status=Registration.StatusChoices.PENDING, final_price=None) + response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json') + assert response.status_code == 200 + assert response.json()['final_price'] == event.price def _create_discount_code(self, event): - code = DiscountCode.objects.create( - code=f"CODE-{uuid.uuid4().hex[:4]}", - value=50, - type=DiscountCode.Type.PERCENT, - is_active=True, - ) + code = DiscountCode.objects.create(code=f'CODE-{uuid.uuid4().hex[:4]}', value=50, type=DiscountCode.Type.PERCENT, is_active=True) code.applicable_events.add(event) return code def test_register_for_event_with_free_price_confirms(self): event = self._create_event(price=0) - response = self.client.post( - f"/api/events/{event.id}/register", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - content_type="application/json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED) + response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json') + assert response.status_code == 200 + assert response.json()['status'] == Registration.StatusChoices.CONFIRMED def test_register_for_event_with_discount_updates_final_price(self): event = self._create_paid_event() code = self._create_discount_code(event) - response = self.client.post( - f"/api/events/{event.id}/register", - data=json.dumps({"discount_code": code.code}), - content_type="application/json", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - + response = self.client.post(f'/api/events/{event.id}/register', data=json.dumps({'discount_code': code.code}), content_type='application/json', HTTP_AUTHORIZATION=f'Bearer {self.token}') result = response.json() - self.assertEqual(response.status_code, 200) - self.assertEqual(result["discount_code"], code.code) - self.assertEqual(result["discount_amount"], event.price // 2) - self.assertEqual(result["final_price"], event.price // 2) + assert response.status_code == 200 + assert result['discount_code'] == code.code + assert result['discount_amount'] == event.price // 2 + assert result['final_price'] == event.price // 2 def test_register_fails_when_capacity_full(self): event = self._create_event(capacity=1) - other = self._create_event_user("other_user", "other@example.com") - Registration.objects.create( - event=event, - user=other, - status=Registration.StatusChoices.CONFIRMED, - final_price=0, - ) - - response = self.client.post( - f"/api/events/{event.id}/register", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) + other = self._create_event_user('other_user', 'other@example.com') + Registration.objects.create(event=event, user=other, status=Registration.StatusChoices.CONFIRMED, final_price=0) + response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json') + assert response.status_code == 400 def _create_event_user(self, username, email): - suffix = str(abs(hash(username)) % 1_000_000).zfill(6) - user = User.objects.create_user(username=username, email=email, mobile=f"09190{suffix}", password=self.password) + suffix = str(abs(hash(username)) % 1000000).zfill(6) + user = User.objects.create_user(username=username, email=email, mobile=f'09190{suffix}', password=self.password) user.is_email_verified = True - user.save(update_fields=["is_email_verified"]) + user.save(update_fields=['is_email_verified']) user.major = self.user.major user.university = self.user.university - user.save(update_fields=["major", "university"]) + user.save(update_fields=['major', 'university']) return user def test_register_rejects_duplicate_confirmed(self): event = self._create_event(price=0) - Registration.objects.create( - event=event, - user=self.user, - status=Registration.StatusChoices.CONFIRMED, - final_price=0, - ) - - response = self.client.post( - f"/api/events/{event.id}/register", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) + Registration.objects.create(event=event, user=self.user, status=Registration.StatusChoices.CONFIRMED, final_price=0) + response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json') + assert response.status_code == 400 def test_registration_status_update_and_cancel(self): event = self._create_event(price=0) registration = self._create_registration(event, self.user) - - update = self.client.put( - f"/api/events/registrations/{registration.id}", - data=json.dumps({"status": Registration.StatusChoices.ATTENDED}), - content_type="application/json", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - self.assertEqual(update.status_code, 200) - self.assertEqual(update.json()["status"], Registration.StatusChoices.ATTENDED) - - cancel = self.client.delete( - f"/api/events/registrations/{registration.id}", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - self.assertEqual(cancel.status_code, 200) - self.assertEqual(cancel.json()["message"], "ثبت‌نام شما لغو شد :(") + update = self.client.put(f'/api/events/registrations/{registration.id}', data=json.dumps({'status': Registration.StatusChoices.ATTENDED}), content_type='application/json', HTTP_AUTHORIZATION=f'Bearer {self.token}') + assert update.status_code == 200 + assert update.json()['status'] == Registration.StatusChoices.ATTENDED + cancel = self.client.delete(f'/api/events/registrations/{registration.id}', HTTP_AUTHORIZATION=f'Bearer {self.token}') + assert cancel.status_code == 200 + assert cancel.json()['message'] == 'ثبت\u200cنام شما لغو شد :(' def test_verify_registration_and_my_registrations(self): event = self._create_event(price=0) registration = self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED) - - verify = self.client.get( - f"/api/events/registerations/verify/{registration.ticket_id}", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - self.assertEqual(verify.status_code, 200) - self.assertEqual(verify.json()["ticket_id"], str(registration.ticket_id)) - - my_regs = self.client.get( - "/api/events/my-registrations", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - self.assertEqual(my_regs.status_code, 200) - self.assertGreater(len(my_regs.json()), 0) - - status_resp = self.client.get( - f"/api/events/{event.id}/is-registered", - HTTP_AUTHORIZATION=f"Bearer {self.token}", - ) - self.assertEqual(status_resp.status_code, 200) - self.assertTrue(status_resp.json()["is_registered"]) + verify = self.client.get(f'/api/events/registerations/verify/{registration.ticket_id}', HTTP_AUTHORIZATION=f'Bearer {self.token}') + assert verify.status_code == 200 + assert verify.json()['ticket_id'] == str(registration.ticket_id) + my_regs = self.client.get('/api/events/my-registrations', HTTP_AUTHORIZATION=f'Bearer {self.token}') + assert my_regs.status_code == 200 + assert len(my_regs.json()) > 0 + status_resp = self.client.get(f'/api/events/{event.id}/is-registered', HTTP_AUTHORIZATION=f'Bearer {self.token}') + assert status_resp.status_code == 200 + assert status_resp.json()['is_registered'] def test_list_event_registrations(self): event = self.event self._create_registration(event, self.user) - - response = self.client.get(f"/api/events/{event.id}/registrations") - self.assertEqual(response.status_code, 200) - self.assertTrue(response.json()) + response = self.client.get(f'/api/events/{event.id}/registrations') + assert response.status_code == 200 + assert response.json() def test_list_event_registrations_admin_filters(self): event = self.event self._create_registration(event, self.user, status=Registration.StatusChoices.PENDING) headers = self._auth_headers(self.staff_token) - response = self.client.get( - f"/api/events/{event.id}/admin-registrations", - {"status": [Registration.StatusChoices.PENDING]}, - **headers, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["count"], 1) + response = self.client.get(f'/api/events/{event.id}/admin-registrations', {'status': [Registration.StatusChoices.PENDING]}, **headers) + assert response.status_code == 200 + assert response.json()['count'] == 1 + @pytest.fixture(autouse=True) + def _pytest_client(self, client): + self.client = client -class EventSchemasIntegrationTests(TestCase): - password = "SchemaPass!123" + @pytest.fixture(autouse=True) + def _pytest_settings(self, settings): + settings.MEDIA_ROOT = MEDIA_ROOT - def setUp(self): - self.user = User.objects.create_user( - username="schema_user", - email="schema.user@example.com", - mobile="09198000003", - password=self.password, - ) +@pytest.mark.django_db +@pytest.mark.integration +class TestEventSchemasIntegrationTests: + password = 'SchemaPass!123' + + def setup_method(self): + self.user = User.objects.create_user(username='schema_user', email='schema.user@example.com', mobile='09198000003', password=self.password) self.user.is_email_verified = True - self.user.save(update_fields=["is_email_verified"]) - - self.event = Event.objects.create( - title="Schema Event", - description="**bold**", - start_time=timezone.now(), - end_time=timezone.now() + timedelta(hours=1), - registration_start_date=timezone.now() - timedelta(days=1), - registration_end_date=timezone.now() + timedelta(days=1), - price=1000, - slug="schema-event", - ) - Registration.objects.create( - event=self.event, - user=self.user, - status=Registration.StatusChoices.CONFIRMED, - final_price=0, - ) - Registration.objects.create( - event=self.event, - user=self.user, - status=Registration.StatusChoices.ATTENDED, - final_price=0, - ) + self.user.save(update_fields=['is_email_verified']) + self.event = Event.objects.create(title='Schema Event', description='**bold**', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=1), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), price=1000, slug='schema-event') + Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED, final_price=0) + Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.ATTENDED, final_price=0) def _mock_request(self): - return SimpleNamespace(build_absolute_uri=lambda path: f"https://test{path}") + return SimpleNamespace(build_absolute_uri=lambda path: f'https://test{path}') def test_gallery_schema_returns_full_url(self): - obj = SimpleNamespace(image=SimpleNamespace(url="/media/gallery.png")) - result = EventGallerySchema.resolve_absolute_image_url(obj, {"request": self._mock_request()}) - self.assertEqual(result, "https://test/media/gallery.png") + obj = SimpleNamespace(image=SimpleNamespace(url='/media/gallery.png')) + result = EventGallerySchema.resolve_absolute_image_url(obj, {'request': self._mock_request()}) + assert result == 'https://test/media/gallery.png' def test_event_schema_resolvers(self): - context = {"request": self._mock_request()} - event_obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations) - self.assertEqual(EventSchema.resolve_absolute_featured_image_url(event_obj, context), "https://test/media/feat.png") - self.assertEqual(EventSchema.resolve_registration_count(self.event), 2) - self.assertIn("

", EventSchema.resolve_description_html(self.event)) + context = {'request': self._mock_request()} + event_obj = SimpleNamespace(featured_image=SimpleNamespace(url='/media/feat.png'), registrations=self.event.registrations) + assert EventSchema.resolve_absolute_featured_image_url(event_obj, context) == 'https://test/media/feat.png' + assert EventSchema.resolve_registration_count(self.event) == 2 + assert '

' in EventSchema.resolve_description_html(self.event) def test_event_list_schema_resolvers(self): - obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations) - context = {"request": self._mock_request()} - self.assertEqual(EventListSchema.resolve_absolute_featured_image_url(obj, context), "https://test/media/feat.png") - self.assertEqual(EventListSchema.resolve_registration_count(self.event), 2) + obj = SimpleNamespace(featured_image=SimpleNamespace(url='/media/feat.png'), registrations=self.event.registrations) + context = {'request': self._mock_request()} + assert EventListSchema.resolve_absolute_featured_image_url(obj, context) == 'https://test/media/feat.png' + assert EventListSchema.resolve_registration_count(self.event) == 2 def test_registration_schema_resolves_discount_code(self): - discount = DiscountCode.objects.create(code="SCHEMA", type=DiscountCode.Type.FIXED, value=100, is_active=True) + discount = DiscountCode.objects.create(code='SCHEMA', type=DiscountCode.Type.FIXED, value=100, is_active=True) discount.applicable_events.add(self.event) - registration = Registration.objects.create( - event=self.event, - user=self.user, - status=Registration.StatusChoices.CONFIRMED, - final_price=900, - discount_code=discount, - ) - self.assertEqual(RegistrationSchema.resolve_discount_code(registration), discount.code) + registration = Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED, final_price=900, discount_code=discount) + assert RegistrationSchema.resolve_discount_code(registration) == discount.code def test_payment_admin_schema_normalizes_discount_code(self): - self.assertIsNone(PaymentAdminSchema.normalize_discount_code(None)) - self.assertEqual(PaymentAdminSchema.normalize_discount_code("123"), "123") - self.assertEqual(PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code="ABC")), "ABC") + assert PaymentAdminSchema.normalize_discount_code(None) is None + assert PaymentAdminSchema.normalize_discount_code('123') == '123' + assert PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code='ABC')) == 'ABC' def test_event_admin_detail_resolves_registrations(self): registrations = EventAdminDetailSchema.resolve_registrations(self.event) - self.assertTrue(list(registrations)) - # TODO registration-related tests + assert list(registrations) diff --git a/apps/events/tests/unit/test_admin_actions.py b/apps/events/tests/unit/test_admin_actions.py new file mode 100644 index 0000000..c742bcd --- /dev/null +++ b/apps/events/tests/unit/test_admin_actions.py @@ -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) diff --git a/apps/events/tests/unit/test_event_notifications.py b/apps/events/tests/unit/test_event_notifications.py new file mode 100644 index 0000000..085f3b8 --- /dev/null +++ b/apps/events/tests/unit/test_event_notifications.py @@ -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']) diff --git a/apps/events/tests/unit/test_events.py b/apps/events/tests/unit/test_events.py deleted file mode 100644 index ed70dae..0000000 --- a/apps/events/tests/unit/test_events.py +++ /dev/null @@ -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("Important", 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) diff --git a/apps/gallery/api/schemas.py b/apps/gallery/api/schemas.py index 0069e5e..c312e1d 100644 --- a/apps/gallery/api/schemas.py +++ b/apps/gallery/api/schemas.py @@ -17,9 +17,9 @@ class GallerySchema(ModelSchema): absolute_image_preview_url: Optional[str] = None absolute_image_blur_url: Optional[str] = None - class Config: + class Meta: model = Gallery - model_fields = ['id', 'title', 'description', 'image', 'alt_text', + fields = ['id', 'title', 'description', 'image', 'alt_text', 'width', 'height', 'is_public', 'created_at'] @staticmethod diff --git a/apps/gallery/api/views.py b/apps/gallery/api/views.py index cbcae30..88e9598 100644 --- a/apps/gallery/api/views.py +++ b/apps/gallery/api/views.py @@ -109,14 +109,14 @@ def delete_image(request, image_id: int): 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): """List all soft-deleted gallery images (Admin/Committee only)""" if not (request.auth.is_staff or request.auth.is_superuser): return 403, {"error": "Permission denied"} 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): """Restore a soft-deleted gallery image (Admin/Committee only)""" if not (request.auth.is_staff or request.auth.is_superuser): diff --git a/apps/gallery/tests/integration/test_gallery.py b/apps/gallery/tests/integration/test_gallery.py new file mode 100644 index 0000000..e6ad4e4 --- /dev/null +++ b/apps/gallery/tests/integration/test_gallery.py @@ -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() diff --git a/apps/gallery/tests/integration/test_tasks.py b/apps/gallery/tests/integration/test_tasks.py new file mode 100644 index 0000000..072e46f --- /dev/null +++ b/apps/gallery/tests/integration/test_tasks.py @@ -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) diff --git a/apps/gallery/tests/unit/test_admin.py b/apps/gallery/tests/unit/test_admin.py new file mode 100644 index 0000000..c6bf92e --- /dev/null +++ b/apps/gallery/tests/unit/test_admin.py @@ -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 diff --git a/apps/gallery/tests/unit/test_schemas.py b/apps/gallery/tests/unit/test_schemas.py new file mode 100644 index 0000000..817ae24 --- /dev/null +++ b/apps/gallery/tests/unit/test_schemas.py @@ -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 diff --git a/apps/notifications/tests/integration/test_api_flows.py b/apps/notifications/tests/integration/test_api_flows.py new file mode 100644 index 0000000..d1be46d --- /dev/null +++ b/apps/notifications/tests/integration/test_api_flows.py @@ -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() diff --git a/apps/notifications/tests/integration/test_notification_integration.py b/apps/notifications/tests/integration/test_notification_integration.py new file mode 100644 index 0000000..11c8b4f --- /dev/null +++ b/apps/notifications/tests/integration/test_notification_integration.py @@ -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 diff --git a/apps/notifications/tests/integration/test_redis_store.py b/apps/notifications/tests/integration/test_redis_store.py new file mode 100644 index 0000000..d66f280 --- /dev/null +++ b/apps/notifications/tests/integration/test_redis_store.py @@ -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 diff --git a/apps/notifications/tests/test_notifications.py b/apps/notifications/tests/test_notifications.py deleted file mode 100644 index 409f8e1..0000000 --- a/apps/notifications/tests/test_notifications.py +++ /dev/null @@ -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) diff --git a/apps/notifications/tests/unit/test_store.py b/apps/notifications/tests/unit/test_store.py new file mode 100644 index 0000000..7ef5a43 --- /dev/null +++ b/apps/notifications/tests/unit/test_store.py @@ -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() diff --git a/apps/notifications/tests/unit/test_stream.py b/apps/notifications/tests/unit/test_stream.py new file mode 100644 index 0000000..3fb00ee --- /dev/null +++ b/apps/notifications/tests/unit/test_stream.py @@ -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() diff --git a/apps/notifications/tests/unit/test_stream_token.py b/apps/notifications/tests/unit/test_stream_token.py new file mode 100644 index 0000000..a4944ee --- /dev/null +++ b/apps/notifications/tests/unit/test_stream_token.py @@ -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}) diff --git a/apps/payments/tests/integration/test_payment_admin.py b/apps/payments/tests/integration/test_payment_admin.py new file mode 100644 index 0000000..c94b647 --- /dev/null +++ b/apps/payments/tests/integration/test_payment_admin.py @@ -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() diff --git a/apps/payments/tests/unit/test_payments.py b/apps/payments/tests/integration/test_payment_models.py similarity index 56% rename from apps/payments/tests/unit/test_payments.py rename to apps/payments/tests/integration/test_payment_models.py index 88e2824..3865282 100644 --- a/apps/payments/tests/unit/test_payments.py +++ b/apps/payments/tests/integration/test_payment_models.py @@ -1,11 +1,9 @@ +import pytest import uuid from datetime import timedelta from types import SimpleNamespace - from django.core.exceptions import ValidationError -from django.test import TestCase from django.utils import timezone - from django.contrib.admin import AdminSite from apps.payments.admin import DiscountCodeAdmin from apps.payments.models import DiscountCode, Payment @@ -14,50 +12,32 @@ from apps.events.models import Event from apps.users.models import User from ninja.errors import HttpError - class PaymentTestMixin: + @staticmethod def _create_user(**overrides): - data = { - "username": f"user_{uuid.uuid4().hex[:6]}", - "email": f"user_{uuid.uuid4().hex[:6]}@example.com", - "password": "Test!1234", - } + 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'} data.update(overrides) return User.objects.create_user(**data) @staticmethod def _create_event(**overrides): now = timezone.now() - 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, - } + 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} defaults.update(overrides) return Event.objects.create(**defaults) @staticmethod def _discount_code(**overrides): - defaults = { - "code": f"CODE{uuid.uuid4().hex[:4]}", - "value": 50, - "is_active": True, - "type": DiscountCode.Type.PERCENT, - } + defaults = {'code': f'CODE{uuid.uuid4().hex[:4]}', 'value': 50, 'is_active': True, 'type': DiscountCode.Type.PERCENT} defaults.update(overrides) return DiscountCode.objects.create(**defaults) +@pytest.mark.django_db +@pytest.mark.integration +class TestDiscountCodeModelTests(PaymentTestMixin): -class DiscountCodeModelTests(TestCase, PaymentTestMixin): - def setUp(self): + def setup_method(self): self.event = self._create_event() self.user = self._create_user(is_email_verified=True) @@ -65,124 +45,98 @@ class DiscountCodeModelTests(TestCase, PaymentTestMixin): event = self._create_event(price=0) code = self._discount_code() 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): code = self._discount_code(is_active=False) code.applicable_events.add(self.event) - with self.assertRaises(HttpError): + with pytest.raises(HttpError): code.calculate_discount(self.event, self.user) def test_start_date_validation(self): code = self._discount_code(starts_at=timezone.now() + timedelta(days=1)) code.applicable_events.add(self.event) - with self.assertRaises(HttpError): + with pytest.raises(HttpError): code.calculate_discount(self.event, self.user) def test_end_date_validation(self): code = self._discount_code(ends_at=timezone.now() - timedelta(days=1)) code.applicable_events.add(self.event) - with self.assertRaises(HttpError): + with pytest.raises(HttpError): code.calculate_discount(self.event, self.user) def test_applicable_events_enforcement(self): code = self._discount_code() other_event = self._create_event() code.applicable_events.add(other_event) - with self.assertRaises(HttpError): + with pytest.raises(HttpError): code.calculate_discount(self.event, self.user) def test_min_amount_guard(self): code = self._discount_code(min_amount=200000) code.applicable_events.add(self.event) - with self.assertRaises(HttpError): + with pytest.raises(HttpError): code.calculate_discount(self.event, self.user) def test_usage_limit_total(self): code = self._discount_code(usage_limit_total=1) code.applicable_events.add(self.event) - 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, - ) - with self.assertRaises(HttpError): + 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) + with pytest.raises(HttpError): code.calculate_discount(self.event, self.user) def test_usage_limit_per_user(self): code = self._discount_code(usage_limit_per_user=1) code.applicable_events.add(self.event) - 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, - ) - with self.assertRaises(HttpError): + 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) + with pytest.raises(HttpError): code.calculate_discount(self.event, self.user) def test_final_price_below_min_post_discount(self): event = self._create_event(price=15000) code = self._discount_code(value=80) code.applicable_events.add(event) - with self.assertRaises(HttpError): + with pytest.raises(HttpError): code.calculate_discount(event, self.user) def test_fixed_discount_type(self): code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000) code.applicable_events.add(self.event) final, disc = code.calculate_discount(self.event, self.user) - self.assertEqual(disc, 5000) - self.assertEqual(final, self.event.price - 5000) + assert disc == 5000 + assert final == self.event.price - 5000 +@pytest.mark.django_db +@pytest.mark.integration +class TestPaymentModelAndResourceTests(PaymentTestMixin): -class PaymentModelAndResourceTests(TestCase, PaymentTestMixin): - def setUp(self): + def setup_method(self): self.event = self._create_event() self.user = self._create_user(is_email_verified=True) def test_payment_clean_validates_amount(self): - payment = Payment( - user=self.user, - event=self.event, - base_amount=1000, - amount=500, - discount_amount=400, - status=Payment.OrderStatusChoices.INIT, - ) - with self.assertRaises(ValidationError): + payment = Payment(user=self.user, event=self.event, base_amount=1000, amount=500, discount_amount=400, status=Payment.OrderStatusChoices.INIT) + with pytest.raises(ValidationError): payment.full_clean() def test_payment_resource_defers_user_event(self): - payment = Payment.objects.create( - user=self.user, - event=self.event, - base_amount=1000, - amount=1000, - discount_amount=0, - status=Payment.OrderStatusChoices.INIT, - ) + payment = Payment.objects.create(user=self.user, event=self.event, base_amount=1000, amount=1000, discount_amount=0, status=Payment.OrderStatusChoices.INIT) resource = PaymentResource() - user_cell = resource.fields["user"].widget.clean(self.user.username, None) - self.assertEqual(user_cell, self.user) - event_cell = resource.fields["event"].widget.clean(self.event.title, None) - self.assertEqual(event_cell, self.event) + user_cell = resource.fields['user'].widget.clean(self.user.username, None) + assert user_cell == self.user + event_cell = resource.fields['event'].widget.clean(self.event.title, None) + assert event_cell == self.event def test_discount_resource_expands_events(self): resource = DiscountResource() - widget = resource.fields["event"].widget - self.assertEqual(widget.separator, "||") + widget = resource.fields['event'].widget + assert widget.separator == '||' +@pytest.mark.django_db +@pytest.mark.integration +class TestDiscountCodeAdminTests(PaymentTestMixin): -class DiscountCodeAdminTests(TestCase, PaymentTestMixin): - def setUp(self): + def setup_method(self): self.admin = DiscountCodeAdmin(DiscountCode, AdminSite()) def test_deactivate_codes_action(self): @@ -191,4 +145,4 @@ class DiscountCodeAdminTests(TestCase, PaymentTestMixin): request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None)) self.admin.deactivate_codes(request, queryset) code.refresh_from_db() - self.assertFalse(code.is_active) + assert not code.is_active diff --git a/apps/payments/tests/integration/test_payments.py b/apps/payments/tests/integration/test_payments.py index 5a11048..6a9724f 100644 --- a/apps/payments/tests/integration/test_payments.py +++ b/apps/payments/tests/integration/test_payments.py @@ -1,282 +1,137 @@ +import pytest import json from datetime import timedelta -from unittest import mock - -from django.test import TestCase, override_settings from django.utils import timezone - from core.authentication import create_jwt_token from apps.events.models import Event, Registration from apps.payments.models import Payment, DiscountCode from apps.users.models import User - -@override_settings( - ZARINPAL_MERCHANT_ID="MID", - ZARINPAL_REQUEST_URL="https://zarinpal/request", - ZARINPAL_STARTPAY="https://zarinpal/start/", - ZARINPAL_VERIFY_URL="https://zarinpal/verify", - ZARINPAL_CALLBACK_URL="https://frontend/callback", -) -class PaymentsAPIIntegrationTests(TestCase): - password = "PaymentPass!123" +@pytest.mark.django_db +@pytest.mark.integration +class TestPaymentsAPIIntegrationTests: + password = 'PaymentPass!123' @classmethod def setUpTestData(cls): - cls.user = User.objects.create_user( - username="pay_user", - email="pay.user@example.com", - password=cls.password, - ) + cls.user = User.objects.create_user(username='pay_user', email='pay.user@example.com', mobile='09123456789', password=cls.password) 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): - super().setUp() - 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, - ) + def setup_method(self): + type(self).setUpTestData() + 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) self.token = create_jwt_token(self.user) def _headers(self): - return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"} + return {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} def _create_paid_event(self): - 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, - ) + 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) def _create_discount_code(self, event): - code = DiscountCode.objects.create( - code="DISC50", - value=50, - is_active=True, - ) + code = DiscountCode.objects.create(code='DISC50', value=50, is_active=True) code.applicable_events.add(event) return code def test_create_payment_for_free_event(self): - 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, - ) - 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) + 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) + response = self.client.post('/api/payments/create', data=json.dumps({'event_id': free.id, 'description': 'Free registration'}), content_type='application/json', **self._headers()) + assert response.status_code == 200 data = response.json() - self.assertEqual(data["amount"], 0) - self.assertIsNone(data["start_pay_url"]) + assert data['amount'] == 0 + assert data['start_pay_url'] is None - @mock.patch("apps.payments.api.views.requests.post") - def test_create_payment_with_discount(self, mock_post): - mock_response = mock.Mock() - mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}} + def test_create_payment_with_discount(self, mocker): + mock_post = mocker.patch('apps.payments.api.views.requests.post') + mock_response = mocker.Mock() + mock_response.json.return_value = {'data': {'code': 100, 'authority': 'AUTH'}} mock_post.return_value = mock_response - code = self._create_discount_code(self.event) - 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(), - ) - - self.assertEqual(response.status_code, 200) + 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()) + assert response.status_code == 200 payload = response.json() - self.assertEqual(payload["discount_amount"], self.event.price // 2) - self.assertEqual(payload["amount"], self.event.price // 2) - self.assertIn("start_pay_url", payload) + assert payload['discount_amount'] == self.event.price // 2 + assert payload['amount'] == self.event.price // 2 + assert 'start_pay_url' in payload 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, mock_post): - 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", - ) - mock_resp = mock.Mock() - mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}} + def test_callback_success_marks_paid(self, mocker): + mock_post = mocker.patch('apps.payments.api.views.requests.post') + 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') + mock_resp = mocker.Mock() + mock_resp.json.return_value = {'data': {'code': 100, 'ref_id': 'REF', 'card_pan': '123', 'card_hash': 'ABC'}} 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() - self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID) - self.assertTrue("status=success" in response.url) + assert payment.status == Payment.OrderStatusChoices.PAID + assert 'status=success' in response.url - @mock.patch("apps.payments.api.views.requests.post") - def test_callback_failure_redirects_failed(self, mock_post): - 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", - ) - mock_resp = mock.Mock() - mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}} + def test_callback_failure_redirects_failed(self, mocker): + mock_post = mocker.patch('apps.payments.api.views.requests.post') + 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') + mock_resp = mocker.Mock() + mock_resp.json.return_value = {'data': {'code': 101, 'ref_id': 'REF'}} 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() - self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID) - self.assertTrue("status=success" in response.url) + assert payment.status == Payment.OrderStatusChoices.PAID + assert 'status=success' in response.url def test_callback_missing_authority_returns_error(self): - response = self.client.get("/api/payments/callback", {"Status": "OK"}) - self.assertEqual(response.status_code, 400) + response = self.client.get('/api/payments/callback', {'Status': 'OK'}) + assert response.status_code == 400 def test_callback_not_ok_cancels(self): - payment = Payment.objects.create( - user=self.user, - event=self.event, - base_amount=self.event.price, - amount=self.event.price, - status=Payment.OrderStatusChoices.PENDING, - authority="AUTH789", - ) - response = self.client.get( - "/api/payments/callback", - {"Authority": "AUTH789", "Status": "NOK"}, - ) + 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') + response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'}) payment.refresh_from_db() - self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED) - self.assertIn("status=failed", response.url) + assert payment.status == Payment.OrderStatusChoices.CANCELED + 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, mock_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(), - ) - self.assertEqual(response.status_code, 502) - self.assertFalse(Payment.objects.filter(user=self.user).exists()) + def test_create_payment_gateway_failure(self, mocker): + mock_post = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down')) + response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Gateway fail'}), content_type='application/json', **self._headers()) + assert response.status_code == 502 + assert not Payment.objects.filter(user=self.user).exists() def test_create_payment_when_already_paid(self): - Payment.objects.create( - user=self.user, - event=self.event, - 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) + Payment.objects.create(user=self.user, event=self.event, 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()) + assert response.status_code == 400 - @mock.patch("apps.payments.api.views.requests.post") - def test_registration_final_price_none_updates(self, mock_post): - registration = Registration.objects.create( - event=self.event, - user=self.user, - status=Registration.StatusChoices.PENDING, - final_price=None, - ) - mock_response = mock.Mock() - mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}} + def test_registration_final_price_none_updates(self, mocker): + mock_post = mocker.patch('apps.payments.api.views.requests.post') + registration = Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.PENDING, final_price=None) + mock_response = mocker.Mock() + mock_response.json.return_value = {'data': {'code': 100, 'authority': 'AUTH'}} mock_post.return_value = mock_response - response = self.client.post( - "/api/payments/create", - data=json.dumps({"event_id": self.event.id, "description": "Update"}), - content_type="application/json", - **self._headers(), - ) - self.assertEqual(response.status_code, 200) + response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Update'}), content_type='application/json', **self._headers()) + assert response.status_code == 200 registration.refresh_from_db() 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): - 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) + 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 - missing = self.client.post( - "/api/payments/coupon/check", - data=json.dumps({"event_id": self.event.id}), - content_type="application/json", - **self._headers(), - ) - self.assertEqual(missing.status_code, 422) + @pytest.fixture(autouse=True) + def _pytest_client(self, client): + self.client = client - # invalid code - invalid = self.client.post( - "/api/payments/coupon/check", - data=json.dumps({"event_id": self.event.id, "code": "INVALID"}), - content_type="application/json", - **self._headers(), - ) - 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()) + @pytest.fixture(autouse=True) + def _pytest_settings(self, settings): + settings.ZARINPAL_MERCHANT_ID = 'MID' + settings.ZARINPAL_REQUEST_URL = 'https://zarinpal/request' + settings.ZARINPAL_STARTPAY = 'https://zarinpal/start/' + settings.ZARINPAL_VERIFY_URL = 'https://zarinpal/verify' + settings.ZARINPAL_CALLBACK_URL = 'https://frontend/callback' diff --git a/apps/payments/tests/unit/test_schemas.py b/apps/payments/tests/unit/test_schemas.py new file mode 100644 index 0000000..e3c05df --- /dev/null +++ b/apps/payments/tests/unit/test_schemas.py @@ -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 == [] diff --git a/apps/users/tests/integration/test_api_flows.py b/apps/users/tests/integration/test_api_flows.py new file mode 100644 index 0000000..22970b1 --- /dev/null +++ b/apps/users/tests/integration/test_api_flows.py @@ -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 diff --git a/apps/users/tests/integration/test_audit_command.py b/apps/users/tests/integration/test_audit_command.py new file mode 100644 index 0000000..f1009c7 --- /dev/null +++ b/apps/users/tests/integration/test_audit_command.py @@ -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 diff --git a/apps/users/tests/integration/test_auth_flows.py b/apps/users/tests/integration/test_auth_flows.py new file mode 100644 index 0000000..754e5e6 --- /dev/null +++ b/apps/users/tests/integration/test_auth_flows.py @@ -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() diff --git a/apps/users/tests/integration/test_auth_service_edges.py b/apps/users/tests/integration/test_auth_service_edges.py new file mode 100644 index 0000000..cb8f81e --- /dev/null +++ b/apps/users/tests/integration/test_auth_service_edges.py @@ -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!") diff --git a/apps/users/tests/integration/test_google_api.py b/apps/users/tests/integration/test_google_api.py new file mode 100644 index 0000000..cc91cfd --- /dev/null +++ b/apps/users/tests/integration/test_google_api.py @@ -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"] diff --git a/apps/users/tests/integration/test_google_flows.py b/apps/users/tests/integration/test_google_flows.py new file mode 100644 index 0000000..6a8568a --- /dev/null +++ b/apps/users/tests/integration/test_google_flows.py @@ -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" diff --git a/apps/users/tests/integration/test_meta.py b/apps/users/tests/integration/test_meta.py new file mode 100644 index 0000000..34cd346 --- /dev/null +++ b/apps/users/tests/integration/test_meta.py @@ -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() diff --git a/apps/users/tests/integration/test_user_models.py b/apps/users/tests/integration/test_user_models.py new file mode 100644 index 0000000..77ae0c3 --- /dev/null +++ b/apps/users/tests/integration/test_user_models.py @@ -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) diff --git a/apps/users/tests/integration/test_users.py b/apps/users/tests/integration/test_users.py index 61d6af3..4573bb3 100644 --- a/apps/users/tests/integration/test_users.py +++ b/apps/users/tests/integration/test_users.py @@ -1,85 +1,72 @@ +import pytest import json -from unittest import mock - -from django.test import TestCase from django.core.files.uploadedfile import SimpleUploadedFile - from apps.users.models import User from core.authentication import create_jwt_token +@pytest.mark.django_db +@pytest.mark.integration +class TestUsersAPIIntegrationTests: -class UsersAPIIntegrationTests(TestCase): - 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, - ) + def setup_method(self): + 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): - 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): - response = self.client.post("/api/auth/login", json.dumps({ - "identifier": self.user.mobile, - "password": "Sup3rSecure!123", - }), content_type="application/json") - self.assertEqual(response.status_code, 200) - self.assertIn("access_token", response.json()) + response = self.client.post('/api/auth/login', json.dumps({'identifier': self.user.mobile, 'password': 'Sup3rSecure!123'}), content_type='application/json') + assert response.status_code == 200 + assert 'access_token' in response.json() def test_profile_update_treats_supplied_email_as_identity(self): - response = self.client.put("/api/auth/profile", json.dumps({"email": "new@example.com"}), - content_type="application/json", **self._auth()) - self.assertEqual(response.status_code, 200) + response = self.client.put('/api/auth/profile', json.dumps({'email': 'new@example.com'}), content_type='application/json', **self._auth()) + assert response.status_code == 200 self.user.refresh_from_db() - self.assertEqual(self.user.email, "new@example.com") - self.assertTrue(self.user.is_email_verified) + assert self.user.email == 'new@example.com' + assert self.user.is_email_verified def test_profile_update_clears_email_identity_flag(self): - response = self.client.put("/api/auth/profile", json.dumps({"email": ""}), - content_type="application/json", **self._auth()) - self.assertEqual(response.status_code, 200) + response = self.client.put('/api/auth/profile', json.dumps({'email': ''}), content_type='application/json', **self._auth()) + assert response.status_code == 200 self.user.refresh_from_db() - self.assertIsNone(self.user.email) - self.assertFalse(self.user.is_email_verified) + assert self.user.email is None + assert not self.user.is_email_verified def test_profile_requires_authentication_and_returns_account_identity(self): - self.assertEqual(self.client.get("/api/auth/profile").status_code, 401) - response = self.client.get("/api/auth/profile", **self._auth()) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["mobile"], self.user.mobile) + assert self.client.get('/api/auth/profile').status_code == 401 + response = self.client.get('/api/auth/profile', **self._auth()) + assert response.status_code == 200 + assert response.json()['mobile'] == self.user.mobile def test_profile_rejects_duplicate_email(self): - 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"}), - content_type="application/json", **self._auth()) - self.assertEqual(response.status_code, 400) + 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'}), content_type='application/json', **self._auth()) + assert response.status_code == 400 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): - image = SimpleUploadedFile("notes.txt", b"not an image", content_type="text/plain") - response = self.client.post("/api/auth/profile/picture", {"file": image}, **self._auth()) - self.assertEqual(response.status_code, 400) + image = SimpleUploadedFile('notes.txt', b'not an image', content_type='text/plain') + response = self.client.post('/api/auth/profile/picture', {'file': image}, **self._auth()) + assert response.status_code == 400 def test_check_username_reports_existing_user(self): - response = self.client.get("/api/auth/check-username", {"username": self.user.username}) - self.assertEqual(response.status_code, 200) - self.assertTrue(response.json()["exists"]) + response = self.client.get('/api/auth/check-username', {'username': self.user.username}) + assert response.status_code == 200 + assert response.json()['exists'] def test_legacy_email_auth_routes_are_gone(self): - self.assertEqual(self.client.get("/api/auth/verify-email/token").status_code, 404) - self.assertEqual(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.get('/api/auth/verify-email/token').status_code == 404 + assert self.client.post('/api/auth/resend-verification').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, reset): - response = self.client.post("/api/auth/reset-password", json.dumps({ - "mobile": self.user.mobile, - "code": "123456", - "new_password": "AnotherSecurePass!123", - }), content_type="application/json") - self.assertEqual(response.status_code, 200) + def test_mobile_otp_password_reset_route_remains_available(self, mocker): + reset = mocker.patch('apps.users.api.views.reset_password_with_otp') + response = self.client.post('/api/auth/reset-password', json.dumps({'mobile': self.user.mobile, 'code': '123456', 'new_password': 'AnotherSecurePass!123'}), content_type='application/json') + assert response.status_code == 200 reset.assert_called_once() + + @pytest.fixture(autouse=True) + def _pytest_client(self, client): + self.client = client diff --git a/apps/users/tests/unit/test_auth_service.py b/apps/users/tests/unit/test_auth_service.py new file mode 100644 index 0000000..74ae65d --- /dev/null +++ b/apps/users/tests/unit/test_auth_service.py @@ -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") diff --git a/apps/users/tests/unit/test_google_oauth.py b/apps/users/tests/unit/test_google_oauth.py new file mode 100644 index 0000000..e523129 --- /dev/null +++ b/apps/users/tests/unit/test_google_oauth.py @@ -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" diff --git a/apps/users/tests/unit/test_identity.py b/apps/users/tests/unit/test_identity.py new file mode 100644 index 0000000..fd2026b --- /dev/null +++ b/apps/users/tests/unit/test_identity.py @@ -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") diff --git a/apps/users/tests/unit/test_resources.py b/apps/users/tests/unit/test_resources.py new file mode 100644 index 0000000..19abf32 --- /dev/null +++ b/apps/users/tests/unit/test_resources.py @@ -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 diff --git a/apps/users/tests/unit/test_signals.py b/apps/users/tests/unit/test_signals.py new file mode 100644 index 0000000..1e26cae --- /dev/null +++ b/apps/users/tests/unit/test_signals.py @@ -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() diff --git a/apps/users/tests/unit/test_sms_tasks.py b/apps/users/tests/unit/test_sms_tasks.py new file mode 100644 index 0000000..7cdceac --- /dev/null +++ b/apps/users/tests/unit/test_sms_tasks.py @@ -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 diff --git a/apps/users/tests/unit/test_users.py b/apps/users/tests/unit/test_users.py deleted file mode 100644 index 4d0d848..0000000 --- a/apps/users/tests/unit/test_users.py +++ /dev/null @@ -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) diff --git a/config/settings/test.py b/config/settings/test.py index aa527fc..2d9ca26 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -1,3 +1,5 @@ +from tempfile import TemporaryDirectory + from .base import * # Lightweight defaults keep local/CI test runs isolated from production infra. @@ -31,6 +33,9 @@ CACHES = { CELERY_TASK_ALWAYS_EAGER = 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. CSRF_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False diff --git a/config/tests/integration/test_routing.py b/config/tests/integration/test_routing.py new file mode 100644 index 0000000..ee041da --- /dev/null +++ b/config/tests/integration/test_routing.py @@ -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 diff --git a/config/tests/unit/test_environment.py b/config/tests/unit/test_environment.py new file mode 100644 index 0000000..b143e92 --- /dev/null +++ b/config/tests/unit/test_environment.py @@ -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"] diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..04181a8 --- /dev/null +++ b/conftest.py @@ -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 diff --git a/core/tests/integration/test_health.py b/core/tests/integration/test_health.py new file mode 100644 index 0000000..fe63289 --- /dev/null +++ b/core/tests/integration/test_health.py @@ -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" diff --git a/core/tests/integration/test_media.py b/core/tests/integration/test_media.py new file mode 100644 index 0000000..aeec629 --- /dev/null +++ b/core/tests/integration/test_media.py @@ -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) diff --git a/core/tests/integration/test_soft_delete.py b/core/tests/integration/test_soft_delete.py new file mode 100644 index 0000000..c0426b9 --- /dev/null +++ b/core/tests/integration/test_soft_delete.py @@ -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() diff --git a/core/tests/unit/test_admin.py b/core/tests/unit/test_admin.py new file mode 100644 index 0000000..b885cd9 --- /dev/null +++ b/core/tests/unit/test_admin.py @@ -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() diff --git a/core/tests/unit/test_formatting.py b/core/tests/unit/test_formatting.py new file mode 100644 index 0000000..dbde15a --- /dev/null +++ b/core/tests/unit/test_formatting.py @@ -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"] diff --git a/core/tests/unit/test_media.py b/core/tests/unit/test_media.py deleted file mode 100644 index 23af837..0000000 --- a/core/tests/unit/test_media.py +++ /dev/null @@ -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), - ) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..66ed40f --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..524f68e --- /dev/null +++ b/requirements-test.txt @@ -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 diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py new file mode 100644 index 0000000..e40b1e9 --- /dev/null +++ b/scripts/check_coverage.py @@ -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()