diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..5ceb386 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +venv diff --git a/backend/apps/accounts/tests/test_phone_auth_request_contract.py b/backend/apps/accounts/tests/test_phone_auth_request_contract.py new file mode 100644 index 0000000..0d5e21d --- /dev/null +++ b/backend/apps/accounts/tests/test_phone_auth_request_contract.py @@ -0,0 +1,166 @@ +from unittest.mock import patch + +import pytest +from django.test import override_settings +from django.urls import reverse + +from apps.accounts.models import OtpPurpose, PhoneOTP, User + + +@pytest.mark.django_db +@override_settings(OTP_PROVIDER="console") +def test_phone_auth_request_creates_customer_for_new_phone(client): + with patch("apps.accounts.services.otp.generate_code", return_value="123456"): + response = client.post( + reverse("phone_auth_request"), + { + "phone_number": "0512345678", + "channel": "sms", + "first_name": "Sara", + "last_name": "Ali", + "email": "sara@example.com", + }, + content_type="application/json", + ) + + assert response.status_code == 201 + data = response.json() + assert "request_id" in data + assert "expires_at" in data + + user = User.objects.get(phone_number="+966512345678") + assert user.role == "customer" + assert user.is_phone_verified is False + + otp = PhoneOTP.objects.get(id=data["request_id"]) + assert otp.phone_number == "+966512345678" + assert otp.purpose == OtpPurpose.AUTH + + +@pytest.mark.django_db +@override_settings(OTP_PROVIDER="console") +def test_phone_auth_request_existing_phone_no_duplicate_user(client): + User.objects.create_user( + phone_number="+966512345678", + email="existing@example.com", + first_name="Existing", + ) + + before_count = User.objects.filter(phone_number="+966512345678").count() + + with patch("apps.accounts.services.otp.generate_code", return_value="123456"): + response = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345678", "channel": "sms"}, + content_type="application/json", + ) + + assert response.status_code == 201 + assert User.objects.filter(phone_number="+966512345678").count() == before_count + assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1 + + +@pytest.mark.django_db +@override_settings(OTP_PROVIDER="console") +def test_phone_auth_request_rejects_email_already_used(client): + User.objects.create_user( + phone_number="+966500000001", + email="taken@example.com", + ) + + response = client.post( + reverse("phone_auth_request"), + { + "phone_number": "0512345678", + "channel": "sms", + "email": "taken@example.com", + }, + content_type="application/json", + ) + + assert response.status_code == 400 + assert "detail" in response.json() + assert User.objects.filter(phone_number="+966512345678").count() == 0 + assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 0 + + +@pytest.mark.django_db +def test_phone_auth_request_invalid_phone_localized_en(client): + response = client.post( + reverse("phone_auth_request"), + {"phone_number": "123", "channel": "sms"}, + content_type="application/json", + HTTP_ACCEPT_LANGUAGE="en", + ) + + assert response.status_code == 400 + assert response.json()["phone_number"][0] == "Phone number must be in E.164 format or a valid Saudi mobile" + + +@pytest.mark.django_db +def test_phone_auth_request_invalid_phone_localized_ar(client): + response = client.post( + reverse("phone_auth_request"), + {"phone_number": "123", "channel": "sms"}, + content_type="application/json", + HTTP_ACCEPT_LANGUAGE="ar-sa", + ) + + assert response.status_code == 400 + assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح" + + +@pytest.mark.django_db +@override_settings( + OTP_PROVIDER="console", + OTP_MAX_PER_WINDOW=5, + OTP_WINDOW_MINUTES=15, + OTP_RESEND_COOLDOWN_SECONDS=60, +) +def test_phone_auth_request_cooldown_returns_retry_after(client): + with patch("apps.accounts.services.otp.generate_code", return_value="123456"): + first = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345678", "channel": "sms"}, + content_type="application/json", + ) + assert first.status_code == 201 + + second = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345678", "channel": "sms"}, + content_type="application/json", + ) + + assert second.status_code == 429 + data = second.json() + assert "detail" in data + assert data["retry_after_seconds"] > 0 + + +@pytest.mark.django_db +@override_settings( + OTP_PROVIDER="console", + OTP_MAX_PER_WINDOW=1, + OTP_WINDOW_MINUTES=15, + OTP_RESEND_COOLDOWN_SECONDS=0, +) +def test_phone_auth_request_rate_limit_returns_retry_after(client): + with patch("apps.accounts.services.otp.generate_code", return_value="123456"): + first = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345678", "channel": "sms"}, + content_type="application/json", + ) + assert first.status_code == 201 + + second = client.post( + reverse("phone_auth_request"), + {"phone_number": "0512345678", "channel": "sms"}, + content_type="application/json", + ) + + assert second.status_code == 429 + data = second.json() + assert "detail" in data + assert data["retry_after_seconds"] > 0