# -*- 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')