test: migrate backend suites to pytest with module coverage gate
Split backend tests into unit and integration suites, add isolated fixtures and coverage enforcement, and update CI and test documentation. Cover current authentication, content, notification, payment, media, and admin flows; fix behaviors revealed by the new tests. Validate both suites and the per-module gate against local PostgreSQL.
This commit is contained in:
10
core/tests/integration/test_health.py
Normal file
10
core/tests/integration/test_health.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import pytest
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def test_health_endpoint_reports_database_connection(client):
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
95
core/tests/integration/test_media.py
Normal file
95
core/tests/integration/test_media.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
import io
|
||||
from datetime import timedelta
|
||||
from PIL import Image
|
||||
from django.core.management import call_command
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import RequestFactory
|
||||
from django.utils import timezone
|
||||
from apps.blog.api.schemas import AuthorSchema, PostListSchema
|
||||
from apps.blog.models import Post
|
||||
from apps.events.api.schemas import EventGallerySchema, EventListSchema
|
||||
from apps.events.models import Event
|
||||
from apps.gallery.api.schemas import GallerySchema
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.users.api.schemas import UserProfileSchema
|
||||
from apps.users.models import User
|
||||
from core.media import BLUR_VARIANT, PREVIEW_VARIANT, THUMBNAIL_VARIANT, delete_image_derivatives, derivative_name, process_public_image
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.integration
|
||||
class TestMediaProcessingTests:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _media_root(self, settings, tmp_path):
|
||||
self.media_root = str(tmp_path)
|
||||
settings.MEDIA_ROOT = self.media_root
|
||||
settings.MEDIA_URL = '/media/'
|
||||
|
||||
def setup_method(self):
|
||||
self.user = User.objects.create_user(username='media-user', email='media@example.com', mobile='09123456789', password='TestPass123!')
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def _upload(self, name: str, *, mode: str='RGB', fmt: str='JPEG') -> SimpleUploadedFile:
|
||||
buffer = io.BytesIO()
|
||||
Image.new(mode, (2400, 1800), color=(20, 80, 200)).save(buffer, format=fmt)
|
||||
buffer.seek(0)
|
||||
return SimpleUploadedFile(name, buffer.read(), content_type=f'image/{fmt.lower()}')
|
||||
|
||||
def test_process_public_image_generates_derivatives_for_jpeg(self):
|
||||
gallery = Gallery.objects.create(title='JPEG image', uploaded_by=self.user, image=self._upload('photo.jpg', fmt='JPEG'))
|
||||
result = process_public_image(gallery.image, 'gallery', force=True)
|
||||
assert result.processed
|
||||
assert gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT))
|
||||
assert gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))
|
||||
assert gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT))
|
||||
|
||||
def test_process_public_image_generates_derivatives_for_png(self):
|
||||
gallery = Gallery.objects.create(title='PNG image', uploaded_by=self.user, image=self._upload('photo.png', mode='RGBA', fmt='PNG'))
|
||||
result = process_public_image(gallery.image, 'gallery', force=True)
|
||||
assert result.width == 2400
|
||||
assert result.height == 1800
|
||||
assert gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))
|
||||
|
||||
def test_delete_image_derivatives_removes_sidecars(self):
|
||||
gallery = Gallery.objects.create(title='Delete derivatives', uploaded_by=self.user, image=self._upload('cleanup.jpg', fmt='JPEG'))
|
||||
process_public_image(gallery.image, 'gallery', force=True)
|
||||
delete_image_derivatives(gallery.image, 'gallery')
|
||||
assert not gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT))
|
||||
assert not gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))
|
||||
assert not gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT))
|
||||
|
||||
def test_replacing_image_removes_previous_sidecars(self):
|
||||
gallery = Gallery.objects.create(title='Replace derivatives', uploaded_by=self.user, image=self._upload('replace-first.jpg', fmt='JPEG'))
|
||||
first_name = gallery.image.name
|
||||
gallery.image = self._upload('replace-second.jpg', fmt='JPEG')
|
||||
gallery.save()
|
||||
assert not gallery.image.storage.exists(derivative_name(first_name, THUMBNAIL_VARIANT))
|
||||
assert not gallery.image.storage.exists(derivative_name(first_name, PREVIEW_VARIANT))
|
||||
assert not gallery.image.storage.exists(derivative_name(first_name, BLUR_VARIANT))
|
||||
|
||||
def test_backfill_command_is_idempotent(self):
|
||||
gallery = Gallery(title='Backfill image', uploaded_by=self.user)
|
||||
gallery._defer_image_processing = True
|
||||
gallery.image = self._upload('backfill.jpg', fmt='JPEG')
|
||||
gallery.save()
|
||||
first = io.StringIO()
|
||||
second = io.StringIO()
|
||||
call_command('backfill_media_derivatives', stdout=first)
|
||||
call_command('backfill_media_derivatives', stdout=second)
|
||||
assert 'processed=1' in first.getvalue()
|
||||
assert 'skipped=1' in second.getvalue()
|
||||
|
||||
def test_schema_resolvers_expose_derivative_urls(self):
|
||||
self.user.profile_picture.save('profile.jpg', self._upload('profile.jpg', fmt='JPEG'))
|
||||
gallery = Gallery.objects.create(title='Schema image', uploaded_by=self.user, image=self._upload('schema-gallery.jpg', fmt='JPEG'))
|
||||
event = Event.objects.create(title='Schema event', description='Event description', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=2), featured_image=self._upload('schema-event.jpg', fmt='JPEG'))
|
||||
post = Post.objects.create(title='Schema post', content='Post body', author=self.user, featured_image=self._upload('schema-post.jpg', fmt='JPEG'), status=Post.StatusChoices.PUBLISHED)
|
||||
context = {'request': self.factory.get('/')}
|
||||
assert derivative_name(event.featured_image.name, THUMBNAIL_VARIANT) in EventListSchema.resolve_absolute_featured_image_thumbnail_url(event, context)
|
||||
assert derivative_name(event.featured_image.name, PREVIEW_VARIANT) in EventListSchema.resolve_absolute_featured_image_preview_url(event, context)
|
||||
assert derivative_name(gallery.image.name, PREVIEW_VARIANT) in EventGallerySchema.resolve_absolute_image_preview_url(gallery, context)
|
||||
assert derivative_name(gallery.image.name, BLUR_VARIANT) in GallerySchema.resolve_absolute_image_blur_url(gallery, context)
|
||||
assert derivative_name(post.featured_image.name, THUMBNAIL_VARIANT) in PostListSchema.resolve_absolute_featured_image_thumbnail_url(post, context)
|
||||
assert derivative_name(self.user.profile_picture.name, THUMBNAIL_VARIANT) in AuthorSchema.resolve_profile_picture_thumbnail_url(self.user, context)
|
||||
assert derivative_name(self.user.profile_picture.name, PREVIEW_VARIANT) in UserProfileSchema.resolve_profile_picture_preview_url(self.user, context)
|
||||
23
core/tests/integration/test_soft_delete.py
Normal file
23
core/tests/integration/test_soft_delete.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from apps.events.models import Event
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def test_soft_delete_hides_record_and_restore_reveals_it(event_factory):
|
||||
event = event_factory()
|
||||
event.delete()
|
||||
assert not Event.objects.filter(pk=event.pk).exists()
|
||||
assert Event.deleted_objects.filter(pk=event.pk).exists()
|
||||
assert Event.all_objects.get_queryset().dead().filter(pk=event.pk).exists()
|
||||
assert not Event.all_objects.get_queryset().alive().filter(pk=event.pk).exists()
|
||||
event.restore()
|
||||
assert Event.objects.filter(pk=event.pk).exists()
|
||||
|
||||
|
||||
def test_hard_delete_removes_row(event_factory):
|
||||
event = event_factory()
|
||||
Event.all_objects.filter(pk=event.pk).hard_delete()
|
||||
assert not Event.all_objects.filter(pk=event.pk).exists()
|
||||
35
core/tests/unit/test_admin.py
Normal file
35
core/tests/unit/test_admin.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.events.models import Event
|
||||
from core.admin import BaseModelAdmin, SoftDeleteListFilter
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_soft_delete_filter_selects_active_deleted_or_all(mocker):
|
||||
filt = SoftDeleteListFilter(SimpleNamespace(GET={}), {}, Event, admin.site)
|
||||
queryset = mocker.Mock()
|
||||
queryset.model = Event
|
||||
mocker.patch.object(filt, "value", return_value="0")
|
||||
filt.queryset(None, queryset)
|
||||
queryset.filter.assert_called_with(is_deleted=False)
|
||||
filt.value.return_value = "1"
|
||||
assert filt.queryset(None, queryset).model == Event
|
||||
filt.value.return_value = None
|
||||
assert filt.queryset(None, queryset) is queryset
|
||||
|
||||
|
||||
def test_admin_delete_permission_and_restore_action(mocker):
|
||||
model_admin = BaseModelAdmin(Event, admin.site)
|
||||
assert not model_admin.has_delete_permission(SimpleNamespace(user=SimpleNamespace(is_superuser=False)))
|
||||
assert model_admin.has_delete_permission(SimpleNamespace(user=SimpleNamespace(is_superuser=True)))
|
||||
mocker.patch.object(model_admin, "message_user")
|
||||
deleted = SimpleNamespace(is_deleted=True, restore=mocker.Mock())
|
||||
active = SimpleNamespace(is_deleted=False, restore=mocker.Mock())
|
||||
model_admin.restore_selected(SimpleNamespace(), [deleted, active])
|
||||
deleted.restore.assert_called_once()
|
||||
active.restore.assert_not_called()
|
||||
24
core/tests/unit/test_formatting.py
Normal file
24
core/tests/unit/test_formatting.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core.choices import MajorChoices
|
||||
from core.templatetags.jalali import fa_digits, jdate
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_persian_digits_translate_numbers_without_changing_text():
|
||||
assert fa_digits("Year 1404/09") == "Year ۱۴۰۴/۰۹"
|
||||
assert fa_digits(12) == "۱۲"
|
||||
|
||||
|
||||
def test_jalali_date_handles_missing_and_aware_values():
|
||||
assert jdate(None) == ""
|
||||
assert jdate(datetime(2025, 3, 21, tzinfo=timezone.utc), "%Y/%m/%d") == "1404/01/01"
|
||||
|
||||
|
||||
def test_major_choice_public_values_are_stable():
|
||||
assert MajorChoices.CE.value == "CE"
|
||||
assert dict(MajorChoices.choices)["CE"]
|
||||
@@ -1,174 +0,0 @@
|
||||
import io
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
from PIL import Image
|
||||
from django.core.management import call_command
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import RequestFactory
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.blog.api.schemas import AuthorSchema, PostListSchema
|
||||
from apps.blog.models import Post
|
||||
from apps.events.api.schemas import EventGallerySchema, EventListSchema
|
||||
from apps.events.models import Event
|
||||
from apps.gallery.api.schemas import GallerySchema
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.users.api.schemas import UserProfileSchema
|
||||
from apps.users.models import User
|
||||
from core.media import (
|
||||
BLUR_VARIANT,
|
||||
PREVIEW_VARIANT,
|
||||
THUMBNAIL_VARIANT,
|
||||
delete_image_derivatives,
|
||||
derivative_name,
|
||||
process_public_image,
|
||||
)
|
||||
|
||||
|
||||
class MediaProcessingTests(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.media_root = tempfile.mkdtemp()
|
||||
self.addCleanup(lambda: shutil.rmtree(self.media_root, ignore_errors=True))
|
||||
self.override = override_settings(MEDIA_ROOT=self.media_root, MEDIA_URL="/media/")
|
||||
self.override.enable()
|
||||
self.addCleanup(self.override.disable)
|
||||
self.user = User.objects.create_user(
|
||||
username="media-user",
|
||||
email="media@example.com",
|
||||
password="TestPass123!",
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def _upload(self, name: str, *, mode: str = "RGB", fmt: str = "JPEG") -> SimpleUploadedFile:
|
||||
buffer = io.BytesIO()
|
||||
Image.new(mode, (2400, 1800), color=(20, 80, 200)).save(buffer, format=fmt)
|
||||
buffer.seek(0)
|
||||
return SimpleUploadedFile(name, buffer.read(), content_type=f"image/{fmt.lower()}")
|
||||
|
||||
def test_process_public_image_generates_derivatives_for_jpeg(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="JPEG image",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("photo.jpg", fmt="JPEG"),
|
||||
)
|
||||
|
||||
result = process_public_image(gallery.image, "gallery", force=True)
|
||||
|
||||
self.assertTrue(result.processed)
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT)))
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT)))
|
||||
|
||||
def test_process_public_image_generates_derivatives_for_png(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="PNG image",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("photo.png", mode="RGBA", fmt="PNG"),
|
||||
)
|
||||
|
||||
result = process_public_image(gallery.image, "gallery", force=True)
|
||||
|
||||
self.assertEqual(result.width, 2400)
|
||||
self.assertEqual(result.height, 1800)
|
||||
self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
|
||||
|
||||
def test_delete_image_derivatives_removes_sidecars(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="Delete derivatives",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("cleanup.jpg", fmt="JPEG"),
|
||||
)
|
||||
process_public_image(gallery.image, "gallery", force=True)
|
||||
|
||||
delete_image_derivatives(gallery.image, "gallery")
|
||||
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT)))
|
||||
|
||||
def test_replacing_image_removes_previous_sidecars(self):
|
||||
gallery = Gallery.objects.create(
|
||||
title="Replace derivatives",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("replace-first.jpg", fmt="JPEG"),
|
||||
)
|
||||
first_name = gallery.image.name
|
||||
|
||||
gallery.image = self._upload("replace-second.jpg", fmt="JPEG")
|
||||
gallery.save()
|
||||
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, THUMBNAIL_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, PREVIEW_VARIANT)))
|
||||
self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, BLUR_VARIANT)))
|
||||
|
||||
def test_backfill_command_is_idempotent(self):
|
||||
gallery = Gallery(title="Backfill image", uploaded_by=self.user)
|
||||
gallery._defer_image_processing = True
|
||||
gallery.image = self._upload("backfill.jpg", fmt="JPEG")
|
||||
gallery.save()
|
||||
|
||||
first = io.StringIO()
|
||||
second = io.StringIO()
|
||||
call_command("backfill_media_derivatives", stdout=first)
|
||||
call_command("backfill_media_derivatives", stdout=second)
|
||||
|
||||
self.assertIn("processed=1", first.getvalue())
|
||||
self.assertIn("skipped=1", second.getvalue())
|
||||
|
||||
def test_schema_resolvers_expose_derivative_urls(self):
|
||||
self.user.profile_picture.save("profile.jpg", self._upload("profile.jpg", fmt="JPEG"))
|
||||
|
||||
gallery = Gallery.objects.create(
|
||||
title="Schema image",
|
||||
uploaded_by=self.user,
|
||||
image=self._upload("schema-gallery.jpg", fmt="JPEG"),
|
||||
)
|
||||
event = Event.objects.create(
|
||||
title="Schema event",
|
||||
description="Event description",
|
||||
start_time=timezone.now(),
|
||||
end_time=timezone.now() + timedelta(hours=2),
|
||||
featured_image=self._upload("schema-event.jpg", fmt="JPEG"),
|
||||
)
|
||||
post = Post.objects.create(
|
||||
title="Schema post",
|
||||
content="Post body",
|
||||
author=self.user,
|
||||
featured_image=self._upload("schema-post.jpg", fmt="JPEG"),
|
||||
status=Post.StatusChoices.PUBLISHED,
|
||||
)
|
||||
|
||||
context = {"request": self.factory.get("/")}
|
||||
|
||||
self.assertIn(
|
||||
derivative_name(event.featured_image.name, THUMBNAIL_VARIANT),
|
||||
EventListSchema.resolve_absolute_featured_image_thumbnail_url(event, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(event.featured_image.name, PREVIEW_VARIANT),
|
||||
EventListSchema.resolve_absolute_featured_image_preview_url(event, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(gallery.image.name, PREVIEW_VARIANT),
|
||||
EventGallerySchema.resolve_absolute_image_preview_url(gallery, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(gallery.image.name, BLUR_VARIANT),
|
||||
GallerySchema.resolve_absolute_image_blur_url(gallery, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(post.featured_image.name, THUMBNAIL_VARIANT),
|
||||
PostListSchema.resolve_absolute_featured_image_thumbnail_url(post, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(self.user.profile_picture.name, THUMBNAIL_VARIANT),
|
||||
AuthorSchema.resolve_profile_picture_thumbnail_url(self.user, context),
|
||||
)
|
||||
self.assertIn(
|
||||
derivative_name(self.user.profile_picture.name, PREVIEW_VARIANT),
|
||||
UserProfileSchema.resolve_profile_picture_preview_url(self.user, context),
|
||||
)
|
||||
Reference in New Issue
Block a user