From ff991309387097823ff8481a5f5dfd2b8271f2b8 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 26 Sep 2026 16:50:29 +0330 Subject: [PATCH] test: migrate backend suites to pytest with module coverage gate Split backend tests into unit and integration suites, add isolated fixtures and coverage enforcement, and update CI and test documentation. Cover current authentication, content, notification, payment, media, and admin flows; fix behaviors revealed by the new tests. Validate both suites and the per-module gate against local PostgreSQL. --- .coveragerc | 15 +- .github/workflows/backend.yml | 19 +- README.md | 20 +- .../tests/integration/test_analytics.py | 40 ++ apps/analytics/tests/unit/test_helpers.py | 55 ++ apps/blog/api/schemas.py | 20 +- .../commands/seed_blog_mock_data.py | 23 +- apps/blog/tests/integration/test_commands.py | 43 ++ .../tests/integration/test_public_blog.py | 309 ++++++++++ apps/blog/tests/unit/test_admin_actions.py | 33 ++ apps/blog/tests/unit/test_permissions.py | 69 +++ apps/blog/tests/unit/test_seed_helpers.py | 15 + apps/certificates/api/views.py | 4 +- .../tests/integration/test_certificates.py | 151 +++++ .../tests/unit/test_certificates.py | 20 + apps/communications/api/schemas.py | 8 +- apps/communications/api/views.py | 52 +- .../tests/integration/test_announcements.py | 29 + .../tests/integration/test_api.py | 66 +++ .../tests/integration/test_reminders.py | 23 + .../tests/test_announcements.py | 32 - apps/communications/tests/unit/test_push.py | 76 +++ .../tests/unit/test_resources.py | 13 + apps/events/api/schemas.py | 12 +- .../tests/integration/test_event_changes.py | 61 ++ .../tests/integration/test_event_models.py | 35 ++ apps/events/tests/integration/test_events.py | 561 +++++------------- apps/events/tests/unit/test_admin_actions.py | 34 ++ .../tests/unit/test_event_notifications.py | 72 +++ apps/events/tests/unit/test_events.py | 93 --- apps/gallery/api/schemas.py | 4 +- apps/gallery/api/views.py | 4 +- .../gallery/tests/integration/test_gallery.py | 49 ++ apps/gallery/tests/integration/test_tasks.py | 28 + apps/gallery/tests/unit/test_admin.py | 41 ++ apps/gallery/tests/unit/test_schemas.py | 17 + .../tests/integration/test_api_flows.py | 51 ++ .../test_notification_integration.py | 111 ++++ .../tests/integration/test_redis_store.py | 38 ++ .../notifications/tests/test_notifications.py | 170 ------ apps/notifications/tests/unit/test_store.py | 64 ++ apps/notifications/tests/unit/test_stream.py | 28 + .../tests/unit/test_stream_token.py | 22 + .../tests/integration/test_payment_admin.py | 87 +++ .../test_payment_models.py} | 126 ++-- .../tests/integration/test_payments.py | 313 +++------- apps/payments/tests/unit/test_schemas.py | 21 + .../users/tests/integration/test_api_flows.py | 171 ++++++ .../tests/integration/test_audit_command.py | 20 + .../tests/integration/test_auth_flows.py | 48 ++ .../integration/test_auth_service_edges.py | 89 +++ .../tests/integration/test_google_api.py | 62 ++ .../tests/integration/test_google_flows.py | 98 +++ apps/users/tests/integration/test_meta.py | 42 ++ .../tests/integration/test_user_models.py | 25 + apps/users/tests/integration/test_users.py | 99 ++-- apps/users/tests/unit/test_auth_service.py | 40 ++ apps/users/tests/unit/test_google_oauth.py | 50 ++ apps/users/tests/unit/test_identity.py | 42 ++ apps/users/tests/unit/test_resources.py | 10 + apps/users/tests/unit/test_signals.py | 18 + apps/users/tests/unit/test_sms_tasks.py | 27 + apps/users/tests/unit/test_users.py | 30 - config/settings/test.py | 5 + config/tests/integration/test_routing.py | 9 + config/tests/unit/test_environment.py | 15 + conftest.py | 109 ++++ core/tests/integration/test_health.py | 10 + core/tests/integration/test_media.py | 95 +++ core/tests/integration/test_soft_delete.py | 23 + core/tests/unit/test_admin.py | 35 ++ core/tests/unit/test_formatting.py | 24 + core/tests/unit/test_media.py | 174 ------ pytest.ini | 13 + requirements-test.txt | 6 + scripts/check_coverage.py | 62 ++ 76 files changed, 3271 insertions(+), 1357 deletions(-) create mode 100644 apps/analytics/tests/integration/test_analytics.py create mode 100644 apps/analytics/tests/unit/test_helpers.py create mode 100644 apps/blog/tests/integration/test_commands.py create mode 100644 apps/blog/tests/integration/test_public_blog.py create mode 100644 apps/blog/tests/unit/test_admin_actions.py create mode 100644 apps/blog/tests/unit/test_permissions.py create mode 100644 apps/blog/tests/unit/test_seed_helpers.py create mode 100644 apps/certificates/tests/integration/test_certificates.py create mode 100644 apps/certificates/tests/unit/test_certificates.py create mode 100644 apps/communications/tests/integration/test_announcements.py create mode 100644 apps/communications/tests/integration/test_api.py create mode 100644 apps/communications/tests/integration/test_reminders.py delete mode 100644 apps/communications/tests/test_announcements.py create mode 100644 apps/communications/tests/unit/test_push.py create mode 100644 apps/communications/tests/unit/test_resources.py create mode 100644 apps/events/tests/integration/test_event_changes.py create mode 100644 apps/events/tests/integration/test_event_models.py create mode 100644 apps/events/tests/unit/test_admin_actions.py create mode 100644 apps/events/tests/unit/test_event_notifications.py delete mode 100644 apps/events/tests/unit/test_events.py create mode 100644 apps/gallery/tests/integration/test_gallery.py create mode 100644 apps/gallery/tests/integration/test_tasks.py create mode 100644 apps/gallery/tests/unit/test_admin.py create mode 100644 apps/gallery/tests/unit/test_schemas.py create mode 100644 apps/notifications/tests/integration/test_api_flows.py create mode 100644 apps/notifications/tests/integration/test_notification_integration.py create mode 100644 apps/notifications/tests/integration/test_redis_store.py delete mode 100644 apps/notifications/tests/test_notifications.py create mode 100644 apps/notifications/tests/unit/test_store.py create mode 100644 apps/notifications/tests/unit/test_stream.py create mode 100644 apps/notifications/tests/unit/test_stream_token.py create mode 100644 apps/payments/tests/integration/test_payment_admin.py rename apps/payments/tests/{unit/test_payments.py => integration/test_payment_models.py} (56%) create mode 100644 apps/payments/tests/unit/test_schemas.py create mode 100644 apps/users/tests/integration/test_api_flows.py create mode 100644 apps/users/tests/integration/test_audit_command.py create mode 100644 apps/users/tests/integration/test_auth_flows.py create mode 100644 apps/users/tests/integration/test_auth_service_edges.py create mode 100644 apps/users/tests/integration/test_google_api.py create mode 100644 apps/users/tests/integration/test_google_flows.py create mode 100644 apps/users/tests/integration/test_meta.py create mode 100644 apps/users/tests/integration/test_user_models.py create mode 100644 apps/users/tests/unit/test_auth_service.py create mode 100644 apps/users/tests/unit/test_google_oauth.py create mode 100644 apps/users/tests/unit/test_identity.py create mode 100644 apps/users/tests/unit/test_resources.py create mode 100644 apps/users/tests/unit/test_signals.py create mode 100644 apps/users/tests/unit/test_sms_tasks.py delete mode 100644 apps/users/tests/unit/test_users.py create mode 100644 config/tests/integration/test_routing.py create mode 100644 config/tests/unit/test_environment.py create mode 100644 conftest.py create mode 100644 core/tests/integration/test_health.py create mode 100644 core/tests/integration/test_media.py create mode 100644 core/tests/integration/test_soft_delete.py create mode 100644 core/tests/unit/test_admin.py create mode 100644 core/tests/unit/test_formatting.py delete mode 100644 core/tests/unit/test_media.py create mode 100644 pytest.ini create mode 100644 requirements-test.txt create mode 100644 scripts/check_coverage.py 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()