test: migrate backend suites to pytest with module coverage gate

Split backend tests into unit and integration suites, add isolated fixtures and coverage enforcement, and update CI and test documentation. Cover current authentication, content, notification, payment, media, and admin flows; fix behaviors revealed by the new tests. Validate both suites and the per-module gate against local PostgreSQL.
This commit is contained in:
2026-09-26 16:50:29 +03:30
parent 3ec28638d1
commit ff99130938
76 changed files with 3271 additions and 1357 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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