From 9c13222d0dd3c30c29c393b3c1d5ee264aedd392 Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Sun, 12 Jul 2026 15:50:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8,=20UserStor?= =?UTF-8?q?y=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- net/xeaf/rack/core/__init__.py | 2 + net/xeaf/rack/core/core_command.py | 37 +++++++++ net/xeaf/rack/core/core_test_case.py | 25 ++++++ net/xeaf/rack/management/__init__.py | 9 ++ net/xeaf/rack/management/commands/__init__.py | 10 +++ .../management/commands/rack_test_database.py | 44 ++++++++++ .../management/commands/rack_wipe_database.py | 33 ++++++++ .../rack/management/userstory/__init__.py | 12 +++ .../management/userstory/account_test_data.py | 68 +++++++++++++++ .../management/userstory/session_test_data.py | 62 ++++++++++++++ net/xeaf/rack/managers/crypto_manager.py | 2 +- net/xeaf/rack/migrations/0001_initial.py | 6 +- net/xeaf/rack/models/account_model.py | 2 +- .../rack/tests/models/account_model_tests.py | 66 ++++++--------- .../rack/tests/models/session_model_tests.py | 83 +++++++------------ 15 files changed, 364 insertions(+), 97 deletions(-) create mode 100644 net/xeaf/rack/core/core_command.py create mode 100644 net/xeaf/rack/core/core_test_case.py create mode 100644 net/xeaf/rack/management/__init__.py create mode 100644 net/xeaf/rack/management/commands/__init__.py create mode 100644 net/xeaf/rack/management/commands/rack_test_database.py create mode 100644 net/xeaf/rack/management/commands/rack_wipe_database.py create mode 100644 net/xeaf/rack/management/userstory/__init__.py create mode 100644 net/xeaf/rack/management/userstory/account_test_data.py create mode 100644 net/xeaf/rack/management/userstory/session_test_data.py diff --git a/net/xeaf/rack/core/__init__.py b/net/xeaf/rack/core/__init__.py index 1a67734..90b0821 100644 --- a/net/xeaf/rack/core/__init__.py +++ b/net/xeaf/rack/core/__init__.py @@ -8,6 +8,7 @@ Пакет базовых классов """ +from .core_command import CoreCommand from .core_enum import CoreEnum from .core_exception import CoreException from .core_model import CoreModel @@ -15,3 +16,4 @@ from .core_model import CoreModel from .core_persistent_model import CorePersistentModel from .core_persistent_model import CorePersistentModel from .core_response import CoreResponse +from .core_test_case import CoreTestCase diff --git a/net/xeaf/rack/core/core_command.py b/net/xeaf/rack/core/core_command.py new file mode 100644 index 0000000..a9bea28 --- /dev/null +++ b/net/xeaf/rack/core/core_command.py @@ -0,0 +1,37 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreCommand +""" + +import sys + +from django.conf import settings +from django.core.management import BaseCommand +from django.core.management import call_command + + +class CoreCommand(BaseCommand): + """ + Реализует базовые свойства всех обработчиков команд + """ + + # noinspection PyMethodMayBeStatic + def _clear_database(self): + """ + Очищает базу данных + """ + + call_command("flush", "--no-input") + + @classmethod + def is_debug_or_test(cls): + """ + Возвращает признак режима отладки или тестов + """ + + return settings.DEBUG or 'test' in sys.argv diff --git a/net/xeaf/rack/core/core_test_case.py b/net/xeaf/rack/core/core_test_case.py new file mode 100644 index 0000000..65fa345 --- /dev/null +++ b/net/xeaf/rack/core/core_test_case.py @@ -0,0 +1,25 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreTestCase +""" + +from django.core.management import call_command +from django.test import TestCase + +class CoreTestCase(TestCase): + """ + Реализует инициализацию тестовой базы данных перед каждым тестом + """ + + def setUp(self): + """ + Подготовка к тестированию + """ + + call_command("rack_test_database") + diff --git a/net/xeaf/rack/management/__init__.py b/net/xeaf/rack/management/__init__.py new file mode 100644 index 0000000..5b38ac1 --- /dev/null +++ b/net/xeaf/rack/management/__init__.py @@ -0,0 +1,9 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет классов управления +""" diff --git a/net/xeaf/rack/management/commands/__init__.py b/net/xeaf/rack/management/commands/__init__.py new file mode 100644 index 0000000..4b9f952 --- /dev/null +++ b/net/xeaf/rack/management/commands/__init__.py @@ -0,0 +1,10 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет классов команд управления +""" + diff --git a/net/xeaf/rack/management/commands/rack_test_database.py b/net/xeaf/rack/management/commands/rack_test_database.py new file mode 100644 index 0000000..c6758c8 --- /dev/null +++ b/net/xeaf/rack/management/commands/rack_test_database.py @@ -0,0 +1,44 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса для команды rack_test_database +""" + +from django.core.management import call_command +from django.core.management.base import no_translations + +from net.xeaf.rack.core import CoreCommand +from net.xeaf.rack.management.userstory import AccountTestData +from net.xeaf.rack.management.userstory import SessionTestData + + +class Command(CoreCommand): + """ + Инициализирует базу данных для тестирования + """ + + @no_translations + def handle(self, *args, **options): + """ + Обрабатывает команду инициализации БД + + :params args: Позиционные аргументы + :params options: Дополнительные параметры + """ + + if self.is_debug_or_test(): + + # Готовимся + self._clear_database() + call_command("migrate") + + # Инициализируем данные + AccountTestData.init_accounts() + SessionTestData.init_sessions() + + else: + self.stdout.write(self.style.ERROR("The rack_test_database command can only be executed in debug or testing mode.")) diff --git a/net/xeaf/rack/management/commands/rack_wipe_database.py b/net/xeaf/rack/management/commands/rack_wipe_database.py new file mode 100644 index 0000000..fb65e57 --- /dev/null +++ b/net/xeaf/rack/management/commands/rack_wipe_database.py @@ -0,0 +1,33 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса для команды rack_wipe_database +""" + +from django.core.management.base import no_translations + +from net.xeaf.rack.core import CoreCommand + + +class Command(CoreCommand): + """ + Очищает базу данных + """ + + @no_translations + def handle(self, *args, **options): + """ + Обрабатывает команду инициализации БД + + :params args: Позиционные аргументы + :params options: Дополнительные параметры + """ + + if self.is_debug_or_test(): + self._clear_database() + else: + self.stdout.write(self.style.ERROR("The rack_wipe_database command can only be executed in debug or test mode.")) diff --git a/net/xeaf/rack/management/userstory/__init__.py b/net/xeaf/rack/management/userstory/__init__.py new file mode 100644 index 0000000..d5f29af --- /dev/null +++ b/net/xeaf/rack/management/userstory/__init__.py @@ -0,0 +1,12 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет классов создания тестовой БД +""" + +from .account_test_data import AccountTestData +from .session_test_data import SessionTestData diff --git a/net/xeaf/rack/management/userstory/account_test_data.py b/net/xeaf/rack/management/userstory/account_test_data.py new file mode 100644 index 0000000..123bc42 --- /dev/null +++ b/net/xeaf/rack/management/userstory/account_test_data.py @@ -0,0 +1,68 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса AccountTestData +""" + +import uuid + +from net.xeaf.rack.models import AccountModel + + +class AccountTestData: + """ + Инициализирует модели данных тестовых Аккаунтов + """ + + I_IVANOV_ID = uuid.UUID("bd428358-6944-4657-8044-a3dc1c4dff8a") + """ Идентификатор пользователя Иван И. Иванов """ + + P_PETROV_ID = uuid.UUID("9c5010cc-f3be-42ae-bc05-d142d68a610a") + """ Идентификатор пользователя Петр П. Петров """ + + DEFAULT_PASSWORD = "manager123!" + """ Пароль по умолчанию для тестов """ + + @classmethod + def init_accounts(cls): + """ + Инициализирует Аккаунты пользователя + """ + + cls.init_i_ivanov_account() + cls.init_p_petrov_account() + + @classmethod + def init_i_ivanov_account(cls): + """ + Инициализирует Аккаунт пользователя Иван И. Иванов + """ + + account = AccountModel() + account.id = cls.I_IVANOV_ID + account.username = "i.ivanov@xeaf.net" + account.nick_name = "Иван" + account.full_name = "Иван И. Иванов" + account.email = "i.ivanov@xeaf.net" + account.set_password(cls.DEFAULT_PASSWORD) + account.save() + + @classmethod + def init_p_petrov_account(cls): + """ + Инициализирует Аккаунт пользователя Петр П. Петров + """ + + account = AccountModel() + account.id = cls.P_PETROV_ID + account.username = "p.petrov@xeaf.net" + account.nick_name = "Петр" + account.full_name = "Петр П. Петров" + account.email = "p.petrov@xeaf.net" + account.set_password(cls.DEFAULT_PASSWORD) + account.save() + diff --git a/net/xeaf/rack/management/userstory/session_test_data.py b/net/xeaf/rack/management/userstory/session_test_data.py new file mode 100644 index 0000000..f2788e3 --- /dev/null +++ b/net/xeaf/rack/management/userstory/session_test_data.py @@ -0,0 +1,62 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса SessionTestData +""" + +from net.xeaf.rack.models import SessionModel +from .account_test_data import AccountTestData + + +class SessionTestData: + """ + Инициализирует модели данных тестовых Сессий пользователей + """ + + I_IVANOV_SESSION_TOKEN = "ced4217bbd12b10a59a3f9342164793819a29238" + """ Токен сессии пользователя Иван И. Иванов """ + + P_PETROV_SESSION_TOKEN = "1e20d0f236efdda2f31688b6c176bf817cb3357e" + """ Токен сессии пользователя Петр П. Петров """ + + TEST_SESSION_TTL = 3600 + """ Время жизни тестовой сессии """ + + @classmethod + def init_sessions(cls): + """ + Инициализирует сессии для тестовых пользователей + """ + + cls.init_i_ivanov_session() + cls.init_p_petrov_session() + + @classmethod + def init_i_ivanov_session(cls): + """ + Создает Сессию для пользователя Иван И. Иванов + """ + + session = SessionModel() + session.token = cls.I_IVANOV_SESSION_TOKEN + session.account_id = AccountTestData.I_IVANOV_ID + session.actual_account_id = AccountTestData.I_IVANOV_ID + session.renew(cls.TEST_SESSION_TTL) + session.save() + + @classmethod + def init_p_petrov_session(cls): + """ + Создает Сессию для пользователя Петр П. Петров + """ + + session = SessionModel() + session.token = cls.P_PETROV_SESSION_TOKEN + session.account_id = AccountTestData.P_PETROV_ID + session.actual_account_id = AccountTestData.P_PETROV_ID + session.renew(cls.TEST_SESSION_TTL) + session.save() diff --git a/net/xeaf/rack/managers/crypto_manager.py b/net/xeaf/rack/managers/crypto_manager.py index 4c56abc..378c293 100644 --- a/net/xeaf/rack/managers/crypto_manager.py +++ b/net/xeaf/rack/managers/crypto_manager.py @@ -16,7 +16,7 @@ class CryptoManager: Реализует методы работы со случайными и криптографическими значениями """ - DEFAULT_TOKEN_LENGTH = 32 + DEFAULT_TOKEN_LENGTH = 40 """ Длина токена по умолчанию """ DEFAULT_PIN_CODE_LENGTH = 4 diff --git a/net/xeaf/rack/migrations/0001_initial.py b/net/xeaf/rack/migrations/0001_initial.py index 009dbfa..0680855 100644 --- a/net/xeaf/rack/migrations/0001_initial.py +++ b/net/xeaf/rack/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.7 on 2026-07-12 09:19 +# Generated by Django 6.0.7 on 2026-07-12 10:42 import django.contrib.auth.models import django.db.models.deletion @@ -34,7 +34,7 @@ class Migration(migrations.Migration): ('full_name', models.CharField(blank=True, max_length=127, null=True, verbose_name='rack.models.account.full_name')), ('joined_at', models.DateTimeField(blank=True, default=None, null=True, verbose_name='rack.models.account.joined_at')), ('last_login', models.DateTimeField(blank=True, default=None, null=True, verbose_name='rack.models.account.last_login')), - ('extra', models.JSONField(default={}, verbose_name='rack.models.account.extra')), + ('extra', models.JSONField(default=dict, verbose_name='rack.models.account.extra')), ('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')), ('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')), ], @@ -53,7 +53,7 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='rack.mixin.created_at')), ('expires_at', models.DateTimeField(verbose_name='rack.mixin.expires_at')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='rack.mixin.id')), - ('token', models.CharField(max_length=32, verbose_name='rack.models.session_model.token')), + ('token', models.CharField(max_length=40, verbose_name='rack.models.session_model.token')), ('device_id', models.CharField(blank=True, default='WEB', max_length=36, null=True, verbose_name='rack.models.session_model.device_id')), ('fcm_token', models.CharField(blank=True, default=None, max_length=256, null=True, verbose_name='rack.models.session_model.fcm_token')), ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='rack.models.session_model.account')), diff --git a/net/xeaf/rack/models/account_model.py b/net/xeaf/rack/models/account_model.py index afd965b..2839c2d 100644 --- a/net/xeaf/rack/models/account_model.py +++ b/net/xeaf/rack/models/account_model.py @@ -75,7 +75,7 @@ class AccountModel(AbstractUser, UuidIdMixin, CreatedAtMixin, UpdatedAtMixin, De extra = models.JSONField( null=False, - default={}, + default=dict, blank=False, verbose_name=_("rack.models.account.extra"), ) diff --git a/net/xeaf/rack/tests/models/account_model_tests.py b/net/xeaf/rack/tests/models/account_model_tests.py index 681e6ba..c5dd1ce 100644 --- a/net/xeaf/rack/tests/models/account_model_tests.py +++ b/net/xeaf/rack/tests/models/account_model_tests.py @@ -9,64 +9,48 @@ """ from django.db.utils import IntegrityError -from django.test import TestCase +from net.xeaf.rack.core import CoreTestCase +from net.xeaf.rack.management.userstory import AccountTestData from net.xeaf.rack.models import AccountModel -class AccountModelTests(TestCase): +class AccountModelTests(CoreTestCase): """ Тесты для класса AccountModel """ - def test_create_account_with_defaults(self): + def test_user_story_accounts_exist(self): """ - Проверяет корректное создание аккаунта с дефолтными полями + Проверяет, что эталонные пользователи успешно созданы в User Story """ - email = "test_user@example.com" + # Ищем Ивана Иванова по фиксированному UUID + ivanov = AccountModel.objects.get(id=AccountTestData.I_IVANOV_ID) - # Создаем через чистый ORM, как ты любишь, построчно - account = AccountModel() - account.username = email - account.save() + self.assertEqual(ivanov.username, "i.ivanov@xeaf.net") + self.assertEqual(ivanov.full_name, "Иван И. Иванов") + self.assertEqual(ivanov.get_short_name(), "Иван") - # Проверяем контракт полей - self.assertEqual(account.username, email) - self.assertIsNone(account.full_name) - self.assertIsNone(account.nick_name) + # Проверяем, что наше исправление JSONField (default=dict) работает: + # Поле extra должно быть пустым словарем, а не None, готовым к .update() + self.assertEqual(ivanov.extra, {}) - # Если ты исправил дефолт на dict, тест гарантирует безопасность: - self.assertEqual(account.extra, {}) + def test_password_hashing_works(self): + """Проверяет, что пароли пользователей в User Story захешированы, а не лежат строкой""" + ivanov = AccountModel.objects.get(id=AccountTestData.I_IVANOV_ID) - def test_compatibility_methods(self): - """ - Проверяет методы совместимости get_full_name и get_short_name - """ - - account = AccountModel() - account.username = "names@example.com" - account.full_name = "Иван Иванов" - account.nick_name = "vanya" - account.save() - - self.assertEqual(account.get_full_name(), "Иван Иванов") - self.assertEqual(account.get_short_name(), "vanya") + # Проверяем, что стандартный джанговский механизм проверки пароля работает + self.assertTrue(ivanov.check_password(AccountTestData.DEFAULT_PASSWORD)) + # Гарантируем, что в базе лежит хэш, а не сырая строка + self.assertNotEqual(ivanov.password, AccountTestData.DEFAULT_PASSWORD) def test_username_uniqueness_constraint(self): - """ - Проверяет жесткое ограничение уникальности на уровне UniqueConstraint - """ - - duplicate_email = "duplicate@example.com" - - # Создаем первого - account1 = AccountModel(username=duplicate_email) - account1.save() - - # Попытка создать второго с тем же username должна намертво упасть в БД - account2 = AccountModel(username=duplicate_email) + """Проверяет жесткое ограничение уникальности email-username""" + # Попытка создать пользователя с дублирующим email Ивана должна намертво упасть + duplicate_account = AccountModel() + duplicate_account.username = "i.ivanov@xeaf.net" with self.assertRaises(IntegrityError): - account2.save() + duplicate_account.save() diff --git a/net/xeaf/rack/tests/models/session_model_tests.py b/net/xeaf/rack/tests/models/session_model_tests.py index 530d8be..50b6c40 100644 --- a/net/xeaf/rack/tests/models/session_model_tests.py +++ b/net/xeaf/rack/tests/models/session_model_tests.py @@ -9,75 +9,56 @@ """ from django.db.utils import IntegrityError -from django.test import TestCase +from django.utils import timezone -from net.xeaf.rack.managers import CryptoManager -from net.xeaf.rack.models import AccountModel +from net.xeaf.rack.core import CoreTestCase +from net.xeaf.rack.management.userstory import AccountTestData +from net.xeaf.rack.management.userstory import SessionTestData from net.xeaf.rack.models import SessionModel -class SessionModelTests(TestCase): +class SessionModelTests(CoreTestCase): """ Тесты для класса SessionModel """ - def setUp(self): + def test_user_story_sessions_exist_and_linked(self): """ - Атомарно создаем тестовых пользователей для связей + Проверяет, что сессии из User Story созданы и правильно связаны с аккаунтами """ - self.primary_user = AccountModel.objects.create_user( - username="primary@example.com", - password="secure_password_123" - ) - self.actual_user = AccountModel.objects.create_user( - username="actual@example.com", - password="secure_password_456" - ) + # Ищем сессию Ивана по фиксированному токену + session = SessionModel.objects.get(token=SessionTestData.I_IVANOV_SESSION_TOKEN) - def test_create_session_with_defaults_and_relations(self): - """ - Проверяет успешное создание сессии, заполнение дефолтных полей и внешних ключей - """ + # Проверяем связи (включая твой крутой механизм имперсонации) + self.assertEqual(session.account_id, AccountTestData.I_IVANOV_ID) + self.assertEqual(session.actual_account_id, AccountTestData.I_IVANOV_ID) - token_str = CryptoManager.gen_token() - - session = SessionModel() - session.token = token_str - session.account = self.primary_user - session.actual_account = self.actual_user - session.renew(3600) - session.save() - - # Проверяем контракт полей и связей - self.assertEqual(session.token, token_str) - self.assertEqual(session.account.id, self.primary_user.id) - self.assertEqual(session.actual_account.id, self.actual_user.id) + # Проверяем дефолты текстовых полей, которые мы закладывали в модель self.assertEqual(session.device_id, "WEB") + self.assertIsNone(session.fcm_token) + + def test_session_expiration_logic(self): + """ + Проверяет, что метод renew() правильно выставил expires_at в будущем + """ + + session = SessionModel.objects.get(token=SessionTestData.I_IVANOV_SESSION_TOKEN) + + # Сессия должна быть активной прямо сейчас + self.assertGreater(session.expires_at, timezone.now()) def test_token_uniqueness_constraint(self): """ - Проверяет жесткое ограничение уникальности токена сессии в базе данных + Проверяет жесткое ограничение уникальности токенов сессий """ - duplicate_token = "same_token_xyz" - - # Создаем первую валидную сессию - session1 = SessionModel( - token=duplicate_token, - account=self.primary_user, - actual_account=self.actual_user - ) - session1.renew(3600) - session1.save() - - # Попытка создать вторую сессию с тем же токеном должна намертво упасть в БД - session2 = SessionModel( - token=duplicate_token, - account=self.primary_user, - actual_account=self.actual_user - ) + # Попытка создать новую сессию с токеном Петра должна упасть + duplicate_session = SessionModel() + duplicate_session.token = SessionTestData.P_PETROV_SESSION_TOKEN + duplicate_session.account_id = AccountTestData.I_IVANOV_ID + duplicate_session.actual_account_id = AccountTestData.I_IVANOV_ID + duplicate_session.expire() with self.assertRaises(IntegrityError): - session2.renew(3600) - session2.save() + duplicate_session.save()