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:
40
apps/users/tests/unit/test_auth_service.py
Normal file
40
apps/users/tests/unit/test_auth_service.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
|
||||
from apps.users.services import auth
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_otp_normalizes_persian_digits_and_rejects_wrong_length():
|
||||
assert auth._normalize_otp_code(" ۱۲۳۴۵ ") == "12345"
|
||||
with pytest.raises(auth.AuthServiceError) as error:
|
||||
auth._normalize_otp_code("1234")
|
||||
assert error.value.field == "code"
|
||||
|
||||
|
||||
def test_otp_is_single_use(fake_redis, monkeypatch):
|
||||
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
|
||||
fake_redis.set(auth._otp_key("login", "09190000001"), "12345")
|
||||
auth.verify_otp_code(mobile="09190000001", code="12345", mode="login")
|
||||
assert fake_redis.get(auth._otp_key("login", "09190000001")) is None
|
||||
with pytest.raises(auth.AuthServiceError):
|
||||
auth.verify_otp_code(mobile="09190000001", code="12345", mode="login")
|
||||
|
||||
|
||||
def test_otp_issue_stores_code_and_queues_sms(fake_redis, monkeypatch, mocker):
|
||||
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
|
||||
send_sms = mocker.patch("apps.users.services.auth.send_critical_sms.delay")
|
||||
result = auth._issue_otp("09190000001", "register")
|
||||
stored = fake_redis.get(auth._otp_key("register", "09190000001"))
|
||||
assert stored and len(stored) == 5
|
||||
send_sms.assert_called_once_with("09190000001", auth.SMS_KIND_REGISTER, stored.decode())
|
||||
assert result["expires_in_seconds"] == auth.OTP_EXPIRY_SECONDS
|
||||
|
||||
|
||||
def test_verified_otp_is_consumed_once(fake_redis, monkeypatch):
|
||||
monkeypatch.setattr(auth, "_redis", lambda: fake_redis)
|
||||
key = auth._verified_otp_key("register", "09190000001")
|
||||
fake_redis.set(key, "1")
|
||||
assert auth._consume_verified_otp("09190000001", "register")
|
||||
assert not auth._consume_verified_otp("09190000001", "register")
|
||||
50
apps/users/tests/unit/test_google_oauth.py
Normal file
50
apps/users/tests/unit/test_google_oauth.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from apps.users.services import google_oauth as google
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_google_state_and_flow_are_single_use_or_expire():
|
||||
state = google.create_google_state()
|
||||
google.consume_google_state(state)
|
||||
with pytest.raises(google.GoogleOAuthFlowError):
|
||||
google.consume_google_state(state)
|
||||
flow = google.create_google_flow({"status": "collect_profile", "email": "person@example.com", "google_profile": {"secret": True}})
|
||||
assert google.get_google_flow(flow) == {"status": "collect_profile", "email": "person@example.com"}
|
||||
google.update_google_flow(flow, {"status": "authenticated", "access_token": "token"})
|
||||
assert google.get_google_flow(flow)["access_token"] == "token"
|
||||
with pytest.raises(google.GoogleOAuthFlowError):
|
||||
google.get_google_flow("unknown")
|
||||
|
||||
|
||||
def test_google_authorization_url_requires_configured_client(settings):
|
||||
settings.GOOGLE_OAUTH_CLIENT_ID = "client-id"
|
||||
settings.GOOGLE_OAUTH_REDIRECT_URI = "https://example.test/callback"
|
||||
url = google.build_google_authorization_url()
|
||||
assert "client_id=client-id" in url
|
||||
assert "state=" in url
|
||||
|
||||
|
||||
def test_google_profile_exchange_checks_verified_email(settings, mocker):
|
||||
settings.GOOGLE_OAUTH_CLIENT_ID = "client-id"
|
||||
settings.GOOGLE_OAUTH_CLIENT_SECRET = "secret"
|
||||
settings.GOOGLE_OAUTH_REDIRECT_URI = "https://example.test/callback"
|
||||
token = mocker.patch("apps.users.services.google_oauth.requests.post")
|
||||
token.return_value.json.return_value = {"access_token": "access"}
|
||||
profile = mocker.patch("apps.users.services.google_oauth.requests.get")
|
||||
profile.return_value.json.return_value = {"sub": "google-1", "email": " PERSON@EXAMPLE.COM ", "email_verified": True}
|
||||
result = google.exchange_code_for_google_profile("code")
|
||||
assert result.provider_user_id == "google-1"
|
||||
assert result.email == "person@example.com"
|
||||
profile.return_value.json.return_value["email_verified"] = False
|
||||
with pytest.raises(google.GoogleOAuthFlowError):
|
||||
google.exchange_code_for_google_profile("code")
|
||||
|
||||
|
||||
def test_google_avatar_extension_uses_safe_suffix():
|
||||
profile = google.GoogleProfile("id", "person@example.com", True, "", "", "https://example.test/a.png?size=1")
|
||||
assert google._avatar_file_extension(profile) == "png"
|
||||
profile.avatar_url = "https://example.test/a.exe"
|
||||
assert google._avatar_file_extension(profile) == "jpg"
|
||||
42
apps/users/tests/unit/test_identity.py
Normal file
42
apps/users/tests/unit/test_identity.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
|
||||
from apps.users.email_identity import (
|
||||
is_placeholder_email,
|
||||
is_valid_mobile_number,
|
||||
mask_mobile,
|
||||
normalize_email_identity,
|
||||
normalize_mobile_number,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[(None, None), (" ", None), (" Alice@EXAMPLE.COM ", "alice@example.com")],
|
||||
)
|
||||
def test_email_normalization(raw, expected):
|
||||
assert normalize_email_identity(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "normalized", "valid"),
|
||||
[
|
||||
(None, None, False),
|
||||
("+98 (0919) 000-0001", "9809190000001", False),
|
||||
("0919-000-0001", "09190000001", True),
|
||||
("123", "123", False),
|
||||
],
|
||||
)
|
||||
def test_mobile_normalization_and_validation(raw, normalized, valid):
|
||||
assert normalize_mobile_number(raw) == normalized
|
||||
assert is_valid_mobile_number(raw) is valid
|
||||
|
||||
|
||||
def test_masking_and_placeholder_identity():
|
||||
assert mask_mobile(None) is None
|
||||
assert mask_mobile("1234") == "1234"
|
||||
assert mask_mobile("09190000001") == "09*****0001"
|
||||
assert is_placeholder_email(" LEGACY@NOEMAIL.LOCAL ")
|
||||
assert not is_placeholder_email("person@example.com")
|
||||
10
apps/users/tests/unit/test_resources.py
Normal file
10
apps/users/tests/unit/test_resources.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import pytest
|
||||
from apps.users.models import User
|
||||
from apps.users.resources import UserResource
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUserResourceTests:
|
||||
|
||||
def test_email_identity_flag_remains_exportable(self):
|
||||
resource = UserResource()
|
||||
assert 'is_email_verified' in resource.fields
|
||||
18
apps/users/tests/unit/test_signals.py
Normal file
18
apps/users/tests/unit/test_signals.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.users.signals import ensure_username_on_registration
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_missing_username_is_generated_only_for_new_account(mocker):
|
||||
user = SimpleNamespace(username="", save=mocker.Mock())
|
||||
ensure_username_on_registration(None, user, created=True)
|
||||
assert len(user.username) == 10
|
||||
user.save.assert_called_once_with(update_fields=["username"])
|
||||
user.save.reset_mock()
|
||||
ensure_username_on_registration(None, user, created=False)
|
||||
user.save.assert_not_called()
|
||||
27
apps/users/tests/unit/test_sms_tasks.py
Normal file
27
apps/users/tests/unit/test_sms_tasks.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import pytest
|
||||
|
||||
from apps.users.tasks import send_critical_sms
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_sms_skips_when_credentials_are_missing(settings, mocker):
|
||||
settings.SMS_APIKEY = ""
|
||||
settings.SMS_AUTH_OTP_TEMPLATE_ID = ""
|
||||
post = mocker.patch("apps.users.tasks.requests.post")
|
||||
result = send_critical_sms.run("09190000001", "auth_register_otp", "12345")
|
||||
assert result["sent"] is False
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
def test_sms_uses_otp_template_and_message_template(settings, mocker):
|
||||
settings.SMS_APIKEY = "test-key"
|
||||
settings.SMS_AUTH_OTP_TEMPLATE_ID = "12"
|
||||
settings.SMS_EVENT_CANCELLATION_TEMPLATE_ID = "13"
|
||||
post = mocker.patch("apps.users.tasks.requests.post")
|
||||
assert send_critical_sms.run("09190000001", "auth_register_otp", "12345")["sent"]
|
||||
assert post.call_args.kwargs["json"]["parameters"] == [{"name": "OTP", "value": "12345"}]
|
||||
assert send_critical_sms.run("09190000001", "event_cancellation", "Cancelled")["sent"]
|
||||
assert post.call_args.kwargs["json"]["parameters"] == [{"name": "MESSAGE", "value": "Cancelled"}]
|
||||
assert post.call_args.kwargs["json"]["templateId"] == 13
|
||||
@@ -1,30 +0,0 @@
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.users.resources import UserResource
|
||||
|
||||
|
||||
class UserIdentityTests(TestCase):
|
||||
def test_email_is_optional_and_flags_follow_identity(self):
|
||||
user = User.objects.create_user(username="mobile_only", mobile="09191110000", password="password")
|
||||
self.assertIsNone(user.email)
|
||||
self.assertFalse(user.is_email_verified)
|
||||
user.email = " PERSON@EXAMPLE.COM "
|
||||
user.save(update_fields=["email"])
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.email, "person@example.com")
|
||||
self.assertTrue(user.is_email_verified)
|
||||
user.email = None
|
||||
user.save(update_fields=["email"])
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_email_verified)
|
||||
|
||||
def test_display_name_prefers_mobile_when_email_absent(self):
|
||||
user = User.objects.create_user(username="mobile_only", mobile="09191110001", password="password")
|
||||
self.assertIn(user.mobile, str(user))
|
||||
|
||||
|
||||
class UserResourceTests(SimpleTestCase):
|
||||
def test_email_identity_flag_remains_exportable(self):
|
||||
resource = UserResource()
|
||||
self.assertIn("is_email_verified", resource.fields)
|
||||
Reference in New Issue
Block a user