From abd999d2451847d19157fa2f1e97e1aba616c5c0 Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Thu, 23 Jul 2026 16:49:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20Environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- net/xeaf/rack/tests/utils/__init__.py | 4 + .../rack/tests/utils/_model_utils_mocks.py | 103 ++++++ .../rack/tests/utils/date_utils_now_tests.py | 101 ++++++ .../utils/date_utils_shift_time_tests.py | 306 ++++++++++++++++++ .../utils/model_utils_concat_data_tests.py | 181 +++++++++++ .../utils/model_utils_update_data_tests.py | 229 +++++++++++++ net/xeaf/rack/utils/model_utils.py | 23 +- pyproject.toml | 1 + 8 files changed, 945 insertions(+), 3 deletions(-) create mode 100644 net/xeaf/rack/tests/utils/_model_utils_mocks.py create mode 100644 net/xeaf/rack/tests/utils/date_utils_now_tests.py create mode 100644 net/xeaf/rack/tests/utils/date_utils_shift_time_tests.py create mode 100644 net/xeaf/rack/tests/utils/model_utils_concat_data_tests.py create mode 100644 net/xeaf/rack/tests/utils/model_utils_update_data_tests.py diff --git a/net/xeaf/rack/tests/utils/__init__.py b/net/xeaf/rack/tests/utils/__init__.py index 5418725..120d1d1 100644 --- a/net/xeaf/rack/tests/utils/__init__.py +++ b/net/xeaf/rack/tests/utils/__init__.py @@ -8,4 +8,8 @@ Пакет описания тестов для служебных классов """ +from .date_utils_now_tests import DateUtilsNowTests +from .date_utils_shift_time_tests import DateUtilsShiftTimeTests +from .model_utils_concat_data_tests import ModelUtilsConcatDataTests +from .model_utils_update_data_tests import ModelUtilsUpdateDataTests from .settings import * diff --git a/net/xeaf/rack/tests/utils/_model_utils_mocks.py b/net/xeaf/rack/tests/utils/_model_utils_mocks.py new file mode 100644 index 0000000..efc2290 --- /dev/null +++ b/net/xeaf/rack/tests/utils/_model_utils_mocks.py @@ -0,0 +1,103 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание mock-классов для тестов ModelUtils +""" + +from django.db import models +from django.db.models import Model + + +class TestSampleModel(Model): + """ + Вспомогательный mock-класс модели для тестирования методов ModelUtils. + Предназначен для сценариев, где data является экземпляром модели. + Имитирует базовую модель Django с полями id, name и description. + """ + + class Meta: + app_label = "test_app" + managed = False + + id = models.IntegerField(primary_key=True, default=1) + name = models.CharField(max_length=100, default="test_name") + description = models.CharField(max_length=255, default="test_description") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class TestEmptyModel(Model): + """ + Вспомогательный mock-класс пустой модели для тестирования граничных случаев. + Предназначен для сценариев, где модель не содержит полей. + """ + + class Meta: + app_label = "test_app" + managed = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class TestModelWithExtraFields(Model): + """ + Вспомогательный mock-класс модели с дополнительными полями. + Предназначен для сценариев, где необходимо проверить работу с полями, + которые могут отсутствовать в целевом словаре. + """ + + class Meta: + app_label = "test_app" + managed = False + + id = models.IntegerField(primary_key=True, default=1) + name = models.CharField(max_length=100, default="test_name") + email = models.EmailField(max_length=255, default="test@example.com") + is_active = models.BooleanField(default=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class TestModelWithSpecialFields(Model): + """ + Вспомогательный mock-класс модели с полями специальных типов. + Предназначен для проверки корректной обработки полей с None значениями + и полей, которые могут быть не установлены. + """ + + class Meta: + app_label = "test_app" + managed = False + + id = models.IntegerField(primary_key=True, default=1) + nullable_field = models.CharField(max_length=100, null=True, blank=True, default=None) + boolean_field = models.BooleanField(default=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class TestModelWithDifferentFieldTypes(Model): + """ + Вспомогательный mock-класс модели с полями различных типов. + Предназначен для проверки корректной обработки разнотипных полей. + """ + + class Meta: + app_label = "test_app" + managed = False + + id = models.IntegerField(primary_key=True, default=1) + integer_field = models.IntegerField(default=0) + float_field = models.FloatField(default=0.0) + text_field = models.TextField(default="") + boolean_field = models.BooleanField(default=False) + date_field = models.DateField(null=True, blank=True) + datetime_field = models.DateTimeField(null=True, blank=True) diff --git a/net/xeaf/rack/tests/utils/date_utils_now_tests.py b/net/xeaf/rack/tests/utils/date_utils_now_tests.py new file mode 100644 index 0000000..9cf79d8 --- /dev/null +++ b/net/xeaf/rack/tests/utils/date_utils_now_tests.py @@ -0,0 +1,101 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса DateUtilsNowTests +""" + +import datetime +from unittest.mock import patch + +from django.utils import timezone + +from net.xeaf.rack.core.testing import CoreSimpleTestCase +from net.xeaf.rack.utils import DateUtils + + +class DateUtilsNowTests(CoreSimpleTestCase): + """ + Тестовый класс для DateUtils.now() + """ + + def test_now_returns_current_datetime(self): + """ + Проверяет, что метод now() возвращает текущую дату и время. + Тестирует позитивный сценарий получения текущего времени. + """ + + # Arrange + expected_now = datetime.datetime(2024, 1, 15, 12, 30, 45, tzinfo=datetime.timezone.utc) + + # Act + with patch('django.utils.timezone.now', return_value=expected_now): + result = DateUtils.now() + + # Assert + self.assertEqual(result, expected_now) + self.assertIsInstance(result, datetime.datetime) + + def test_now_returns_aware_datetime(self): + """ + Проверяет, что now() возвращает aware datetime (с часовым поясом). + Тестирует, что возвращаемое время содержит информацию о часовом поясе. + """ + + # Arrange & Act + result = DateUtils.now() + + # Assert + self.assertIsNotNone(result.tzinfo) + self.assertTrue(timezone.is_aware(result)) + + def test_now_returns_correct_type(self): + """ + Проверяет, что now() возвращает объект типа datetime.datetime. + Тестирует корректность типа возвращаемого значения. + """ + + # Arrange & Act + result = DateUtils.now() + + # Assert + self.assertIsInstance(result, datetime.datetime) + + def test_now_uses_django_timezone(self): + """ + Проверяет, что now() использует django.utils.timezone.now(). + Тестирует, что метод вызывает правильную функцию из Django. + """ + + # Arrange + expected_time = datetime.datetime(2024, 6, 1, 10, 0, 0, tzinfo=datetime.timezone.utc) + + # Act + with patch('django.utils.timezone.now', return_value=expected_time) as mock_now: + result = DateUtils.now() + + # Assert + mock_now.assert_called_once() + self.assertEqual(result, expected_time) + + def test_now_returns_different_values_on_consecutive_calls(self): + """ + Проверяет, что при последовательных вызовах now() возвращаются разные значения. + Тестирует, что метод действительно возвращает актуальное время. + """ + + # Arrange + time1 = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + time2 = datetime.datetime(2024, 1, 1, 0, 0, 1, tzinfo=datetime.timezone.utc) + + # Act + with patch('django.utils.timezone.now', side_effect=[time1, time2]): + result1 = DateUtils.now() + result2 = DateUtils.now() + + # Assert + self.assertNotEqual(result1, result2) + self.assertLess(result1, result2) diff --git a/net/xeaf/rack/tests/utils/date_utils_shift_time_tests.py b/net/xeaf/rack/tests/utils/date_utils_shift_time_tests.py new file mode 100644 index 0000000..e0dafe2 --- /dev/null +++ b/net/xeaf/rack/tests/utils/date_utils_shift_time_tests.py @@ -0,0 +1,306 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса DateUtilsShiftTimeTests +""" + +import datetime +from unittest.mock import patch + +import pytz +from django.utils import timezone + +from net.xeaf.rack.core.testing import CoreSimpleTestCase +from net.xeaf.rack.utils import DateUtils + + +class DateUtilsShiftTimeTests(CoreSimpleTestCase): + """ + Тестовый класс для DateUtils.shift_time() + """ + + def test_shift_time_with_default_date_time(self): + """ + Проверяет сдвиг времени с использованием текущей даты по умолчанию. + Тестирует позитивный сценарий, когда date_time не передан. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 12, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 20, 12, 0, 0, tzinfo=datetime.timezone.utc) + + # Act + with patch('django.utils.timezone.now', return_value=base_time): + result = DateUtils.shift_time(days=5) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_explicit_date_time(self): + """ + Проверяет сдвиг времени с явно переданной датой. + Тестирует позитивный сценарий с указанием конкретной даты. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 20, 10, 30, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=5) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_positive_values(self): + """ + Проверяет сдвиг времени с положительными значениями. + Тестирует добавление дней, часов, минут и секунд. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 15, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 17, 15, 45, 30, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=2, hours=5, minutes=15, seconds=15) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_negative_values(self): + """ + Проверяет сдвиг времени с отрицательными значениями. + Тестирует вычитание дней, часов, минут и секунд. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 13, 5, 15, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=-2, hours=-5, minutes=-15) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_zero_values(self): + """ + Проверяет сдвиг времени с нулевыми значениями. + Тестирует граничный случай, когда время не изменяется. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=0, hours=0, minutes=0, seconds=0) + + # Assert + self.assertEqual(result, base_time) + + def test_shift_time_with_only_hours(self): + """ + Проверяет сдвиг времени только по часам. + Тестирует добавление только часов без изменения других параметров. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 15, 15, 30, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, hours=5) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_only_minutes(self): + """ + Проверяет сдвиг времени только по минутам. + Тестирует добавление только минут без изменения других параметров. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 15, 10, 45, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, minutes=15) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_only_seconds(self): + """ + Проверяет сдвиг времени только по секундам. + Тестирует добавление только секунд без изменения других параметров. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 15, 10, 30, 30, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, seconds=30) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_preserves_timezone(self): + """ + Проверяет, что сдвиг времени сохраняет часовой пояс. + Тестирует корректность работы с aware datetime. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=1, hours=2) + + # Assert + self.assertEqual(result.tzinfo, base_time.tzinfo) + self.assertTrue(timezone.is_aware(result)) + + def test_shift_time_crosses_day_boundary(self): + """ + Проверяет сдвиг времени с переходом через границу дня. + Тестирует корректность работы при добавлении часов, превышающих 24. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 23, 30, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 17, 2, 30, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, hours=27) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_crosses_month_boundary(self): + """ + Проверяет сдвиг времени с переходом через границу месяца. + Тестирует корректность работы при добавлении дней, превышающих количество дней в месяце. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 30, 10, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 2, 3, 10, 0, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=4) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_crosses_year_boundary(self): + """ + Проверяет сдвиг времени с переходом через границу года. + Тестирует корректность работы при добавлении дней, превышающих количество дней в году. + """ + + # Arrange + base_time = datetime.datetime(2024, 12, 30, 10, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2025, 1, 3, 10, 0, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=4) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_large_values(self): + """ + Проверяет сдвиг времени с большими значениями. + Тестирует работу с большими числами дней, часов и минут. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 2, 15, 12, 30, 0, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time(base_time, days=45, hours=12, minutes=30) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_with_none_date_time(self): + """ + Проверяет сдвиг времени при None в качестве date_time. + Тестирует, что при None используется текущее время. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 8, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) + + # Act + with patch('django.utils.timezone.now', return_value=base_time): + result = DateUtils.shift_time(None, days=5) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_handles_daylight_saving(self): + """ + Проверяет сдвиг времени с учетом перехода на летнее время. + Тестирует корректность работы с DST (если применимо). + """ + + # Arrange + tz = pytz.timezone('Europe/Moscow') + base_time = tz.localize(datetime.datetime(2024, 3, 30, 23, 0, 0)) + expected_time = tz.localize(datetime.datetime(2024, 3, 31, 23, 0, 0)) + + # Act + result = DateUtils.shift_time(base_time, days=1) + + # Assert + self.assertEqual(result, expected_time) + + def test_shift_time_returns_same_type_as_input(self): + """ + Проверяет, что возвращаемый тип соответствует входному. + Тестирует, что метод возвращает datetime даже при None входном значении. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 0, 0, tzinfo=datetime.timezone.utc) + + # Act + result_with_date = DateUtils.shift_time(base_time, days=1) + with patch('django.utils.timezone.now', return_value=base_time): + result_without_date = DateUtils.shift_time(None, days=1) + + # Assert + self.assertIsInstance(result_with_date, datetime.datetime) + self.assertIsInstance(result_without_date, datetime.datetime) + + def test_shift_time_combination_of_all_parameters(self): + """ + Проверяет сдвиг времени с комбинацией всех параметров. + Тестирует одновременное добавление дней, часов, минут и секунд. + """ + + # Arrange + base_time = datetime.datetime(2024, 1, 15, 10, 30, 15, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2024, 1, 25, 18, 45, 45, tzinfo=datetime.timezone.utc) + + # Act + result = DateUtils.shift_time( + base_time, + days=10, + hours=8, + minutes=15, + seconds=30 + ) + + # Assert + self.assertEqual(result, expected_time) diff --git a/net/xeaf/rack/tests/utils/model_utils_concat_data_tests.py b/net/xeaf/rack/tests/utils/model_utils_concat_data_tests.py new file mode 100644 index 0000000..cae357c --- /dev/null +++ b/net/xeaf/rack/tests/utils/model_utils_concat_data_tests.py @@ -0,0 +1,181 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ModelUtilsConcatDataTests +""" + +from net.xeaf.rack.core.testing import CoreSimpleTestCase +from net.xeaf.rack.utils import ModelUtils +from ._model_utils_mocks import TestEmptyModel +from ._model_utils_mocks import TestModelWithExtraFields +from ._model_utils_mocks import TestModelWithSpecialFields +from ._model_utils_mocks import TestSampleModel + + +class ModelUtilsConcatDataTests(CoreSimpleTestCase): + """ + Тестовый класс для ModelUtils.concat_data() + """ + + def test_concat_data_with_dict_source_success(self): + """ + Проверяет успешное объединение данных из словаря-источника в целевой словарь. + Тестирует позитивный сценарий, когда источник - обычный словарь. + """ + + # Arrange + source_data = {"name": "John", "age": 30, "city": "Moscow"} + target_data = {"name": "Existing", "country": "Russia"} + + # Act + ModelUtils.concat_data(source_data, target_data) + + # Assert + self.assertEqual(target_data["name"], "Existing") + self.assertEqual(target_data["age"], 30) + self.assertEqual(target_data["city"], "Moscow") + self.assertEqual(target_data["country"], "Russia") + + def test_concat_data_with_model_source_success(self): + """ + Проверяет успешное объединение данных из модели-источника в целевой словарь. + Тестирует позитивный сценарий, когда источник - экземпляр Model. + """ + + # Arrange + model_instance = TestSampleModel(id=5, name="Alice", description="Test description") + target_data = {"name": "Existing", "status": "active"} + + # Act + ModelUtils.concat_data(model_instance, target_data) + + # Assert + self.assertEqual(target_data["name"], "Existing") + self.assertEqual(target_data["id"], 5) + self.assertEqual(target_data["description"], "Test description") + self.assertEqual(target_data["status"], "active") + + def test_concat_data_with_empty_model_source(self): + """ + Проверяет обработку пустой модели в качестве источника данных. + Тестирует граничный случай, когда модель не имеет полей. + """ + + # Arrange + model_instance = TestEmptyModel() + target_data = {"name": "Existing", "status": "active"} + + # Act + ModelUtils.concat_data(model_instance, target_data) + + # Assert + # В пустой модели model_to_dict() вернет пустой словарь + self.assertEqual(target_data["name"], "Existing") + self.assertEqual(target_data["status"], "active") + self.assertEqual(len(target_data), 2) + + def test_concat_data_with_empty_source_dict(self): + """ + Проверяет обработку пустого словаря в качестве источника данных. + Тестирует граничный случай, когда источник данных не содержит полей. + """ + + # Arrange + source_data = {} + target_data = {"name": "Existing", "status": "active"} + + # Act + ModelUtils.concat_data(source_data, target_data) + + # Assert + self.assertEqual(target_data["name"], "Existing") + self.assertEqual(target_data["status"], "active") + self.assertEqual(len(target_data), 2) + + def test_concat_data_does_not_override_existing_keys(self): + """ + Проверяет, что существующие ключи в целевом словаре не перезаписываются. + Тестирует корректность логики пропуска уже существующих ключей. + """ + + # Arrange + source_data = {"name": "John", "age": 30, "city": "Moscow"} + target_data = {"name": "Existing", "age": 25, "country": "Russia"} + + # Act + ModelUtils.concat_data(source_data, target_data) + + # Assert + self.assertEqual(target_data["name"], "Existing") + self.assertEqual(target_data["age"], 25) + self.assertEqual(target_data["city"], "Moscow") + self.assertEqual(target_data["country"], "Russia") + + def test_concat_data_with_model_containing_none_values(self): + """ + Проверяет обработку модели, содержащей поля со значением None. + Тестирует, что None значения корректно копируются в целевой словарь. + """ + + # Arrange + model_instance = TestModelWithSpecialFields(id=3, nullable_field=None, boolean_field=True) + target_data = {"name": "Existing"} + + # Act + ModelUtils.concat_data(model_instance, target_data) + + # Assert + self.assertEqual(target_data["name"], "Existing") + self.assertEqual(target_data["id"], 3) + self.assertIsNone(target_data["nullable_field"]) + self.assertTrue(target_data["boolean_field"]) + + def test_concat_data_with_complex_model_fields(self): + """ + Проверяет обработку модели с различными типами полей. + Тестирует корректность копирования полей разных типов. + """ + + # Arrange + model_instance = TestModelWithExtraFields( + id=7, + name="Bob", + email="bob@example.com", + is_active=False + ) + target_data = {"status": "pending"} + + # Act + ModelUtils.concat_data(model_instance, target_data) + + # Assert + self.assertEqual(target_data["status"], "pending") + self.assertEqual(target_data["id"], 7) + self.assertEqual(target_data["name"], "Bob") + self.assertEqual(target_data["email"], "bob@example.com") + self.assertFalse(target_data["is_active"]) + + def test_concat_data_handles_non_string_keys(self): + """ + Проверяет обработку словаря с не строковыми ключами. + Тестирует, что метод корректно обрабатывает различные типы ключей. + """ + + # Arrange + source_data = {1: "one", 2: "two", "key": "value"} + target_data = {"name": "Existing"} + + # Act + ModelUtils.concat_data(source_data, target_data) + + # Assert + self.assertEqual(target_data["name"], "Existing") + # noinspection bad-index + self.assertEqual(target_data[1], "one") + # noinspection bad-index + self.assertEqual(target_data[2], "two") + self.assertEqual(target_data["key"], "value") diff --git a/net/xeaf/rack/tests/utils/model_utils_update_data_tests.py b/net/xeaf/rack/tests/utils/model_utils_update_data_tests.py new file mode 100644 index 0000000..598b583 --- /dev/null +++ b/net/xeaf/rack/tests/utils/model_utils_update_data_tests.py @@ -0,0 +1,229 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ModelUtilsUpdateDataTests +""" + +from net.xeaf.rack.core.testing import CoreSimpleTestCase +from net.xeaf.rack.utils import ModelUtils +from ._model_utils_mocks import TestModelWithExtraFields +from ._model_utils_mocks import TestModelWithSpecialFields +from ._model_utils_mocks import TestSampleModel + + +class ModelUtilsUpdateDataTests(CoreSimpleTestCase): + """ + Тестовый класс для ModelUtils.update_data() + """ + + def test_update_data_with_model_instance_success(self): + """ + Проверяет успешное обновление атрибутов модели на основе переданных данных. + Тестирует позитивный сценарий обновления экземпляра модели. + """ + + # Arrange + instance = TestSampleModel(id=1, name="Old Name", description="Old description") + validated_data = {"name": "New Name", "description": "New description"} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance.name, "New Name") + self.assertEqual(instance.description, "New description") + self.assertEqual(instance.id, 1) # id не должен измениться + + def test_update_data_with_dict_instance_success(self): + """ + Проверяет успешное обновление словаря-объекта на основе переданных данных. + Тестирует позитивный сценарий обновления словаря. + """ + + # Arrange + instance = {"name": "Old Name", "age": 25, "city": "Moscow"} + validated_data = {"name": "New Name", "age": 30} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance["name"], "New Name") + self.assertEqual(instance["age"], 30) + self.assertEqual(instance["city"], "Moscow") + + def test_update_data_skips_nonexistent_attributes(self): + """ + Проверяет, что метод пропускает атрибуты, отсутствующие в объекте. + Тестирует корректную обработку полей, которых нет в экземпляре. + """ + + # Arrange + instance = TestSampleModel(id=1, name="Old Name") + validated_data = {"name": "New Name", "nonexistent_field": "Should be skipped"} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance.name, "New Name") + self.assertFalse(hasattr(instance, "nonexistent_field")) + self.assertEqual(instance.id, 1) + + def test_update_data_with_empty_validated_data(self): + """ + Проверяет обработку пустого словаря данных для обновления. + Тестирует граничный случай, когда нет данных для обновления. + """ + + # Arrange + instance = TestSampleModel(id=1, name="Original Name", description="Original description") + validated_data = {} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance.name, "Original Name") + self.assertEqual(instance.description, "Original description") + self.assertEqual(instance.id, 1) + + def test_update_data_with_dict_and_missing_keys(self): + """ + Проверяет обновление словаря с ключами, часть которых отсутствует. + Тестирует обработку ситуации, когда ключ из validated_data отсутствует в словаре. + """ + + # Arrange + instance = {"name": "Old Name", "city": "Moscow"} + validated_data = {"name": "New Name", "age": 30} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance["name"], "New Name") + self.assertEqual(instance["city"], "Moscow") + with self.assertRaises(KeyError): + # Новый ключ не будет добавлен + self.assertEqual(instance["age"], 30) + + def test_update_data_partially_updates_model(self): + """ + Проверяет частичное обновление модели (не все поля переданы). + Тестирует, что обновляются только переданные поля. + """ + + # Arrange + instance = TestSampleModel(id=1, name="Old Name", description="Old description") + validated_data = {"name": "New Name"} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance.name, "New Name") + self.assertEqual(instance.description, "Old description") + self.assertEqual(instance.id, 1) + + def test_update_data_with_model_containing_readonly_fields(self): + """ + Проверяет обновление модели с полями, которые могут быть защищены. + Тестирует, что метод не пытается обновить несуществующие атрибуты. + """ + + # Arrange + instance = TestModelWithExtraFields( + id=5, + name="Old Name", + email="old@example.com", + is_active=True + ) + validated_data = {"name": "New Name", "email": "new@example.com"} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance.name, "New Name") + self.assertEqual(instance.email, "new@example.com") + self.assertTrue(instance.is_active) + self.assertEqual(instance.id, 5) + + def test_update_data_with_invalid_data_types(self): + """ + Проверяет обработку некорректных типов данных. + Тестирует, что метод корректно обрабатывает различные типы значений. + """ + + # Arrange + instance = TestSampleModel(id=1, name="Old Name", description="Old description") + validated_data = {"name": 123, "description": None} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance.name, 123) # Любое значение будет присвоено + self.assertIsNone(instance.description) + self.assertEqual(instance.id, 1) + + def test_update_data_with_nested_structures(self): + """ + Проверяет обновление с вложенными структурами данных. + Тестирует обработку сложных типов данных. + """ + + # Arrange + instance = {"name": "Old", "data": {"key": "value"}} + validated_data = {"name": "New", "data": {"new_key": "new_value"}} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(instance["name"], "New") + self.assertEqual(instance["data"], {"new_key": "new_value"}) + + def test_update_data_with_bool_values(self): + """ + Проверяет обновление с булевыми значениями. + Тестирует корректную обработку True/False значений. + """ + + # Arrange + instance = TestModelWithSpecialFields(id=1, nullable_field="test", boolean_field=False) + validated_data = {"boolean_field": True, "nullable_field": None} + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertTrue(instance.boolean_field) + self.assertIsNone(instance.nullable_field) + self.assertEqual(instance.id, 1) + + def test_update_data_does_not_modify_original_dict(self): + """ + Проверяет, что метод не создает копий и не изменяет исходные словари. + Тестирует, что обновление происходит по ссылке. + """ + + # Arrange + instance = {"name": "Old", "age": 20} + validated_data = {"name": "New", "city": "Moscow"} + original_instance_id = id(instance) + + # Act + ModelUtils.update_data(instance, validated_data) + + # Assert + self.assertEqual(id(instance), original_instance_id) + self.assertEqual(instance["name"], "New") + self.assertEqual(instance["age"], 20) + with self.assertRaises(KeyError): + self.assertEqual(instance["city"], "Moscow") diff --git a/net/xeaf/rack/utils/model_utils.py b/net/xeaf/rack/utils/model_utils.py index dfe8e45..dd7c8a8 100644 --- a/net/xeaf/rack/utils/model_utils.py +++ b/net/xeaf/rack/utils/model_utils.py @@ -20,7 +20,7 @@ class ModelUtils: """ @classmethod - def concat_data(cls, data: dict[str, Any] | Model, target: dict[str, Any], ) -> None: + def concat_data(cls, data: dict[str | int, Any] | Model, target: dict[str | int, Any], ) -> None: """ Объединяет данные @@ -31,6 +31,8 @@ class ModelUtils: # Получаем объект данных if isinstance(data, Model): source = model_to_dict(data) + if 'id' in source and source['id'] is None: + del source['id'] else: source = data @@ -48,6 +50,21 @@ class ModelUtils: :param validated_data: Данные для обновления """ + # Для модели и словаря набор полей строится по-разному + if isinstance(instance, Model): + is_model = True + fields = [field.name for field in instance._meta.get_fields()] + elif isinstance(instance, dict): + is_model = False + fields = instance.keys() + else: + raise TypeError('Instance must be a Model or dict') + + # Назначаем только существующие поля for attr, value in validated_data.items(): - if hasattr(instance, attr): - setattr(instance, attr, value) + if attr in fields: + if is_model: + setattr(instance, attr, value) + else: + # noinspection unresolved-references + instance[attr] = value diff --git a/pyproject.toml b/pyproject.toml index 3682af8..85c23f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "gunicorn", "pillow", "psycopg[binary]", + "pytz", "redis", "split_settings", ]