diff --git a/myproject/customers/models.py b/myproject/customers/models.py index ea852bb..f3e4198 100644 --- a/myproject/customers/models.py +++ b/myproject/customers/models.py @@ -128,15 +128,17 @@ class Customer(models.Model): def save(self, *args, **kwargs): # Защита системного клиента от изменений - if self.pk and self.is_system_customer: + if self.pk: # Получаем оригинальный объект из БД try: original = Customer.objects.get(pk=self.pk) - # Проверяем, не пытаются ли изменить критичные поля - if original.email != self.email: - raise ValidationError("Нельзя изменить email системного клиента") - if original.is_system_customer != self.is_system_customer: - raise ValidationError("Нельзя изменить флаг системного клиента") + # Проверяем, что это системный клиент в БД + if original.is_system_customer: + # Проверяем, не пытаются ли изменить критичные поля + if original.email != self.email: + raise ValidationError("Нельзя изменить email системного клиента") + if original.is_system_customer != self.is_system_customer: + raise ValidationError("Нельзя изменить флаг системного клиента") except Customer.DoesNotExist: pass diff --git a/myproject/customers/tests.py b/myproject/customers/tests.py deleted file mode 100644 index 09451f4..0000000 --- a/myproject/customers/tests.py +++ /dev/null @@ -1,475 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Тесты для модуля customers. - -Используем TenantTestCase для корректной работы с tenant-системой. -""" -from decimal import Decimal - -from django.core.cache import cache -from django_tenants.test.cases import TenantTestCase - -from .models import Customer, WalletTransaction -from .services.wallet_service import WalletService -from .views import determine_search_strategy, is_query_phone_only - - -class DetermineSearchStrategyTestCase(TenantTestCase): - """ - Тесты для функции determine_search_strategy(). - Компактная версия с параметризацией для избежания дублирования. - """ - - def _test_strategy(self, query, expected_strategy, expected_value): - """Вспомогательный метод для проверки стратегии.""" - strategy, search_value = determine_search_strategy(query) - self.assertEqual(strategy, expected_strategy, - f"Query '{query}' должен вернуть стратегию '{expected_strategy}'") - self.assertEqual(search_value, expected_value, - f"Query '{query}' должен вернуть значение '{expected_value}'") - - # ===== email_prefix: query заканчивается на @ ===== - def test_email_prefix_strategy(self): - """Различные варианты поиска по префиксу email.""" - test_cases = [ - ('team_x3m@', 'team_x3m'), - ('user_name@', 'user_name'), - ('test123@', 'test123'), - ] - for query, expected_value in test_cases: - self._test_strategy(query, 'email_prefix', expected_value) - - # ===== email_domain: query начинается с @ ===== - def test_email_domain_strategy(self): - """Различные варианты поиска по домену email.""" - test_cases = [ - ('@bk', 'bk'), - ('@bk.ru', 'bk.ru'), - ('@mail.google.com', 'mail.google.com'), - ] - for query, expected_value in test_cases: - self._test_strategy(query, 'email_domain', expected_value) - - # ===== email_full: query содержит и локальную часть, и домен ===== - def test_email_full_strategy(self): - """Различные варианты полного поиска email.""" - test_cases = [ - ('test@bk.ru', 'test@bk.ru'), - ('test@bk', 'test@bk'), - ('user.name@mail.example.com', 'user.name@mail.example.com'), - ] - for query, expected_value in test_cases: - self._test_strategy(query, 'email_full', expected_value) - - # ===== universal: query без @, 3+ символов ===== - def test_universal_strategy(self): - """Универсальный поиск для запросов 3+ символов.""" - test_cases = [ - ('abc', 'abc'), # минимум 3 символа - ('natul', 'natul'), - ('наталь', 'наталь'), # кириллица - ('Test123', 'Test123'), # смешанный - ('Ivan Petrov', 'Ivan Petrov'), # с пробелами - ] - for query, expected_value in test_cases: - self._test_strategy(query, 'universal', expected_value) - - # ===== name_only: очень короткие запросы (< 3 символов без @) ===== - def test_name_only_strategy(self): - """Поиск только по имени для коротких запросов.""" - test_cases = [ - ('t', 't'), # 1 символ - ('te', 'te'), # 2 символа - ('на', 'на'), # 2 символа кириллица - ('', ''), # пустая строка - ] - for query, expected_value in test_cases: - self._test_strategy(query, 'name_only', expected_value) - - # ===== edge cases ===== - def test_edge_cases(self): - """Граничные и специальные случаи.""" - # Только символ @ - self._test_strategy('@', 'email_domain', '') - - # Множественные @ - берётся первый - self._test_strategy('test@example@com', 'email_full', 'test@example@com') - - # ===== real-world критические сценарии ===== - def test_real_world_email_prefix_no_false_match(self): - """ - КРИТИЧНЫЙ: query 'team_x3m@' НЕ должен найти 'natulj@bk.ru'. - Проверяем, что используется email_prefix (istartswith), а не universal (icontains). - """ - strategy, search_value = determine_search_strategy('team_x3m@') - self.assertEqual(strategy, 'email_prefix') - self.assertEqual(search_value, 'team_x3m') - # Важно: НЕ universal стратегия - self.assertNotEqual(strategy, 'universal') - - def test_real_world_domain_search(self): - """Real-world: '@bk' находит все email с @bk.*""" - self._test_strategy('@bk', 'email_domain', 'bk') - - def test_real_world_universal_search(self): - """Real-world: 'natul' находит и имя 'Наталья' и email 'natulj@bk.ru'""" - self._test_strategy('natul', 'universal', 'natul') - - -class IsQueryPhoneOnlyTestCase(TenantTestCase): - """ - Тесты для функции is_query_phone_only(). - - Проверяют, что функция правильно определяет, содержит ли query - только символы номера телефона (цифры, +, -, (), пробелы). - """ - - # ===== Должны вернуть True (только телефонные символы) ===== - def test_phone_only_digits(self): - """Query '295' должен вернуть True (только цифры)""" - self.assertTrue(is_query_phone_only('295')) - - def test_phone_only_single_digit(self): - """Query '5' должен вернуть True (одна цифра)""" - self.assertTrue(is_query_phone_only('5')) - - def test_phone_with_plus(self): - """Query '+375291234567' должен вернуть True""" - self.assertTrue(is_query_phone_only('+375291234567')) - - def test_phone_with_dashes(self): - """Query '029-123-45' должен вернуть True""" - self.assertTrue(is_query_phone_only('029-123-45')) - - def test_phone_with_parentheses(self): - """Query '(029) 123-45' должен вернуть True""" - self.assertTrue(is_query_phone_only('(029) 123-45')) - - def test_phone_with_spaces(self): - """Query '029 123 45' должен вернуть True""" - self.assertTrue(is_query_phone_only('029 123 45')) - - def test_phone_complex_format(self): - """Query '+375 (29) 123-45-67' должен вернуть True""" - self.assertTrue(is_query_phone_only('+375 (29) 123-45-67')) - - def test_phone_with_dot(self): - """Query '029.123.45' должен вернуть True""" - self.assertTrue(is_query_phone_only('029.123.45')) - - # ===== Должны вернуть False (содержат буквы или другие символы) ===== - def test_query_with_letters_only(self): - """Query 'abc' должен вернуть False (содержит буквы)""" - self.assertFalse(is_query_phone_only('abc')) - - def test_query_with_mixed_letters_digits(self): - """Query 'x3m' должен вернуть False (содержит буквы)""" - self.assertFalse(is_query_phone_only('x3m')) - - def test_query_name_with_digits(self): - """Query 'team_x3m' должен вернуть False (содержит буквы и _)""" - self.assertFalse(is_query_phone_only('team_x3m')) - - def test_query_name_cyrillic(self): - """Query 'Наталья' должен вернуть False (содержит кириллицу)""" - self.assertFalse(is_query_phone_only('Наталья')) - - def test_query_with_underscore(self): - """Query '123_456' должен вернуть False (содержит _)""" - self.assertFalse(is_query_phone_only('123_456')) - - def test_query_with_hash(self): - """Query '123#456' должен вернуть False (содержит #)""" - self.assertFalse(is_query_phone_only('123#456')) - - def test_empty_string(self): - """Query '' должен вернуть False (пустая строка)""" - self.assertFalse(is_query_phone_only('')) - - def test_only_spaces(self): - """Query ' ' должен вернуть False (пустой запрос)""" - self.assertFalse(is_query_phone_only(' ')) - - # ===== Real-world cases ===== - def test_real_world_case_x3m_should_not_be_phone(self): - """ - Real-world case: "x3m" содержит букву, поэтому НЕ похож на телефон. - Это критично для решения проблемы с поиском Натальи. - """ - self.assertFalse(is_query_phone_only('x3m')) - # Значит, при поиске "x3m" НЕ будет поиска по цифре "3" в телефонах - - def test_real_world_case_295_should_be_phone(self): - """Real-world case: '295' только цифры, похож на телефон""" - self.assertTrue(is_query_phone_only('295')) - - def test_real_world_full_phone_number(self): - """Real-world case: полный номер в стандартном формате""" - self.assertTrue(is_query_phone_only('+375 (29) 598-62-62')) - - -# ========== ТЕСТЫ КОШЕЛЬКА ========== - -class WalletBalanceCalculationTestCase(TenantTestCase): - """Тесты вычисления баланса кошелька из транзакций.""" - - def setUp(self): - """Создаём тестового клиента и очищаем кеш.""" - self.customer = Customer.objects.create(name="Тестовый клиент") - cache.clear() - - def tearDown(self): - """Очищаем кеш после каждого теста.""" - cache.clear() - - def test_empty_wallet_returns_zero(self): - """Пустой кошелёк должен возвращать 0.""" - self.assertEqual(self.customer.wallet_balance, Decimal('0')) - - def test_single_deposit(self): - """Одно пополнение корректно учитывается.""" - WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('100.00'), - transaction_type='deposit', - balance_category='money' - ) - cache.clear() - self.assertEqual(self.customer.wallet_balance, Decimal('100.00')) - - def test_single_spend(self): - """Списание корректно учитывается (отрицательная сумма).""" - # Сначала пополняем - WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('100.00'), - transaction_type='deposit', - balance_category='money' - ) - # Затем списываем - WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('-30.00'), - transaction_type='spend', - balance_category='money' - ) - cache.clear() - self.assertEqual(self.customer.wallet_balance, Decimal('70.00')) - - def test_multiple_operations(self): - """Несколько операций подряд вычисляются корректно.""" - operations = [ - ('deposit', Decimal('200.00')), - ('spend', Decimal('-50.00')), - ('deposit', Decimal('100.00')), - ('spend', Decimal('-80.00')), - ('adjustment', Decimal('10.00')), - ] - - for txn_type, signed_amount in operations: - WalletTransaction.objects.create( - customer=self.customer, - signed_amount=signed_amount, - transaction_type=txn_type, - balance_category='money' - ) - - cache.clear() - # 200 - 50 + 100 - 80 + 10 = 180 - self.assertEqual(self.customer.wallet_balance, Decimal('180.00')) - - def test_amount_property_returns_absolute(self): - """Property amount возвращает абсолютное значение.""" - txn = WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('-50.00'), - transaction_type='spend', - balance_category='money' - ) - self.assertEqual(txn.amount, Decimal('50.00')) - - def test_cache_invalidation(self): - """Кеш инвалидируется методом invalidate_wallet_cache.""" - # Первый вызов - баланс 0 - self.assertEqual(self.customer.wallet_balance, Decimal('0')) - - # Добавляем транзакцию напрямую (без сервиса) - WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('100.00'), - transaction_type='deposit', - balance_category='money' - ) - - # Без инвалидации кеша - всё ещё 0 (закешировано) - self.assertEqual(self.customer.get_wallet_balance(use_cache=True), Decimal('0')) - - # После инвалидации - 100 - self.customer.invalidate_wallet_cache() - self.assertEqual(self.customer.wallet_balance, Decimal('100.00')) - - -class WalletServiceTestCase(TenantTestCase): - """Тесты WalletService.""" - - def setUp(self): - """Создаём тестового клиента.""" - self.customer = Customer.objects.create(name="Тестовый клиент") - cache.clear() - - def tearDown(self): - cache.clear() - - def test_create_transaction_deposit(self): - """create_transaction создаёт пополнение с положительной суммой.""" - txn = WalletService.create_transaction( - customer=self.customer, - amount=Decimal('50.00'), - transaction_type='deposit', - description='Тестовое пополнение' - ) - - self.assertEqual(txn.signed_amount, Decimal('50.00')) - self.assertEqual(txn.transaction_type, 'deposit') - self.assertEqual(txn.balance_after, Decimal('50.00')) - self.assertEqual(self.customer.wallet_balance, Decimal('50.00')) - - def test_create_transaction_spend(self): - """create_transaction создаёт списание с отрицательной суммой.""" - # Сначала пополняем - WalletService.create_transaction( - customer=self.customer, - amount=Decimal('100.00'), - transaction_type='deposit' - ) - - # Затем списываем - txn = WalletService.create_transaction( - customer=self.customer, - amount=Decimal('30.00'), - transaction_type='spend', - description='Тестовое списание' - ) - - self.assertEqual(txn.signed_amount, Decimal('-30.00')) - self.assertEqual(txn.transaction_type, 'spend') - self.assertEqual(txn.balance_after, Decimal('70.00')) - self.assertEqual(self.customer.wallet_balance, Decimal('70.00')) - - def test_create_transaction_spend_insufficient_funds(self): - """Списание при недостаточном балансе вызывает ValueError.""" - with self.assertRaises(ValueError) as context: - WalletService.create_transaction( - customer=self.customer, - amount=Decimal('100.00'), - transaction_type='spend' - ) - - self.assertIn('Недостаточно средств', str(context.exception)) - - def test_adjust_balance_positive(self): - """Положительная корректировка увеличивает баланс.""" - txn = WalletService.adjust_balance( - customer_id=self.customer.pk, - amount=Decimal('75.00'), - description='Тестовое пополнение администратором', - user=None - ) - - self.assertEqual(txn.signed_amount, Decimal('75.00')) - self.assertEqual(txn.transaction_type, 'adjustment') - self.assertEqual(self.customer.wallet_balance, Decimal('75.00')) - - def test_adjust_balance_negative(self): - """Отрицательная корректировка уменьшает баланс.""" - # Сначала пополняем - WalletService.adjust_balance( - customer_id=self.customer.pk, - amount=Decimal('100.00'), - description='Начальное пополнение', - user=None - ) - - # Отрицательная корректировка - txn = WalletService.adjust_balance( - customer_id=self.customer.pk, - amount=Decimal('-40.00'), - description='Списание администратором', - user=None - ) - - self.assertEqual(txn.signed_amount, Decimal('-40.00')) - self.assertEqual(self.customer.wallet_balance, Decimal('60.00')) - - def test_adjust_balance_negative_insufficient(self): - """Отрицательная корректировка с недостаточным балансом вызывает ValueError.""" - with self.assertRaises(ValueError) as context: - WalletService.adjust_balance( - customer_id=self.customer.pk, - amount=Decimal('-50.00'), - description='Списание', - user=None - ) - - self.assertIn('отрицательному балансу', str(context.exception)) - - def test_adjust_balance_requires_description(self): - """Корректировка без описания вызывает ValueError.""" - with self.assertRaises(ValueError) as context: - WalletService.adjust_balance( - customer_id=self.customer.pk, - amount=Decimal('50.00'), - description='', - user=None - ) - - self.assertIn('Описание обязательно', str(context.exception)) - - def test_adjust_balance_zero_amount_fails(self): - """Корректировка с нулевой суммой вызывает ValueError.""" - with self.assertRaises(ValueError) as context: - WalletService.adjust_balance( - customer_id=self.customer.pk, - amount=Decimal('0'), - description='Нулевая корректировка', - user=None - ) - - self.assertIn('не может быть нулевой', str(context.exception)) - - -class WalletTransactionModelTestCase(TenantTestCase): - """Тесты модели WalletTransaction.""" - - def setUp(self): - self.customer = Customer.objects.create(name="Тестовый клиент") - cache.clear() - - def test_str_representation_positive(self): - """__str__ для положительной суммы содержит +.""" - txn = WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('100.00'), - transaction_type='deposit', - balance_category='money' - ) - self.assertIn('+100', str(txn)) - - def test_str_representation_negative(self): - """__str__ для отрицательной суммы содержит -.""" - txn = WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('-50.00'), - transaction_type='spend', - balance_category='money' - ) - self.assertIn('-50', str(txn)) - - def test_default_balance_category(self): - """По умолчанию balance_category = 'money'.""" - txn = WalletTransaction.objects.create( - customer=self.customer, - signed_amount=Decimal('100.00'), - transaction_type='deposit' - ) - self.assertEqual(txn.balance_category, 'money') diff --git a/myproject/customers/tests/__init__.py b/myproject/customers/tests/__init__.py new file mode 100644 index 0000000..501b2dd --- /dev/null +++ b/myproject/customers/tests/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +""" +Тесты для модуля customers. + +Все тесты организованы по функциональным областям в отдельных модулях. +""" +from .test_search_strategies import DetermineSearchStrategyTestCase, IsQueryPhoneOnlyTestCase +from .test_wallet_balance import WalletBalanceCalculationTestCase +from .test_wallet_model import WalletTransactionModelTestCase +from .test_wallet_service import WalletServiceTestCase +from .test_system_customer import SystemCustomerProtectionTestCase + +__all__ = [ + # Тесты стратегий поиска клиентов + 'DetermineSearchStrategyTestCase', + 'IsQueryPhoneOnlyTestCase', + + # Тесты баланса кошелька + 'WalletBalanceCalculationTestCase', + + # Тесты модели транзакций + 'WalletTransactionModelTestCase', + + # Тесты сервиса кошелька + 'WalletServiceTestCase', + + # Тесты защиты системного клиента + 'SystemCustomerProtectionTestCase', +] diff --git a/myproject/customers/tests/test_search_strategies.py b/myproject/customers/tests/test_search_strategies.py new file mode 100644 index 0000000..f4b8990 --- /dev/null +++ b/myproject/customers/tests/test_search_strategies.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +""" +Тесты для функций поиска клиентов. + +Используем TenantTestCase для корректной работы с tenant-системой. +""" +from django_tenants.test.cases import TenantTestCase + +from customers.views import determine_search_strategy, is_query_phone_only + + +class DetermineSearchStrategyTestCase(TenantTestCase): + """ + Тесты для функции determine_search_strategy(). + Компактная версия с параметризацией для избежания дублирования. + """ + + def _test_strategy(self, query, expected_strategy, expected_value): + """Вспомогательный метод для проверки стратегии.""" + strategy, search_value = determine_search_strategy(query) + self.assertEqual(strategy, expected_strategy, + f"Query '{query}' должен вернуть стратегию '{expected_strategy}'") + self.assertEqual(search_value, expected_value, + f"Query '{query}' должен вернуть значение '{expected_value}'") + + # ===== email_prefix: query заканчивается на @ ===== + def test_email_prefix_strategy(self): + """Различные варианты поиска по префиксу email.""" + test_cases = [ + ('team_x3m@', 'team_x3m'), + ('user_name@', 'user_name'), + ('test123@', 'test123'), + ] + for query, expected_value in test_cases: + self._test_strategy(query, 'email_prefix', expected_value) + + # ===== email_domain: query начинается с @ ===== + def test_email_domain_strategy(self): + """Различные варианты поиска по домену email.""" + test_cases = [ + ('@bk', 'bk'), + ('@bk.ru', 'bk.ru'), + ('@mail.google.com', 'mail.google.com'), + ] + for query, expected_value in test_cases: + self._test_strategy(query, 'email_domain', expected_value) + + # ===== email_full: query содержит и локальную часть, и домен ===== + def test_email_full_strategy(self): + """Различные варианты полного поиска email.""" + test_cases = [ + ('test@bk.ru', 'test@bk.ru'), + ('test@bk', 'test@bk'), + ('user.name@mail.example.com', 'user.name@mail.example.com'), + ] + for query, expected_value in test_cases: + self._test_strategy(query, 'email_full', expected_value) + + # ===== universal: query без @, 3+ символов ===== + def test_universal_strategy(self): + """Универсальный поиск для запросов 3+ символов.""" + test_cases = [ + ('abc', 'abc'), # минимум 3 символа + ('natul', 'natul'), + ('наталь', 'наталь'), # кириллица + ('Test123', 'Test123'), # смешанный + ('Ivan Petrov', 'Ivan Petrov'), # с пробелами + ] + for query, expected_value in test_cases: + self._test_strategy(query, 'universal', expected_value) + + # ===== name_only: очень короткие запросы (< 3 символов без @) ===== + def test_name_only_strategy(self): + """Поиск только по имени для коротких запросов.""" + test_cases = [ + ('t', 't'), # 1 символ + ('te', 'te'), # 2 символа + ('на', 'на'), # 2 символа кириллица + ('', ''), # пустая строка + ] + for query, expected_value in test_cases: + self._test_strategy(query, 'name_only', expected_value) + + # ===== edge cases ===== + def test_edge_cases(self): + """Граничные и специальные случаи.""" + # Только символ @ + self._test_strategy('@', 'email_domain', '') + + # Множественные @ - берётся первый + self._test_strategy('test@example@com', 'email_full', 'test@example@com') + + # ===== real-world критические сценарии ===== + def test_real_world_email_prefix_no_false_match(self): + """ + КРИТИЧНЫЙ: query 'team_x3m@' НЕ должен найти 'natulj@bk.ru'. + Проверяем, что используется email_prefix (istartswith), а не universal (icontains). + """ + strategy, search_value = determine_search_strategy('team_x3m@') + self.assertEqual(strategy, 'email_prefix') + self.assertEqual(search_value, 'team_x3m') + # Важно: НЕ universal стратегия + self.assertNotEqual(strategy, 'universal') + + def test_real_world_domain_search(self): + """Real-world: '@bk' находит все email с @bk.*""" + self._test_strategy('@bk', 'email_domain', 'bk') + + def test_real_world_universal_search(self): + """Real-world: 'natul' находит и имя 'Наталья' и email 'natulj@bk.ru'""" + self._test_strategy('natul', 'universal', 'natul') + + +class IsQueryPhoneOnlyTestCase(TenantTestCase): + """ + Тесты для функции is_query_phone_only(). + + Проверяют, что функция правильно определяет, содержит ли query + только символы номера телефона (цифры, +, -, (), пробелы). + """ + + # ===== Должны вернуть True (только телефонные символы) ===== + def test_phone_only_digits(self): + """Query '295' должен вернуть True (только цифры)""" + self.assertTrue(is_query_phone_only('295')) + + def test_phone_only_single_digit(self): + """Query '5' должен вернуть True (одна цифра)""" + self.assertTrue(is_query_phone_only('5')) + + def test_phone_with_plus(self): + """Query '+375291234567' должен вернуть True""" + self.assertTrue(is_query_phone_only('+375291234567')) + + def test_phone_with_dashes(self): + """Query '029-123-45' должен вернуть True""" + self.assertTrue(is_query_phone_only('029-123-45')) + + def test_phone_with_parentheses(self): + """Query '(029) 123-45' должен вернуть True""" + self.assertTrue(is_query_phone_only('(029) 123-45')) + + def test_phone_with_spaces(self): + """Query '029 123 45' должен вернуть True""" + self.assertTrue(is_query_phone_only('029 123 45')) + + def test_phone_complex_format(self): + """Query '+375 (29) 123-45-67' должен вернуть True""" + self.assertTrue(is_query_phone_only('+375 (29) 123-45-67')) + + def test_phone_with_dot(self): + """Query '029.123.45' должен вернуть True""" + self.assertTrue(is_query_phone_only('029.123.45')) + + # ===== Должны вернуть False (содержат буквы или другие символы) ===== + def test_query_with_letters_only(self): + """Query 'abc' должен вернуть False (содержит буквы)""" + self.assertFalse(is_query_phone_only('abc')) + + def test_query_with_mixed_letters_digits(self): + """Query 'x3m' должен вернуть False (содержит буквы)""" + self.assertFalse(is_query_phone_only('x3m')) + + def test_query_name_with_digits(self): + """Query 'team_x3m' должен вернуть False (содержит буквы и _)""" + self.assertFalse(is_query_phone_only('team_x3m')) + + def test_query_name_cyrillic(self): + """Query 'Наталья' должен вернуть False (содержит кириллицу)""" + self.assertFalse(is_query_phone_only('Наталья')) + + def test_query_with_underscore(self): + """Query '123_456' должен вернуть False (содержит _)""" + self.assertFalse(is_query_phone_only('123_456')) + + def test_query_with_hash(self): + """Query '123#456' должен вернуть False (содержит #)""" + self.assertFalse(is_query_phone_only('123#456')) + + def test_empty_string(self): + """Query '' должен вернуть False (пустая строка)""" + self.assertFalse(is_query_phone_only('')) + + def test_only_spaces(self): + """Query ' ' должен вернуть False (пустой запрос)""" + self.assertFalse(is_query_phone_only(' ')) + + # ===== Real-world cases ===== + def test_real_world_case_x3m_should_not_be_phone(self): + """ + Real-world case: "x3m" содержит букву, поэтому НЕ похож на телефон. + Это критично для решения проблемы с поиском Натальи. + """ + self.assertFalse(is_query_phone_only('x3m')) + # Значит, при поиске "x3m" НЕ будет поиска по цифре "3" в телефонах + + def test_real_world_case_295_should_be_phone(self): + """Real-world case: '295' только цифры, похож на телефон""" + self.assertTrue(is_query_phone_only('295')) + + def test_real_world_full_phone_number(self): + """Real-world case: полный номер в стандартном формате""" + self.assertTrue(is_query_phone_only('+375 (29) 598-62-62')) diff --git a/myproject/customers/tests/test_system_customer.py b/myproject/customers/tests/test_system_customer.py new file mode 100644 index 0000000..18b38de --- /dev/null +++ b/myproject/customers/tests/test_system_customer.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +Тесты защиты системного клиента от изменений и удаления. + +Системный клиент используется для анонимных продаж в POS системе +и должен быть защищён от случайного изменения или удаления. + +Используем TenantTestCase для корректной работы с tenant-системой. +""" +from django.core.exceptions import ValidationError +from django_tenants.test.cases import TenantTestCase + +from customers.models import Customer + + +class SystemCustomerProtectionTestCase(TenantTestCase): + """ + Тесты защиты системного клиента от изменений и удаления. + + Системный клиент используется для анонимных продаж в POS системе + и должен быть защищён от случайного изменения или удаления. + """ + + def setUp(self): + """Создаём системного клиента для тестов.""" + self.system_customer, created = Customer.get_or_create_system_customer() + self.regular_customer = Customer.objects.create( + name="Обычный клиент", + email="regular@test.com" + ) + + def test_get_or_create_system_customer_creates_with_correct_attributes(self): + """ + Метод get_or_create_system_customer() создаёт клиента с правильными атрибутами. + + Проверяем: + - Фиксированный email: system@pos.customer + - Флаг is_system_customer = True + - Правильное имя и заметки + """ + # Удаляем существующего системного клиента для чистоты теста + Customer.objects.filter(is_system_customer=True).delete() + + # Создаём через метод класса + customer, created = Customer.get_or_create_system_customer() + + # Проверяем, что клиент действительно создан + self.assertTrue(created, "Системный клиент должен быть создан") + + # Проверяем атрибуты + self.assertEqual(customer.email, "system@pos.customer") + self.assertTrue(customer.is_system_customer) + self.assertEqual(customer.name, "АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS)") + self.assertIn("SYSTEM_CUSTOMER", customer.notes) + + # Проверяем идемпотентность - повторный вызов возвращает того же клиента + customer2, created2 = Customer.get_or_create_system_customer() + self.assertFalse(created2, "Системный клиент не должен создаваться повторно") + self.assertEqual(customer.pk, customer2.pk, "Должен вернуться тот же клиент") + + def test_system_customer_cannot_be_deleted(self): + """ + Системный клиент защищён от удаления через метод delete(). + + При попытке удаления должен подниматься ValidationError. + Это критично для работы POS системы. + """ + with self.assertRaises(ValidationError) as context: + self.system_customer.delete() + + self.assertIn("Нельзя удалить системного клиента", str(context.exception)) + + # Проверяем, что клиент действительно не удалён + self.assertTrue( + Customer.objects.filter(pk=self.system_customer.pk).exists(), + "Системный клиент не должен быть удалён" + ) + + def test_system_customer_email_cannot_be_changed(self): + """ + Email системного клиента защищён от изменения. + + Фиксированный email "system@pos.customer" используется для поиска + системного клиента в POS системе. Изменение приведёт к сбоям. + """ + original_email = self.system_customer.email + + # Пытаемся изменить email + self.system_customer.email = "hacker@evil.com" + + with self.assertRaises(ValidationError) as context: + self.system_customer.save() + + self.assertIn("Нельзя изменить email системного клиента", str(context.exception)) + + # Проверяем, что email остался прежним в БД + self.system_customer.refresh_from_db() + self.assertEqual(self.system_customer.email, original_email) + + def test_system_customer_flag_cannot_be_removed(self): + """ + Флаг is_system_customer защищён от изменения. + + Нельзя "превратить" системного клиента в обычного, + это нарушит логику POS системы. + """ + # Пытаемся снять флаг системного клиента + self.system_customer.is_system_customer = False + + with self.assertRaises(ValidationError) as context: + self.system_customer.save() + + self.assertIn("Нельзя изменить флаг системного клиента", str(context.exception)) + + # Проверяем, что флаг остался True в БД + self.system_customer.refresh_from_db() + self.assertTrue(self.system_customer.is_system_customer) + + def test_regular_customer_can_be_deleted_normally(self): + """ + Обычный клиент (не системный) может быть удалён без ограничений. + + Защита применяется ТОЛЬКО к системному клиенту. + Это гарантирует, что мы не сломали обычный функционал удаления. + """ + customer_pk = self.regular_customer.pk + + # Удаление должно пройти успешно + self.regular_customer.delete() + + # Проверяем, что клиент действительно удалён + self.assertFalse( + Customer.objects.filter(pk=customer_pk).exists(), + "Обычный клиент должен быть удалён" + ) diff --git a/myproject/customers/tests/test_wallet_balance.py b/myproject/customers/tests/test_wallet_balance.py new file mode 100644 index 0000000..4af4391 --- /dev/null +++ b/myproject/customers/tests/test_wallet_balance.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +""" +Тесты для вычисления баланса кошелька. + +Используем TenantTestCase для корректной работы с tenant-системой. +""" +from decimal import Decimal + +from django.core.cache import cache +from django_tenants.test.cases import TenantTestCase + +from customers.models import Customer, WalletTransaction + + +class WalletBalanceCalculationTestCase(TenantTestCase): + """Тесты вычисления баланса кошелька из транзакций.""" + + def setUp(self): + """Создаём тестового клиента и очищаем кеш.""" + self.customer = Customer.objects.create(name="Тестовый клиент") + cache.clear() + + def tearDown(self): + """Очищаем кеш после каждого теста.""" + cache.clear() + + def test_empty_wallet_returns_zero(self): + """Пустой кошелёк должен возвращать 0.""" + self.assertEqual(self.customer.wallet_balance, Decimal('0')) + + def test_single_deposit(self): + """Одно пополнение корректно учитывается.""" + WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('100.00'), + transaction_type='deposit', + balance_category='money' + ) + cache.clear() + self.assertEqual(self.customer.wallet_balance, Decimal('100.00')) + + def test_single_spend(self): + """Списание корректно учитывается (отрицательная сумма).""" + # Сначала пополняем + WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('100.00'), + transaction_type='deposit', + balance_category='money' + ) + # Затем списываем + WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('-30.00'), + transaction_type='spend', + balance_category='money' + ) + cache.clear() + self.assertEqual(self.customer.wallet_balance, Decimal('70.00')) + + def test_multiple_operations(self): + """Несколько операций подряд вычисляются корректно.""" + operations = [ + ('deposit', Decimal('200.00')), + ('spend', Decimal('-50.00')), + ('deposit', Decimal('100.00')), + ('spend', Decimal('-80.00')), + ('adjustment', Decimal('10.00')), + ] + + for txn_type, signed_amount in operations: + WalletTransaction.objects.create( + customer=self.customer, + signed_amount=signed_amount, + transaction_type=txn_type, + balance_category='money' + ) + + cache.clear() + # 200 - 50 + 100 - 80 + 10 = 180 + self.assertEqual(self.customer.wallet_balance, Decimal('180.00')) + + def test_amount_property_returns_absolute(self): + """Property amount возвращает абсолютное значение.""" + txn = WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('-50.00'), + transaction_type='spend', + balance_category='money' + ) + self.assertEqual(txn.amount, Decimal('50.00')) + + def test_cache_invalidation(self): + """Кеш инвалидируется методом invalidate_wallet_cache.""" + # Первый вызов - баланс 0 + self.assertEqual(self.customer.wallet_balance, Decimal('0')) + + # Добавляем транзакцию напрямую (без сервиса) + WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('100.00'), + transaction_type='deposit', + balance_category='money' + ) + + # Без инвалидации кеша - всё ещё 0 (закешировано) + self.assertEqual(self.customer.get_wallet_balance(use_cache=True), Decimal('0')) + + # После инвалидации - 100 + self.customer.invalidate_wallet_cache() + self.assertEqual(self.customer.wallet_balance, Decimal('100.00')) diff --git a/myproject/customers/tests/test_wallet_model.py b/myproject/customers/tests/test_wallet_model.py new file mode 100644 index 0000000..b8f464a --- /dev/null +++ b/myproject/customers/tests/test_wallet_model.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +Тесты для модели WalletTransaction. + +Используем TenantTestCase для корректной работы с tenant-системой. +""" +from decimal import Decimal + +from django.core.cache import cache +from django_tenants.test.cases import TenantTestCase + +from customers.models import Customer, WalletTransaction + + +class WalletTransactionModelTestCase(TenantTestCase): + """Тесты модели WalletTransaction.""" + + def setUp(self): + self.customer = Customer.objects.create(name="Тестовый клиент") + cache.clear() + + def test_str_representation_positive(self): + """__str__ для положительной суммы содержит +.""" + txn = WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('100.00'), + transaction_type='deposit', + balance_category='money' + ) + self.assertIn('+100', str(txn)) + + def test_str_representation_negative(self): + """__str__ для отрицательной суммы содержит -.""" + txn = WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('-50.00'), + transaction_type='spend', + balance_category='money' + ) + self.assertIn('-50', str(txn)) + + def test_default_balance_category(self): + """По умолчанию balance_category = 'money'.""" + txn = WalletTransaction.objects.create( + customer=self.customer, + signed_amount=Decimal('100.00'), + transaction_type='deposit' + ) + self.assertEqual(txn.balance_category, 'money') diff --git a/myproject/customers/tests/test_wallet_service.py b/myproject/customers/tests/test_wallet_service.py new file mode 100644 index 0000000..96c6037 --- /dev/null +++ b/myproject/customers/tests/test_wallet_service.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" +Тесты для сервиса кошелька (WalletService). + +Используем TenantTestCase для корректной работы с tenant-системой. +""" +from decimal import Decimal + +from django.core.cache import cache +from django_tenants.test.cases import TenantTestCase + +from customers.models import Customer +from customers.services.wallet_service import WalletService + + +class WalletServiceTestCase(TenantTestCase): + """Тесты WalletService.""" + + def setUp(self): + """Создаём тестового клиента.""" + self.customer = Customer.objects.create(name="Тестовый клиент") + cache.clear() + + def tearDown(self): + cache.clear() + + def test_create_transaction_deposit(self): + """create_transaction создаёт пополнение с положительной суммой.""" + txn = WalletService.create_transaction( + customer=self.customer, + amount=Decimal('50.00'), + transaction_type='deposit', + description='Тестовое пополнение' + ) + + self.assertEqual(txn.signed_amount, Decimal('50.00')) + self.assertEqual(txn.transaction_type, 'deposit') + self.assertEqual(txn.balance_after, Decimal('50.00')) + self.assertEqual(self.customer.wallet_balance, Decimal('50.00')) + + def test_create_transaction_spend(self): + """create_transaction создаёт списание с отрицательной суммой.""" + # Сначала пополняем + WalletService.create_transaction( + customer=self.customer, + amount=Decimal('100.00'), + transaction_type='deposit' + ) + + # Затем списываем + txn = WalletService.create_transaction( + customer=self.customer, + amount=Decimal('30.00'), + transaction_type='spend', + description='Тестовое списание' + ) + + self.assertEqual(txn.signed_amount, Decimal('-30.00')) + self.assertEqual(txn.transaction_type, 'spend') + self.assertEqual(txn.balance_after, Decimal('70.00')) + self.assertEqual(self.customer.wallet_balance, Decimal('70.00')) + + def test_create_transaction_spend_insufficient_funds(self): + """Списание при недостаточном балансе вызывает ValueError.""" + with self.assertRaises(ValueError) as context: + WalletService.create_transaction( + customer=self.customer, + amount=Decimal('100.00'), + transaction_type='spend' + ) + + self.assertIn('Недостаточно средств', str(context.exception)) + + def test_adjust_balance_positive(self): + """Положительная корректировка увеличивает баланс.""" + txn = WalletService.adjust_balance( + customer_id=self.customer.pk, + amount=Decimal('75.00'), + description='Тестовое пополнение администратором', + user=None + ) + + self.assertEqual(txn.signed_amount, Decimal('75.00')) + self.assertEqual(txn.transaction_type, 'adjustment') + self.assertEqual(self.customer.wallet_balance, Decimal('75.00')) + + def test_adjust_balance_negative(self): + """Отрицательная корректировка уменьшает баланс.""" + # Сначала пополняем + WalletService.adjust_balance( + customer_id=self.customer.pk, + amount=Decimal('100.00'), + description='Начальное пополнение', + user=None + ) + + # Отрицательная корректировка + txn = WalletService.adjust_balance( + customer_id=self.customer.pk, + amount=Decimal('-40.00'), + description='Списание администратором', + user=None + ) + + self.assertEqual(txn.signed_amount, Decimal('-40.00')) + self.assertEqual(self.customer.wallet_balance, Decimal('60.00')) + + def test_adjust_balance_negative_insufficient(self): + """Отрицательная корректировка с недостаточным балансом вызывает ValueError.""" + with self.assertRaises(ValueError) as context: + WalletService.adjust_balance( + customer_id=self.customer.pk, + amount=Decimal('-50.00'), + description='Списание', + user=None + ) + + self.assertIn('отрицательному балансу', str(context.exception)) + + def test_adjust_balance_requires_description(self): + """Корректировка без описания вызывает ValueError.""" + with self.assertRaises(ValueError) as context: + WalletService.adjust_balance( + customer_id=self.customer.pk, + amount=Decimal('50.00'), + description='', + user=None + ) + + self.assertIn('Описание обязательно', str(context.exception)) + + def test_adjust_balance_zero_amount_fails(self): + """Корректировка с нулевой суммой вызывает ValueError.""" + with self.assertRaises(ValueError) as context: + WalletService.adjust_balance( + customer_id=self.customer.pk, + amount=Decimal('0'), + description='Нулевая корректировка', + user=None + ) + + self.assertIn('не может быть нулевой', str(context.exception)) diff --git a/myproject/Анализ_тестов_customers.md b/myproject/Анализ_тестов_customers.md index 09ee83b..3591cf4 100644 --- a/myproject/Анализ_тестов_customers.md +++ b/myproject/Анализ_тестов_customers.md @@ -1,9 +1,9 @@ # Анализ тестового покрытия модуля Customers -**Дата анализа:** 27.12.2024 -**Общее количество тестов:** 45 ✅ (оптимизировано с 59) +**Дата анализа:** 27.12.2025 +**Общее количество тестов:** 50 ✅ (было 45, добавлено 5) **Статус выполнения:** ✅ Все тесты проходят -**Последнее обновление:** 27.12.2024 23:55 +**Последнее обновление:** 28.12.2025 00:20 --- @@ -58,7 +58,23 @@ --- -### 4. **Тесты WalletService (7 тестов)** ✅ Хорошее покрытие +### 5. **Тесты защиты системного клиента (5 тестов)** ✅ Отличное покрытие + +**Класс:** `SystemCustomerProtectionTestCase` + +**Что покрывается:** +- ✅ `get_or_create_system_customer()` создаёт клиента с правильными атрибутами +- ✅ Защита от удаления системного клиента (поднимается ValidationError) +- ✅ Защита email системного клиента от изменения +- ✅ Защита флага is_system_customer от изменения +- ✅ Обычные клиенты могут удаляться нормально + +**Оценка:** �︢ **Отличное покрытие** +**Важность:** 🔴 **Критично для работы POS системы** + +--- + +### 6. **Тесты WalletService (7 тестов)** ✅ Хорошее покрытие **Класс:** `WalletServiceTestCase` @@ -75,7 +91,7 @@ --- -### 5. **Тесты модели WalletTransaction (3 теста)** 🔴 Слабое покрытие +### 7. **Тесты модели WalletTransaction (3 теста)** 🔴 Слабое покрытие **Класс:** `WalletTransactionModelTestCase` @@ -138,13 +154,35 @@ return any(c.isdigit() for c in query) - ✅ Требуется хотя бы одна цифра для распознавания как телефона - ✅ Все 45 тестов проходят успешно +### 3. **Добавление тестов защиты системного клиента** ✅ Завершено + +**Было:** 0 тестов защиты +**Стало:** 5 полноценных тестов + +**Что сделано:** +- ✅ Создан новый класс `SystemCustomerProtectionTestCase` с 5 тестами +- ✅ Покрыты все критичные сценарии защиты: + - Создание системного клиента с правильными атрибутами + - Защита от удаления + - Защита email от изменения + - Защита флага is_system_customer + - Проверка, что обычные клиенты не затронуты +- ✅ Исправлена логика в `Customer.save()` - проверка original.is_system_customer +- ✅ Учтена tenant-система (используется TenantTestCase) + +**Результат:** +- ✅ Все 5 тестов проходят успешно +- ✅ Критичная функциональность POS системы теперь покрыта тестами +- ✅ Невозможно случайно сломать системного клиента + --- ## 🔄 СТАТИСТИКА ИЗМЕНЕНИЙ **Было тестов:** 59 -**Стало тестов:** 45 (-14 тестов, -24%) -**Покрытие:** Осталось на том же уровне 100% +**После оптимизации:** 45 (-14 тестов, -24%) +**Текущее количество:** 50 (+5 новых тестов защиты) +**Покрытие:** Осталось на высоком уровне + добавлена критичная защита **Улучшения кода:** - Убрано 56 строк дублирующегося кода тестов @@ -355,23 +393,35 @@ def test_only_spaces(self): ### 🔥 КРИТИЧНЫЕ (добавить СРОЧНО): -1. **Тесты модели Customer** (20+ тестов) +1. ~~**Тесты защиты системного клиента** (5 тестов)~~ ✅ **ВЫПОЛНЕНО 28.12.2025** + - ✅ Защита от удаления + - ✅ Защита email от изменения + - ✅ Защита флага is_system_customer + - ✅ `get_or_create_system_customer()` с правильными атрибутами + - ✅ Обычные клиенты не затронуты + +2. **Тесты модели Customer** (20+ тестов) - Валидация полей - Нормализация телефона - Защита системного клиента - Методы класса -2. **Тесты оплаты через кошелёк** (10+ тестов) +2. **Тесты модели Customer** (15+ тестов) + - Валидация полей + - Нормализация телефона + - Методы `__str__()`, `full_name` + +3. **Тесты оплаты через кошелёк** (10+ тестов) - `pay_with_wallet()` - Интеграция с заказами - Частичная оплата -3. **Тесты permissions и изоляции данных** (15+ тестов) +4. **Тесты permissions и изоляции данных** (15+ тестов) - Авторизация - Роли - Cross-tenant защита -4. **Тесты View-функций** (30+ тестов) +5. **Тесты View-функций** (30+ тестов) - customer_list - customer_detail - customer_create/update/delete @@ -381,11 +431,11 @@ def test_only_spaces(self): ### 🟠 ВАЖНЫЕ (добавить в течение месяца): -5. **Тесты форм** (10+ тестов) +6. **Тесты форм** (10+ тестов) - CustomerForm - ContactChannelForm -6. **Тесты импорта/экспорта** (15+ тестов) +7. **Тесты импорта/экспорта** (15+ тестов) - CSV импорт - Excel экспорт - Обработка ошибок @@ -394,9 +444,9 @@ def test_only_spaces(self): ### 🟡 ЖЕЛАТЕЛЬНЫЕ (когда будет время): -7. **Тесты ContactChannel** (5+ тестов) -8. **Тесты расчётов долга** (10+ тестов) -9. **Performance тесты** (поиск по 10000+ клиентам) +8. **Тесты ContactChannel** (5+ тестов) +9. **Тесты расчётов долга** (10+ тестов) +10. **Performance тесты** (поиск по 10000+ клиентам) --- @@ -421,18 +471,18 @@ def test_only_spaces(self): ### Немедленно: 1. ✅ ~~Оптимизировать избыточные тесты поиска~~ **ВЫПОЛНЕНО!** (23→9 тестов) 2. ✅ ~~Исправить логику `test_only_spaces`~~ **ВЫПОЛНЕНО!** -3. ⏳ Добавить базовые тесты модели Customer (20 тестов) -4. ⏳ Добавить тесты защиты системного клиента (5 тестов) +3. ✅ ~~Добавить тесты защиты системного клиента (5 тестов)~~ **ВЫПОЛНЕНО 28.12.2025!** +4. ⏳ Добавить базовые тесты модели Customer (15 тестов) 5. ⏳ Добавить тесты оплаты через кошелёк (10 тестов) 6. ⏳ Добавить тесты permissions (10 тестов) ### В течение недели: -5. Добавить тесты Views (30 тестов) -6. Добавить тесты форм (10 тестов) +7. Добавить тесты Views (30 тестов) +8. Добавить тесты форм (10 тестов) ### В течение месяца: -7. Добавить тесты импорта/экспорта (15 тестов) -8. ~~Рефакторинг: сократить дублирующиеся тесты поиска~~ ✅ **ВЫПОЛНЕНО!** +9. Добавить тесты импорта/экспорта (15 тестов) +10. ✅ ~~Рефакторинг: сократить дублирующиеся тесты поиска~~ **ВЫПОЛНЕНО!** --- @@ -453,11 +503,12 @@ def test_only_spaces(self): **Прогресс улучшений:** - ✅ Рефакторинг тестов поиска: -14 тестов, +0% качества - ✅ Исправлена логика валидации телефонов -- ⏳ Осталось добавить ~100 критичных тестов +- ✅ Добавлена защита системного клиента: +5 критичных тестов +- ⏳ Осталось добавить ~95 критичных тестов --- *Отчёт подготовлен автоматически на основе анализа кода.* -*Дата создания: 27.12.2024* -*Последнее обновление: 27.12.2024 23:55* -*Выполненные улучшения: Рефакторинг избыточных тестов, исправление логики валидации* +*Дата создания: 27.12.2025* +*Последнее обновление: 28.12.2025 00:20* +*Выполненные улучшения: Рефакторинг избыточных тестов, исправление логики валидации, добавление защиты системного клиента*