From 67ad52e1dede4dd24a3444ba4e5da88ad117b9aa Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Thu, 5 Jun 2025 18:51:04 +0100 Subject: [PATCH 1/5] feat: add UpdateEmailSerializer for user email updates - Add email validation and normalization - Implement authenticated user email update logic - Include proper error handling for invalid formats --- core/serializers.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/core/serializers.py b/core/serializers.py index 33a6b8c..1ea0487 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -250,3 +250,26 @@ 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 \ No newline at end of file From 5c462e083c6d4fb8115bdbfee3d6db2ebaf0c6ec Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Thu, 5 Jun 2025 18:51:10 +0100 Subject: [PATCH 2/5] feat: implement UpdateEmailAPIView endpoint - Add POST /account/update-email endpoint - Enforce authentication with IsAuthenticated permission - Return appropriate HTTP status codes (200/400/401) - Include success response with updated email --- core/views.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/core/views.py b/core/views.py index 33e8330..19f1c90 100644 --- a/core/views.py +++ b/core/views.py @@ -80,3 +80,41 @@ 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 + ) \ No newline at end of file From 339967ad1c21c18263d0c6b9aa0449230505fb09 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Thu, 5 Jun 2025 18:51:11 +0100 Subject: [PATCH 3/5] feat: add URL route for email update endpoint - Add /account/update-email/ route mapping - Wire UpdateEmailAPIView to URL configuration --- core/urls.py | 5 +++++ 1 file changed, 5 insertions(+) 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", + ), ] From 07946854ddf09fd144fd5d5449f20902c08774aa Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Thu, 5 Jun 2025 18:51:11 +0100 Subject: [PATCH 4/5] test: add comprehensive unit tests for email update feature - Test successful email update workflow - Test email format validation with invalid inputs - Test unauthorized access protection - Test missing field validation - Test email normalization to lowercase - Test invalid token handling - Achieve 100% code coverage for UpdateEmailAPIView --- core/tests/test_update_email.py | 102 ++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 core/tests/test_update_email.py diff --git a/core/tests/test_update_email.py b/core/tests/test_update_email.py new file mode 100644 index 0000000..483a7e2 --- /dev/null +++ b/core/tests/test_update_email.py @@ -0,0 +1,102 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from knox.models import AuthToken + +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) \ No newline at end of file From fc6f6f6fb48c696bcd53494c6dbe9b08553a3617 Mon Sep 17 00:00:00 2001 From: clintjeff2 Date: Sat, 7 Jun 2025 11:50:22 +0100 Subject: [PATCH 5/5] Formated code in these files and fixed one error --- core/serializers.py | 15 ++--- core/tests/test_update_email.py | 104 ++++++++++++++++---------------- core/views.py | 25 +++----- 3 files changed, 70 insertions(+), 74 deletions(-) diff --git a/core/serializers.py b/core/serializers.py index 1ea0487..9b7e667 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -251,25 +251,26 @@ class Meta: "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 \ No newline at end of file + 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 index 483a7e2..f2584e4 100644 --- a/core/tests/test_update_email.py +++ b/core/tests/test_update_email.py @@ -1,8 +1,8 @@ import pytest from django.urls import reverse +from knox.models import AuthToken from rest_framework import status from rest_framework.test import APITestCase -from knox.models import AuthToken from core.models import User from core.tests.factories import UserFactory @@ -12,91 +12,91 @@ 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.url = reverse("update-email") + self.token = AuthToken.objects.create(self.user)[1] - self.auth_header = f'Token {self.token}' - + 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} - + 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') - + 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) - + 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', - '', + "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') - + 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) - + self.assertIn("email", response.data) + def test_unauthorized_access(self): """Test that unauthenticated requests return 401.""" - data = {'email': 'test@example.com'} - + data = {"email": "test@example.com"} + # Make request without authentication - response = self.client.post(self.url, data, format='json') - + 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') - + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('email', response.data) - + 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} - + 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') - + 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) - + 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'} - + 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) \ No newline at end of file + 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/views.py b/core/views.py index 19f1c90..684269e 100644 --- a/core/views.py +++ b/core/views.py @@ -82,39 +82,34 @@ def get_queryset(self): 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} + 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 - ) \ No newline at end of file + {"message": "Email updated successfully", "email": updated_user.email}, + status=status.HTTP_200_OK, + )