Фиксируем модели, UserStory и тесты

This commit is contained in:
2026-07-12 15:50:41 +03:00
parent b2e15511c1
commit 9c13222d0d
15 changed files with 364 additions and 97 deletions

View File

@@ -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

View File

@@ -0,0 +1,37 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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

View File

@@ -0,0 +1,25 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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")

View File

@@ -0,0 +1,9 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет классов управления
"""

View File

@@ -0,0 +1,10 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет классов команд управления
"""

View File

@@ -0,0 +1,44 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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."))

View File

@@ -0,0 +1,33 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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."))

View File

@@ -0,0 +1,12 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет классов создания тестовой БД
"""
from .account_test_data import AccountTestData
from .session_test_data import SessionTestData

View File

@@ -0,0 +1,68 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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()

View File

@@ -0,0 +1,62 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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()

View File

@@ -16,7 +16,7 @@ class CryptoManager:
Реализует методы работы со случайными и криптографическими значениями
"""
DEFAULT_TOKEN_LENGTH = 32
DEFAULT_TOKEN_LENGTH = 40
""" Длина токена по умолчанию """
DEFAULT_PIN_CODE_LENGTH = 4

View File

@@ -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')),

View File

@@ -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"),
)

View File

@@ -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()

View File

@@ -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()