From 2899373018ac643e62bff5345fe78755a4d37ad0 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 1 May 2025 22:08:32 +0300 Subject: [PATCH 1/2] test(user): Add unit tests for user profile - Cover profile retrieval (GET) and detailed field assertions - Validate error responses for empty names, invalid avatar URLs, and weak passwords - Test full CRUD flow: update name, avatar URL, password change with persistence checks - Ensure legacy JWT remains valid after password update - Verify authentication rejects old password and accepts new password --- promo_code/user/tests/auth/base.py | 1 + promo_code/user/tests/user/__init__.py | 0 .../user/tests/user/operations/__init__.py | 0 .../tests/user/operations/test_profile.py | 126 ++++++++++++++++++ .../user/tests/user/validations/__init__.py | 0 .../validations/test_profile_validation.py | 81 +++++++++++ 6 files changed, 208 insertions(+) create mode 100644 promo_code/user/tests/user/__init__.py create mode 100644 promo_code/user/tests/user/operations/__init__.py create mode 100644 promo_code/user/tests/user/operations/test_profile.py create mode 100644 promo_code/user/tests/user/validations/__init__.py create mode 100644 promo_code/user/tests/user/validations/test_profile_validation.py diff --git a/promo_code/user/tests/auth/base.py b/promo_code/user/tests/auth/base.py index 66c49ed..9404410 100644 --- a/promo_code/user/tests/auth/base.py +++ b/promo_code/user/tests/auth/base.py @@ -14,6 +14,7 @@ def setUpTestData(cls): cls.refresh_url = django.urls.reverse('api-user:user-token-refresh') cls.signup_url = django.urls.reverse('api-user:sign-up') cls.signin_url = django.urls.reverse('api-user:sign-in') + cls.user_profile_url = django.urls.reverse('api-user:user-profile') def tearDown(self): user.models.User.objects.all().delete() diff --git a/promo_code/user/tests/user/__init__.py b/promo_code/user/tests/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/promo_code/user/tests/user/operations/__init__.py b/promo_code/user/tests/user/operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/promo_code/user/tests/user/operations/test_profile.py b/promo_code/user/tests/user/operations/test_profile.py new file mode 100644 index 0000000..3d6e365 --- /dev/null +++ b/promo_code/user/tests/user/operations/test_profile.py @@ -0,0 +1,126 @@ +import rest_framework.status + +import user.tests.auth.base + + +class TestUserProfile(user.tests.auth.base.BaseUserAuthTestCase): + def setUp(self): + super().setUp() + signup_data = { + 'name': 'Steve', + 'surname': 'Wozniak', + 'email': 'creator@apple.com', + 'password': 'WhoLivesInCalifornia2000!', + 'other': {'age': 23, 'country': 'us'}, + } + response = self.client.post( + self.signup_url, signup_data, format='json', + ) + token = response.data.get('access') + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + token) + self.initial_token = token + + def test_get_profile_initial(self): + response = self.client.get(self.user_profile_url, format='json') + self.assertEqual( + response.status_code, rest_framework.status.HTTP_200_OK, + ) + expected = { + 'name': 'Steve', + 'surname': 'Wozniak', + 'email': 'creator@apple.com', + 'other': {'age': 23, 'country': 'us'}, + } + self.assertEqual(response.json(), expected) + + def test_patch_profile_update_name_and_surname(self): + payload = {'name': 'John', 'surname': 'Tsal'} + response = self.client.patch( + self.user_profile_url, payload, format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_200_OK, + ) + self.assertEqual(response.data.get('name'), 'John') + self.assertEqual(response.data.get('surname'), 'Tsal') + + def test_patch_profile_update_avatar_url(self): + payload = {'avatar_url': 'http://nodomain.com/kitten.jpeg'} + response = self.client.patch( + self.user_profile_url, payload, format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_200_OK, + ) + self.assertEqual( + response.data.get('avatar_url'), 'http://nodomain.com/kitten.jpeg', + ) + + def test_patch_password_and_check_persistence(self): + new_password = 'MegaGiant88888@dooRuveS' + self.client.patch( + self.user_profile_url, + {'name': 'John', 'surname': 'Tsal'}, + format='json', + ) + self.client.patch( + self.user_profile_url, + {'avatar_url': 'http://nodomain.com/kitten.jpeg'}, + format='json', + ) + response = self.client.patch( + self.user_profile_url, {'password': new_password}, format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_200_OK, + ) + data = response.data + self.assertEqual(data.get('name'), 'John') + self.assertEqual(data.get('surname'), 'Tsal') + self.assertEqual(data.get('email'), 'creator@apple.com') + self.assertEqual(data.get('other'), {'age': 23, 'country': 'us'}) + self.assertEqual( + data.get('avatar_url'), 'http://nodomain.com/kitten.jpeg', + ) + + # test old token still valid + response = self.client.get(self.user_profile_url, format='json') + self.assertEqual( + response.status_code, rest_framework.status.HTTP_200_OK, + ) + + def test_auth_sign_in_old_password_fails(self): + new_password = 'MegaGiant88888@dooRuveS' + response = self.client.patch( + self.user_profile_url, {'password': new_password}, format='json', + ) + self.client.credentials() + response = self.client.post( + self.signin_url, + { + 'email': 'creator@apple.com', + 'password': 'WhoLivesInCalifornia2000!', + }, + format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_401_UNAUTHORIZED, + ) + + def test_auth_sign_in_new_password_succeeds(self): + new_password = 'MegaGiant88888@dooRuveS' + response = self.client.patch( + self.user_profile_url, {'password': new_password}, format='json', + ) + self.client.credentials() + response = self.client.post( + self.signin_url, + { + 'email': 'creator@apple.com', + 'password': 'MegaGiant88888@dooRuveS', + }, + format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_200_OK, + ) diff --git a/promo_code/user/tests/user/validations/__init__.py b/promo_code/user/tests/user/validations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/promo_code/user/tests/user/validations/test_profile_validation.py b/promo_code/user/tests/user/validations/test_profile_validation.py new file mode 100644 index 0000000..d3881a8 --- /dev/null +++ b/promo_code/user/tests/user/validations/test_profile_validation.py @@ -0,0 +1,81 @@ +import parameterized +import rest_framework.status + +import user.tests.auth.base + + +class ProfileAPITestCase(user.tests.auth.base.BaseUserAuthTestCase): + def setUp(self): + super().setUp() + signup_data = { + 'name': 'Jack', + 'surname': 'Sparrow', + 'email': 'sparrow@movie.com', + 'password': 'WhoLivesInTheOcean100500!', + 'other': {'age': 48, 'country': 'gb'}, + } + response = self.client.post( + self.signup_url, signup_data, format='json', + ) + token = response.data.get('access') + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + token) + + def test_update_profile_empty_name_and_surname(self): + payload = {'name': '', 'surname': ''} + response = self.client.patch( + self.user_profile_url, payload, format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + @parameterized.parameterized.expand( + [ + ('no_scheme', 'notURLcom'), + ('only_scheme', 'https://'), + ('no_domain', 'https://.com'), + ], + ) + def test_update_profile_invalid_avatar_url(self, name, url): + payload = {'avatar_url': url} + response = self.client.patch( + self.user_profile_url, payload, format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + @parameterized.parameterized.expand( + [ + ('simple_alpha_num', 'pro100'), + ('only_symbols', '!!!!!'), + ('only_nums', '1234567890'), + ('only_lowercase_chars', 'abcdefghijklmno'), + ('only_uppercase-chars', 'ABCDEFGHIJKLMNO'), + ('only_symbols_and_nums', '1234567890!@#$%^&*()_+{}|:"<>?'), + ('repetitive_chars', 'onlyYOUOOOO!'), + ('mixed_short', 'yOu!@1'), + ('repeating_pattern', '11111@@@@@aaaaa'), + ], + ) + def test_update_profile_weak_password(self, name, pwd): + payload = {'password': pwd} + response = self.client.patch( + self.user_profile_url, payload, format='json', + ) + self.assertEqual( + response.status_code, rest_framework.status.HTTP_400_BAD_REQUEST, + ) + + def test_get_profile(self): + response = self.client.get(self.user_profile_url, format='json') + self.assertEqual( + response.status_code, rest_framework.status.HTTP_200_OK, + ) + expected = { + 'name': 'Jack', + 'surname': 'Sparrow', + 'email': 'sparrow@movie.com', + 'other': {'age': 48, 'country': 'gb'}, + } + self.assertEqual(response.json(), expected) From 02a09471ad3e00d58c4ad586ba8de56ca3abcb6e Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 1 May 2025 22:32:20 +0300 Subject: [PATCH 2/2] feat(user): switch primary key to UUID BREAKING CHANGE: changes User.id from integer to UUID. --- promo_code/user/migrations/0001_initial.py | 79 +++++++++---------- .../migrations/0002_user_token_version.py | 18 ----- promo_code/user/models.py | 8 ++ 3 files changed, 46 insertions(+), 59 deletions(-) delete mode 100644 promo_code/user/migrations/0002_user_token_version.py diff --git a/promo_code/user/migrations/0001_initial.py b/promo_code/user/migrations/0001_initial.py index 92150f1..9d1ec60 100644 --- a/promo_code/user/migrations/0001_initial.py +++ b/promo_code/user/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.2b1 on 2025-02-28 17:01 +# Generated by Django 5.2 on 2025-05-01 19:23 +import uuid from django.db import migrations, models @@ -8,70 +9,66 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ + ("password", models.CharField(max_length=128, verbose_name="password")), ( - 'id', - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID', - ), - ), - ( - 'password', - models.CharField(max_length=128, verbose_name='password'), - ), - ( - 'is_superuser', + "is_superuser", models.BooleanField( default=False, - help_text='Designates that this user has all permissions without explicitly assigning them.', - verbose_name='superuser status', + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", ), ), - ('email', models.EmailField(max_length=120, unique=True)), - ('name', models.CharField(max_length=100)), - ('surname', models.CharField(max_length=120)), ( - 'avatar_url', - models.URLField(blank=True, max_length=350, null=True), + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + verbose_name="UUID", + ), ), - ('other', models.JSONField(default=dict)), - ('is_active', models.BooleanField(default=True)), - ('is_staff', models.BooleanField(default=False)), - ('last_login', models.DateTimeField(blank=True, null=True)), + ("email", models.EmailField(max_length=120, unique=True)), + ("name", models.CharField(max_length=100)), + ("surname", models.CharField(max_length=120)), + ("avatar_url", models.URLField(blank=True, max_length=350, null=True)), + ("other", models.JSONField(default=dict)), + ("token_version", models.IntegerField(default=0)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ("last_login", models.DateTimeField(blank=True, null=True)), ( - 'groups', + "groups", models.ManyToManyField( blank=True, - help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', - related_name='user_set', - related_query_name='user', - to='auth.group', - verbose_name='groups', + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", ), ), ( - 'user_permissions', + "user_permissions", models.ManyToManyField( blank=True, - help_text='Specific permissions for this user.', - related_name='user_set', - related_query_name='user', - to='auth.permission', - verbose_name='user permissions', + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", ), ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/promo_code/user/migrations/0002_user_token_version.py b/promo_code/user/migrations/0002_user_token_version.py deleted file mode 100644 index b58577f..0000000 --- a/promo_code/user/migrations/0002_user_token_version.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2b1 on 2025-03-14 19:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('user', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='token_version', - field=models.IntegerField(default=0), - ), - ] diff --git a/promo_code/user/models.py b/promo_code/user/models.py index c926ae2..f4d3ae5 100644 --- a/promo_code/user/models.py +++ b/promo_code/user/models.py @@ -1,3 +1,5 @@ +import uuid + import django.contrib.auth.models import django.db.models import django.utils.timezone @@ -38,6 +40,12 @@ class User( django.contrib.auth.models.AbstractBaseUser, django.contrib.auth.models.PermissionsMixin, ): + id = django.db.models.UUIDField( + 'UUID', + primary_key=True, + default=uuid.uuid4, + editable=False, + ) email = django.db.models.EmailField( unique=True, max_length=user.constants.EMAIL_MAX_LENGTH,