from django.contrib.auth import get_user_model from django.core.cache import cache from django.core.exceptions import ValidationError from django.test import TestCase, override_settings from rest_framework.test import APIClient from apps.accounts.models import OTPCode, Role, TeacherProfile from apps.accounts.services.otp import ( OTPCooldownError, OTPVerificationError, send_otp, verify_otp, ) from apps.accounts.validators import normalize_iran_mobile, validate_iran_mobile User = get_user_model() CONSOLE_SMS = "apps.accounts.services.sms.ConsoleSMSBackend" class PhoneNormalizationTests(TestCase): def test_normalizes_e164(self): self.assertEqual(normalize_iran_mobile("+989121234567"), "09121234567") def test_normalizes_0098_prefix(self): self.assertEqual(normalize_iran_mobile("00989121234567"), "09121234567") def test_normalizes_98_prefix(self): self.assertEqual(normalize_iran_mobile("989121234567"), "09121234567") def test_normalizes_no_leading_zero(self): self.assertEqual(normalize_iran_mobile("9121234567"), "09121234567") def test_keeps_canonical_form(self): self.assertEqual(normalize_iran_mobile("09121234567"), "09121234567") def test_strips_separators(self): self.assertEqual(normalize_iran_mobile("0912 123 4567"), "09121234567") def test_validates_canonical(self): validate_iran_mobile("09121234567") def test_rejects_too_short(self): with self.assertRaises(ValidationError): validate_iran_mobile("0912123456") def test_rejects_non_iran(self): with self.assertRaises(ValidationError): validate_iran_mobile("+12025551234") @override_settings(SMS_BACKEND=CONSOLE_SMS) class OTPServiceTests(TestCase): def setUp(self): cache.clear() def test_send_otp_creates_code(self): otp = send_otp("09121234567") self.assertEqual(otp.phone_number, "09121234567") self.assertEqual(len(otp.code), 4) self.assertFalse(otp.is_used) def test_send_otp_within_cooldown_raises(self): send_otp("09121234567") with self.assertRaises(OTPCooldownError): send_otp("09121234567") def test_verify_correct_code_consumes(self): otp = send_otp("09121234567") verified = verify_otp("09121234567", otp.code) self.assertEqual(verified.pk, otp.pk) otp.refresh_from_db() self.assertTrue(otp.is_used) def test_verify_wrong_code_increments_attempts(self): send_otp("09121234567") with self.assertRaises(OTPVerificationError): verify_otp("09121234567", "0000") latest = OTPCode.objects.filter(phone_number="09121234567").first() self.assertEqual(latest.attempts, 1) self.assertFalse(latest.is_used) def test_verify_with_no_active_code_raises(self): with self.assertRaises(OTPVerificationError): verify_otp("09121234567", "1234") def test_issuing_new_otp_invalidates_previous(self): first = send_otp("09121234567") cache.clear() second = send_otp("09121234567") first.refresh_from_db() self.assertTrue(first.is_used) self.assertFalse(second.is_used) self.assertNotEqual(first.pk, second.pk) @override_settings(SMS_BACKEND=CONSOLE_SMS) class AuthAPITests(TestCase): def setUp(self): cache.clear() self.client = APIClient() def _send(self, phone): return self.client.post("/api/auth/otp/send/", {"phone_number": phone}, format="json") def _latest_otp(self, phone): return OTPCode.objects.get(phone_number=phone, is_used=False) def test_signup_flow_creates_teacher_with_profile(self): r = self._send("09121234567") self.assertEqual(r.status_code, 200, r.content) otp = self._latest_otp("09121234567") r = self.client.post( "/api/auth/otp/verify/", { "phone_number": "09121234567", "code": otp.code, "full_name": "New Teacher", "role": "teacher", }, format="json", ) self.assertEqual(r.status_code, 200, r.content) body = r.json() self.assertTrue(body["created"]) self.assertEqual(body["user"]["role"], "teacher") self.assertIn("access", body["tokens"]) self.assertIn("refresh", body["tokens"]) u = User.objects.get(phone_number="09121234567") self.assertTrue(TeacherProfile.objects.filter(user=u).exists()) self.assertFalse(u.teacher_profile.is_approved) def test_signup_flow_creates_student_no_profile(self): self._send("09121234567") otp = self._latest_otp("09121234567") r = self.client.post( "/api/auth/otp/verify/", { "phone_number": "09121234567", "code": otp.code, "full_name": "New Student", "role": "student", }, format="json", ) self.assertEqual(r.status_code, 200, r.content) u = User.objects.get(phone_number="09121234567") self.assertEqual(u.role, Role.STUDENT) self.assertFalse(TeacherProfile.objects.filter(user=u).exists()) def test_existing_user_login_does_not_require_signup_fields(self): u = User.objects.create_user( phone_number="09121234567", full_name="Existing", role=Role.STUDENT ) self._send("09121234567") otp = self._latest_otp("09121234567") r = self.client.post( "/api/auth/otp/verify/", {"phone_number": "09121234567", "code": otp.code}, format="json", ) self.assertEqual(r.status_code, 200, r.content) body = r.json() self.assertFalse(body["created"]) self.assertEqual(body["user"]["id"], u.id) def test_new_user_without_signup_fields_rejected(self): self._send("09121234567") otp = self._latest_otp("09121234567") r = self.client.post( "/api/auth/otp/verify/", {"phone_number": "09121234567", "code": otp.code}, format="json", ) self.assertEqual(r.status_code, 400) body = r.json() self.assertIn("full_name", body) self.assertIn("role", body) def test_verify_wrong_code_400(self): self._send("09121234567") r = self.client.post( "/api/auth/otp/verify/", { "phone_number": "09121234567", "code": "0000", "full_name": "X", "role": "student", }, format="json", ) self.assertEqual(r.status_code, 400) def test_phone_normalization_in_send(self): r = self.client.post( "/api/auth/otp/send/", {"phone_number": "+989121234567"}, format="json", ) self.assertEqual(r.status_code, 200) self.assertEqual(r.json()["phone_number"], "09121234567") self.assertTrue(OTPCode.objects.filter(phone_number="09121234567").exists()) def test_invalid_phone_400(self): r = self.client.post( "/api/auth/otp/send/", {"phone_number": "12345"}, format="json" ) self.assertEqual(r.status_code, 400) def test_cooldown_429(self): self._send("09121234567") r = self._send("09121234567") self.assertEqual(r.status_code, 429) self.assertIn("retry_after", r.json()) def test_me_requires_auth(self): r = self.client.get("/api/auth/me/") self.assertEqual(r.status_code, 401) def test_me_with_bearer_token(self): User.objects.create_user( phone_number="09121234567", full_name="X", role=Role.STUDENT ) self._send("09121234567") otp = self._latest_otp("09121234567") r = self.client.post( "/api/auth/otp/verify/", {"phone_number": "09121234567", "code": otp.code}, format="json", ) access = r.json()["tokens"]["access"] self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") r2 = self.client.get("/api/auth/me/") self.assertEqual(r2.status_code, 200) self.assertEqual(r2.json()["phone_number"], "09121234567")