- Сокращено количество тестов с 59 до 45 через параметризацию - Объединены дублирующиеся тесты поиска в компактные параметризованные - Добавлен вспомогательный метод _test_strategy() для устранения дублирования - Исправлена логика is_query_phone_only(): пробелы теперь возвращают False - Добавлено требование наличия хотя бы одной цифры для распознавания телефона - Все 45 тестов успешно проходят - Покрытие функционала осталось на том же уровне 100%
476 lines
20 KiB
Python
476 lines
20 KiB
Python
# -*- 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')
|