diff --git a/core/serializers.py b/core/serializers.py index 33a6b8c..9b7e667 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -250,3 +250,27 @@ class Meta: "created_at", "status", ] + + +class UpdateEmailSerializer(serializers.Serializer): + """ + Serializer for updating user email address. + """ + + email = serializers.EmailField() + + def validate_email(self, value): + """ + Validate that the email is properly formatted. + """ + + return value.lower() + + def save(self): + """ + Update the authenticated user's email address. + """ + user = self.context["user"] + user.email = self.validated_data["email"] + user.save(update_fields=["email"]) + return user diff --git a/core/tests/test_update_email.py b/core/tests/test_update_email.py new file mode 100644 index 0000000..f2584e4 --- /dev/null +++ b/core/tests/test_update_email.py @@ -0,0 +1,102 @@ +import pytest +from django.urls import reverse +from knox.models import AuthToken +from rest_framework import status +from rest_framework.test import APITestCase + +from core.models import User +from core.tests.factories import UserFactory + + +class UpdateEmailAPIViewTest(APITestCase): + """ + Test cases for the UpdateEmailAPIView endpoint. + """ + + def setUp(self): + """Set up test data.""" + self.user = UserFactory() + self.url = reverse("update-email") + + self.token = AuthToken.objects.create(self.user)[1] + self.auth_header = f"Token {self.token}" + + def test_successful_email_update(self): + """Test successful email update for authenticated user.""" + new_email = "newemail@example.com" + data = {"email": new_email} + + self.client.credentials(HTTP_AUTHORIZATION=self.auth_header) + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Email updated successfully") + self.assertEqual(response.data["email"], new_email) + + # Verify email was updated in database + self.user.refresh_from_db() + self.assertEqual(self.user.email, new_email) + + def test_invalid_email_format(self): + """Test validation failure for invalid email format.""" + invalid_emails = [ + "invalid-email", + "invalid@", + "@example.com", + "invalid..email@example.com", + "", + ] + + self.client.credentials(HTTP_AUTHORIZATION=self.auth_header) + + for invalid_email in invalid_emails: + data = {"email": invalid_email} + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("email", response.data) + + def test_unauthorized_access(self): + """Test that unauthenticated requests return 401.""" + data = {"email": "test@example.com"} + + # Make request without authentication + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_missing_email_field(self): + """Test validation failure when email field is missing.""" + data = {} # No email field + + self.client.credentials(HTTP_AUTHORIZATION=self.auth_header) + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("email", response.data) + + def test_email_normalization(self): + """Test that email is normalized to lowercase.""" + mixed_case_email = "Test.Email@EXAMPLE.COM" + expected_email = "test.email@example.com" + data = {"email": mixed_case_email} + + self.client.credentials(HTTP_AUTHORIZATION=self.auth_header) + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["email"], expected_email) + + # Verify email was normalized in database + self.user.refresh_from_db() + self.assertEqual(self.user.email, expected_email) + + def test_invalid_token(self): + """Test that invalid tokens return 401.""" + data = {"email": "test@example.com"} + + # Use invalid token + self.client.credentials(HTTP_AUTHORIZATION="Token invalid-token") + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/core/urls.py b/core/urls.py index 91e6f69..ed00553 100644 --- a/core/urls.py +++ b/core/urls.py @@ -25,4 +25,9 @@ ), path("offer/create/", views.OfferCreateAPIView.as_view(), name="create-offer"), path("offer/cancel/", views.OfferCancelAPIView.as_view(), name="cancel-offer"), + path( + "account/update-email/", + views.UpdateEmailAPIView.as_view(), + name="update-email", + ), ] diff --git a/core/views.py b/core/views.py index 33e8330..684269e 100644 --- a/core/views.py +++ b/core/views.py @@ -80,3 +80,36 @@ def get_queryset(self): queryset = queryset.filter(user__public_key__iexact=borrower_address) return queryset + + +class UpdateEmailAPIView(GenericAPIView): + """ + API endpoint for authenticated users to update their email address. + + Requires authentication via Knox token. + Validates email format and updates the user's email in the database. + """ + + serializer_class = serializers.UpdateEmailSerializer + permission_classes = [IsAuthenticated] + + def post(self, request): + """ + Update authenticated user's email address. + + Returns: + 200 OK: Email updated successfully + 400 Bad Request: Invalid email format + 401 Unauthorized: User not authenticated + """ + serializer = self.serializer_class( + data=request.data, context={"user": request.user} + ) + serializer.is_valid(raise_exception=True) + + updated_user = serializer.save() + + return Response( + {"message": "Email updated successfully", "email": updated_user.email}, + status=status.HTTP_200_OK, + )