diff --git a/net/xeaf/rack/managers/crypto_manager.py b/net/xeaf/rack/managers/crypto_manager.py index b31f472..4c56abc 100644 --- a/net/xeaf/rack/managers/crypto_manager.py +++ b/net/xeaf/rack/managers/crypto_manager.py @@ -5,7 +5,7 @@ # Все права защищены. Лицензия: MIT """ -Работа с криптографией +Описание класса CryptoManager """ import secrets diff --git a/net/xeaf/rack/models/__init__.py b/net/xeaf/rack/models/__init__.py index ed3651e..062b320 100644 --- a/net/xeaf/rack/models/__init__.py +++ b/net/xeaf/rack/models/__init__.py @@ -5,5 +5,5 @@ # Все права защищены. Лицензия: MIT """ -Пакет описания моделей данных +Пакет описания классов моделей данных """ diff --git a/net/xeaf/rack/models/mixins/__init__.py b/net/xeaf/rack/models/mixins/__init__.py new file mode 100644 index 0000000..6b73283 --- /dev/null +++ b/net/xeaf/rack/models/mixins/__init__.py @@ -0,0 +1,13 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания классов расширений моделей данных +""" + +from .created_at_mixin import CreatedAtMixin +from .deleted_at_mixin import DeletedAtMixin +from .expired_at_mixin import ExpiresAtMixin diff --git a/net/xeaf/rack/models/mixins/created_at_mixin.py b/net/xeaf/rack/models/mixins/created_at_mixin.py new file mode 100644 index 0000000..657cfa9 --- /dev/null +++ b/net/xeaf/rack/models/mixins/created_at_mixin.py @@ -0,0 +1,34 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CreatedAtMixin +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class CreatedAtMixin(models.Model): + """ + Добавляет к модели свойство даты и времени создания + """ + + created_at = models.DateTimeField( + auto_now_add=True, + null=False, + blank=False, + editable=False, + verbose_name=_("rack.mixin.created_at"), + ) + """ Дата и время создания """ + + class Meta: + """ + Конфигурация класса + """ + + abstract = True diff --git a/net/xeaf/rack/models/mixins/deleted_at_mixin.py b/net/xeaf/rack/models/mixins/deleted_at_mixin.py new file mode 100644 index 0000000..ae36bce --- /dev/null +++ b/net/xeaf/rack/models/mixins/deleted_at_mixin.py @@ -0,0 +1,65 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса DeletedAtMixin +""" + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class DeletedAtMixin(models.Model): + """ + Добавляет к модели свойство даты и времени удаления + """ + + deleted_at = models.DateTimeField( + null=True, + blank=True, + editable=True, + verbose_name=_("rack.mixin.deleted_at"), + ) + """ Дата и время удаления """ + + def soft_delete(self, save: bool = True) -> None: + """ + Помечает запись на удаление + + :param save: Признак необходимости сохранения + """ + + self.deleted_at = timezone.now() + if save: + self.save() + + def undelete(self, save: bool = True) -> None: + """ + Снимает пометку удаленной записи + + :param save: Признак необходимости сохранения + """ + + self.deleted_at = None + if save: + self.save() + + def is_deleted(self) -> bool: + """ + Возвращает признак удаленной записи + + :return: True, если запись была удалена + """ + + return self.deleted_at is not None + + class Meta: + """ + Конфигурация класса + """ + + abstract = True diff --git a/net/xeaf/rack/models/mixins/expired_at_mixin.py b/net/xeaf/rack/models/mixins/expired_at_mixin.py new file mode 100644 index 0000000..59e27c8 --- /dev/null +++ b/net/xeaf/rack/models/mixins/expired_at_mixin.py @@ -0,0 +1,72 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ExpiredAtMixin +""" + +from datetime import timedelta + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class ExpiresAtMixin(models.Model): + """ + Реализует свойство даты и времени истечения + """ + + expires_at = models.DateTimeField( + null=False, + blank=False, + editable=True, + verbose_name=_("rack.mixin.expires_at"), + ) + """ Дата и время истечения """ + + def renew(self, seconds: int, save: bool = False) -> None: + """ + Устанавливает время истечения на заданное количество секунд + + :param seconds: Количество секунд до истечения + :param save: Признак необходимости сохранения + """ + + # Проверка значений + if seconds <=0: + raise ValueError(f"The seconds parameter must be greater than 0") + + self.expires_at = timezone.now() + timedelta(seconds=seconds) + if save: + self.save() + + def expire(self, save: bool = False) -> None: + """ + Устанавливает время истечения на сейчас + + :param save: Признак необходимости сохранения + """ + + self.expires_at = timezone.now() + if save: + self.save() + + def is_expired(self) -> bool: + """ + Возвращает признак просроченного значения + + :return: Признак просроченного значения + """ + + return self.expires_at < timezone.now() + + class Meta: + """ + Параметры класса + """ + + abstract = True diff --git a/net/xeaf/rack/tests/__init__.py b/net/xeaf/rack/tests/__init__.py index c62e2a4..d7e6dc8 100644 --- a/net/xeaf/rack/tests/__init__.py +++ b/net/xeaf/rack/tests/__init__.py @@ -7,3 +7,13 @@ """ Пакет описания классов тестов """ + +import sys +import warnings + +if 'test' in sys.argv: + warnings.filterwarnings( + "ignore", + message="Model .* was already registered", + category=RuntimeWarning + ) diff --git a/net/xeaf/rack/tests/models/__init__.py b/net/xeaf/rack/tests/models/__init__.py new file mode 100644 index 0000000..16600c9 --- /dev/null +++ b/net/xeaf/rack/tests/models/__init__.py @@ -0,0 +1,9 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания тестов классов моделей данных +""" diff --git a/net/xeaf/rack/tests/models/mixins/__init__.py b/net/xeaf/rack/tests/models/mixins/__init__.py new file mode 100644 index 0000000..d410838 --- /dev/null +++ b/net/xeaf/rack/tests/models/mixins/__init__.py @@ -0,0 +1,14 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания тестов классов расширений моделей данных +""" + +from .created_at_mixin_tests import CreatedAtMixinTests +from .deleted_at_mixin_tests import DeletedAtMixinTests +from .expired_at_mixin_tests import ExpiresAtMixinTests + diff --git a/net/xeaf/rack/tests/models/mixins/created_at_mixin_tests.py b/net/xeaf/rack/tests/models/mixins/created_at_mixin_tests.py new file mode 100644 index 0000000..acaedf2 --- /dev/null +++ b/net/xeaf/rack/tests/models/mixins/created_at_mixin_tests.py @@ -0,0 +1,129 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CreatedAtMixinTests +""" + +import time +from datetime import datetime + +from django.db import connection +from django.db import models +from django.test import TransactionTestCase + +from net.xeaf.rack.models.mixins import CreatedAtMixin + + +class CreatedAtMixinTests(TransactionTestCase): + """ + Тесты для миксина CreatedAtMixin + """ + + def setUp(self): + """ + Создаем тестовую модель и таблицу для каждого теста + """ + + # Отключаем foreign keys + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = OFF;') + + # Создаем тестовую модель + class TestCreatedAtModel(CreatedAtMixin): + name = models.CharField(max_length=100) + + class Meta: + app_label = 'test' + db_table = 'test_created_at_mixin' + + self.TestModel = TestCreatedAtModel + + # Создаем таблицу + with connection.schema_editor() as editor: + editor.create_model(TestCreatedAtModel) + + def tearDown(self): + """ + Удаляем таблицу после каждого теста + """ + + # Удаляем таблицу + with connection.schema_editor() as editor: + editor.delete_model(self.TestModel) + + # Включаем foreign keys обратно + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = ON;') + + def test_created_at_is_auto_now_add(self): + """ + Проверяем, что created_at автоматически заполняется при создании + """ + + instance = self.TestModel.objects.create(name='Test Instance') + self.assertIsNotNone(instance.created_at) + self.assertIsInstance(instance.created_at, datetime) + + def test_created_at_is_auto_now_add_true(self): + """ + Проверяем, что auto_now_add=True + """ + + field = self.TestModel._meta.get_field('created_at') + self.assertIsInstance(field, models.DateTimeField) + self.assertTrue(field.auto_now_add) + + def test_created_at_editable_false(self): + """ + Проверяем, что editable=False + """ + + field = self.TestModel._meta.get_field('created_at') + self.assertFalse(field.editable) + + def test_different_instances_have_different_created_at(self): + """ + Проверяем, что у разных экземпляров разное время создания + """ + + instance1 = self.TestModel.objects.create(name='First') + time.sleep(0.1) + instance2 = self.TestModel.objects.create(name='Second') + + self.assertNotEqual(instance1.created_at, instance2.created_at) + self.assertLess(instance1.created_at, instance2.created_at) + + def test_created_at_preserved_on_update(self): + """ + Проверяем, что created_at не меняется при обновлении + """ + + instance = self.TestModel.objects.create(name='Test Instance') + old_created_at = instance.created_at + + instance.name = 'Updated Name' + instance.save() + instance.refresh_from_db() + + self.assertEqual(instance.created_at, old_created_at) + + def test_created_at_not_null(self): + """ + Проверяем, что created_at не может быть NULL + """ + + field = self.TestModel._meta.get_field('created_at') + self.assertFalse(field.null) + + def test_verbose_name(self): + """ + Проверяем verbose_name поля + """ + + field = self.TestModel._meta.get_field('created_at') + self.assertIsInstance(field, models.Field) + self.assertEqual(field.verbose_name, "rack.mixin.created_at") diff --git a/net/xeaf/rack/tests/models/mixins/deleted_at_mixin_tests.py b/net/xeaf/rack/tests/models/mixins/deleted_at_mixin_tests.py new file mode 100644 index 0000000..61a35fa --- /dev/null +++ b/net/xeaf/rack/tests/models/mixins/deleted_at_mixin_tests.py @@ -0,0 +1,251 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CreatedAtMixinTests +""" + +import time +from datetime import timedelta + +from django.db import connection +from django.db import models +from django.test import TransactionTestCase +from django.utils import timezone + +from net.xeaf.rack.models.mixins import DeletedAtMixin + + +class DeletedAtMixinTests(TransactionTestCase): + """ + Тесты для миксина DeletedAtMixin + """ + + def setUp(self): + """ + Создаем тестовую модель и таблицу для каждого теста + """ + + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = OFF;') + + class TestDeletedAtModelModel(DeletedAtMixin): + name = models.CharField(max_length=100) + + class Meta: + app_label = 'test' + db_table = 'test_deleted_at_mixin' + + self.TestModel = TestDeletedAtModelModel + + with connection.schema_editor() as editor: + editor.create_model(TestDeletedAtModelModel) + + def tearDown(self): + """ + Удаляем таблицу после каждого теста + """ + + with connection.schema_editor() as editor: + editor.delete_model(self.TestModel) + + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = ON;') + + def test_deleted_at_null_by_default(self): + """ + Проверяем, что при создании deleted_at = None + """ + + instance = self.TestModel.objects.create(name='Test Instance') + self.assertIsNone(instance.deleted_at) + + def test_soft_delete_sets_current_time(self): + """ + Проверяем, что soft_delete() устанавливает текущее время + """ + + instance = self.TestModel.objects.create(name='Test Instance') + before = timezone.now() + + instance.soft_delete(save=False) + + self.assertIsNotNone(instance.deleted_at) + self.assertGreaterEqual(instance.deleted_at, before) + self.assertLessEqual(instance.deleted_at, timezone.now()) + + def test_soft_delete_saves_to_db_when_save_true(self): + """ + Проверяем, что при soft_delete(save=True) значение сохраняется в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete(save=True) + + # Обновляем из БД + instance.refresh_from_db() + self.assertIsNotNone(instance.deleted_at) + + def test_soft_delete_does_not_save_when_save_false(self): + """ + Проверяем, что при soft_delete(save=False) значение НЕ сохраняется в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete(save=False) + + # Обновляем из БД + instance.refresh_from_db() + self.assertIsNone(instance.deleted_at) + + def test_soft_delete_saves_by_default(self): + """ + Проверяем, что по умолчанию soft_delete() сохраняет в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete() + + instance.refresh_from_db() + self.assertIsNotNone(instance.deleted_at) + + def test_is_deleted_returns_false_when_not_deleted(self): + """ + Проверяем, что is_deleted() возвращает False для активной записи + """ + + instance = self.TestModel.objects.create(name='Test Instance') + self.assertFalse(instance.is_deleted()) + + def test_is_deleted_returns_true_when_deleted(self): + """ + Проверяем, что is_deleted() возвращает True для удаленной записи + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete(save=False) + self.assertTrue(instance.is_deleted()) + + def test_undelete_restores_record(self): + """ + Проверяем, что undelete() восстанавливает запись + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete(save=False) + self.assertTrue(instance.is_deleted()) + + instance.undelete(save=False) + self.assertFalse(instance.is_deleted()) + self.assertIsNone(instance.deleted_at) + + def test_undelete_saves_to_db_when_save_true(self): + """ + Проверяем, что при undelete(save=True) значение сохраняется в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete(save=True) + self.assertTrue(instance.is_deleted()) + + instance.undelete(save=True) + instance.refresh_from_db() + + self.assertFalse(instance.is_deleted()) + self.assertIsNone(instance.deleted_at) + + def test_undelete_does_not_save_when_save_false(self): + """ + Проверяем, что при undelete(save=False) значение НЕ сохраняется в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete(save=True) + self.assertTrue(instance.is_deleted()) + + instance.undelete(save=False) + instance.refresh_from_db() + + # В БД все еще deleted_at не None + self.assertTrue(instance.is_deleted()) + self.assertIsNotNone(instance.deleted_at) + + def test_undelete_saves_by_default(self): + """ + Проверяем, что по умолчанию undelete() сохраняет в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.soft_delete(save=True) + + instance.undelete() + instance.refresh_from_db() + + self.assertFalse(instance.is_deleted()) + self.assertIsNone(instance.deleted_at) + + def test_can_set_deleted_at_manually(self): + """ + Проверяем, что можно установить deleted_at вручную + """ + + custom_time = timezone.now() - timedelta(days=1) + + instance = self.TestModel.objects.create(name='Test Instance') + instance.deleted_at = custom_time + instance.save() + instance.refresh_from_db() + + self.assertEqual(instance.deleted_at, custom_time) + + def test_multiple_soft_deletes_update_time(self): + """ + Проверяем, что повторный вызов soft_delete() обновляет время + """ + + instance = self.TestModel.objects.create(name='Test Instance') + + instance.soft_delete(save=False) + first_time = instance.deleted_at + + time.sleep(0.1) + + instance.soft_delete(save=False) + second_time = instance.deleted_at + + self.assertNotEqual(first_time, second_time) + self.assertGreater(second_time, first_time) + + def test_undelete_after_manual_delete(self): + """ + Проверяем, что undelete() работает после ручной установки deleted_at + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.deleted_at = timezone.now() - timedelta(days=1) + self.assertTrue(instance.is_deleted()) + + instance.undelete(save=False) + self.assertFalse(instance.is_deleted()) + self.assertIsNone(instance.deleted_at) + + def test_soft_delete_twice_with_save(self): + """ + Проверяем, что повторный soft_delete с save=True корректно работает + """ + + instance = self.TestModel.objects.create(name='Test Instance') + + instance.soft_delete(save=True) + first_time = instance.deleted_at + + time.sleep(0.1) + + instance.soft_delete(save=True) + instance.refresh_from_db() + + self.assertNotEqual(instance.deleted_at, first_time) + self.assertGreater(instance.deleted_at, first_time) diff --git a/net/xeaf/rack/tests/models/mixins/expired_at_mixin_tests.py b/net/xeaf/rack/tests/models/mixins/expired_at_mixin_tests.py new file mode 100644 index 0000000..b90c8e2 --- /dev/null +++ b/net/xeaf/rack/tests/models/mixins/expired_at_mixin_tests.py @@ -0,0 +1,320 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ExpiredAtMixinTests +""" + +from datetime import timedelta + +from django.db import connection +from django.db import models +from django.db.models.fields import DateTimeField +from django.test import TransactionTestCase +from django.utils import timezone + +from net.xeaf.rack.models.mixins import ExpiresAtMixin + + +class ExpiresAtMixinTests(TransactionTestCase): + """ + Тесты для миксина ExpiresAtMixin + """ + + def setUp(self): + """ + Создаем тестовую модель и таблицу для каждого теста + """ + + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = OFF;') + + class TestExpiredAtModel(ExpiresAtMixin): + name = models.CharField(max_length=100) + + class Meta: + app_label = 'test' + db_table = 'test_expires_at_mixin' + + self.TestModel = TestExpiredAtModel + + with connection.schema_editor() as editor: + editor.create_model(TestExpiredAtModel) + + def tearDown(self): + """ + Удаляем таблицу после каждого теста + """ + + with connection.schema_editor() as editor: + editor.delete_model(self.TestModel) + + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = ON;') + + def test_expires_at_required(self): + """ + Проверяем, что expires_at обязательное поле + """ + + field = self.TestModel._meta.get_field('expires_at') + self.assertFalse(field.null) + self.assertIsInstance(field, DateTimeField) + self.assertFalse(field.blank) + + def test_renew_sets_expiration_time(self): + """ + Проверяем, что renew() устанавливает время истечения + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + + before = timezone.now() + instance.renew(seconds=3600, save=False) + + self.assertIsNotNone(instance.expires_at) + self.assertGreaterEqual(instance.expires_at, before + timedelta(seconds=3600)) + self.assertLessEqual(instance.expires_at, timezone.now() + timedelta(seconds=3600)) + + def test_renew_saves_to_db_when_save_true(self): + """ + Проверяем, что renew(save=True) сохраняет в БД + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + + instance.renew(seconds=3600, save=True) + instance.refresh_from_db() + + self.assertGreaterEqual(instance.expires_at, timezone.now()) + + def test_renew_does_not_save_when_save_false(self): + """ + Проверяем, что renew(save=False) НЕ сохраняет в БД + """ + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + old_expires_at = instance.expires_at + + instance.renew(seconds=3600, save=False) + instance.refresh_from_db() + + self.assertEqual(instance.expires_at, old_expires_at) + + def test_renew_raises_error_for_negative_seconds(self): + """ + Проверяем, что renew() выбрасывает ошибку для отрицательного значения + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + + with self.assertRaises(ValueError) as context: + instance.renew(seconds=-1) + + self.assertIn('greater than 0', str(context.exception)) + + def test_renew_raises_error_for_zero_seconds(self): + """ + Проверяем, что renew() выбрасывает ошибку для нулевого значения + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + + with self.assertRaises(ValueError) as context: + instance.renew(seconds=0) + + self.assertIn('greater than 0', str(context.exception)) + + def test_expire_sets_time_to_now(self): + """ + Проверяем, что expire() устанавливает время истечения на сейчас + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + + before = timezone.now() + instance.expire(save=False) + + self.assertIsNotNone(instance.expires_at) + self.assertGreaterEqual(instance.expires_at, before) + self.assertLessEqual(instance.expires_at, timezone.now()) + + def test_expire_saves_to_db_when_save_true(self): + """ + Проверяем, что expire(save=True) сохраняет в БД + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + + instance.expire(save=True) + instance.refresh_from_db() + + self.assertLessEqual(instance.expires_at, timezone.now()) + + def test_expire_does_not_save_when_save_false(self): + """ + Проверяем, что expire(save=False) НЕ сохраняет в БД + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + old_expires_at = instance.expires_at + + instance.expire(save=False) + instance.refresh_from_db() + + self.assertEqual(instance.expires_at, old_expires_at) + + def test_is_expired_returns_false_when_not_expired(self): + """ + Проверяем, что is_expired() возвращает False для активной записи + """ + + future_time = timezone.now() + timedelta(days=1) + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=future_time + ) + + self.assertFalse(instance.is_expired()) + + def test_is_expired_returns_true_when_expired(self): + """ + Проверяем, что is_expired() возвращает True для просроченной записи + """ + + past_time = timezone.now() - timedelta(days=1) + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=past_time + ) + + self.assertTrue(instance.is_expired()) + + def test_is_expired_after_renew(self): + """ + Проверяем, что is_expired() корректно работает после renew() + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() - timedelta(days=1) + ) + self.assertTrue(instance.is_expired()) + + instance.renew(seconds=3600, save=False) + self.assertFalse(instance.is_expired()) + + def test_is_expired_after_expire(self): + """ + Проверяем, что is_expired() корректно работает после expire() + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + self.assertFalse(instance.is_expired()) + + instance.expire(save=False) + self.assertTrue(instance.is_expired()) + + def test_renew_with_large_seconds(self): + """ + Проверяем, что renew() работает с большими значениями + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + + seconds = 86400 * 365 # 1 год + instance.renew(seconds=seconds, save=False) + + expected = timezone.now() + timedelta(seconds=seconds) + self.assertLessEqual(instance.expires_at, expected + timedelta(seconds=1)) + self.assertGreaterEqual(instance.expires_at, expected - timedelta(seconds=1)) + + def test_renew_saves_by_default_false(self): + """ + Проверяем, что по умолчанию renew() НЕ сохраняет в БД + (save=False по умолчанию, в отличие от soft_delete) + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + old_expires_at = instance.expires_at + + instance.renew(seconds=3600) + instance.refresh_from_db() + + self.assertEqual(instance.expires_at, old_expires_at) + + def test_expire_saves_by_default_false(self): + """ + Проверяем, что по умолчанию expire() НЕ сохраняет в БД + (save=False по умолчанию, в отличие от soft_delete) + """ + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=timezone.now() + timedelta(days=1) + ) + old_expires_at = instance.expires_at + + instance.expire() + instance.refresh_from_db() + + self.assertEqual(instance.expires_at, old_expires_at) + + def test_expires_at_editable_true(self): + """ + Проверяем, что поле expires_at редактируемое + """ + + field = self.TestModel._meta.get_field('expires_at') + self.assertTrue(field.editable) + + def test_can_set_expires_at_manually(self): + """ + Проверяем, что можно установить expires_at вручную + """ + + custom_time = timezone.now() + timedelta(days=5) + + instance = self.TestModel.objects.create( + name='Test Instance', + expires_at=custom_time + ) + instance.save() + instance.refresh_from_db() + + self.assertEqual(instance.expires_at, custom_time)