Добавлены тесты защиты системного клиента и рефакторинг структуры тестов
- Создан новый класс SystemCustomerProtectionTestCase с 5 критичными тестами - Тест создания системного клиента с правильными атрибутами - Тест защиты от удаления системного клиента (ValidationError) - Тест защиты email системного клиента от изменения - Тест защиты флага is_system_customer от изменения - Тест что обычные клиенты не затронуты защитой - Исправлена логика в Customer.save(): проверка теперь использует original.is_system_customer - Добавлен импорт ValidationError из django.core.exceptions - Рефакторинг структуры тестов customers: - Разделены тесты по отдельным модулям в папке customers/tests/ - test_search_strategies.py - тесты стратегий поиска - test_system_customer.py - тесты защиты системного клиента - test_wallet_balance.py - тесты баланса кошелька - test_wallet_service.py - тесты WalletService - test_wallet_model.py - тесты модели WalletTransaction - Обновлён анализ тестов: 50 тестов (было 45), все проходят успешно - Критичная функциональность POS системы теперь покрыта тестами - Учтена tenant-система (используется TenantTestCase)
This commit is contained in:
@@ -128,15 +128,17 @@ class Customer(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Защита системного клиента от изменений
|
# Защита системного клиента от изменений
|
||||||
if self.pk and self.is_system_customer:
|
if self.pk:
|
||||||
# Получаем оригинальный объект из БД
|
# Получаем оригинальный объект из БД
|
||||||
try:
|
try:
|
||||||
original = Customer.objects.get(pk=self.pk)
|
original = Customer.objects.get(pk=self.pk)
|
||||||
# Проверяем, не пытаются ли изменить критичные поля
|
# Проверяем, что это системный клиент в БД
|
||||||
if original.email != self.email:
|
if original.is_system_customer:
|
||||||
raise ValidationError("Нельзя изменить email системного клиента")
|
# Проверяем, не пытаются ли изменить критичные поля
|
||||||
if original.is_system_customer != self.is_system_customer:
|
if original.email != self.email:
|
||||||
raise ValidationError("Нельзя изменить флаг системного клиента")
|
raise ValidationError("Нельзя изменить email системного клиента")
|
||||||
|
if original.is_system_customer != self.is_system_customer:
|
||||||
|
raise ValidationError("Нельзя изменить флаг системного клиента")
|
||||||
except Customer.DoesNotExist:
|
except Customer.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
|
||||||
29
myproject/customers/tests/__init__.py
Normal file
29
myproject/customers/tests/__init__.py
Normal file
@@ -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',
|
||||||
|
]
|
||||||
203
myproject/customers/tests/test_search_strategies.py
Normal file
203
myproject/customers/tests/test_search_strategies.py
Normal file
@@ -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'))
|
||||||
135
myproject/customers/tests/test_system_customer.py
Normal file
135
myproject/customers/tests/test_system_customer.py
Normal file
@@ -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(),
|
||||||
|
"Обычный клиент должен быть удалён"
|
||||||
|
)
|
||||||
111
myproject/customers/tests/test_wallet_balance.py
Normal file
111
myproject/customers/tests/test_wallet_balance.py
Normal file
@@ -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'))
|
||||||
49
myproject/customers/tests/test_wallet_model.py
Normal file
49
myproject/customers/tests/test_wallet_model.py
Normal file
@@ -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')
|
||||||
142
myproject/customers/tests/test_wallet_service.py
Normal file
142
myproject/customers/tests/test_wallet_service.py
Normal file
@@ -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))
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Анализ тестового покрытия модуля Customers
|
# Анализ тестового покрытия модуля Customers
|
||||||
|
|
||||||
**Дата анализа:** 27.12.2024
|
**Дата анализа:** 27.12.2025
|
||||||
**Общее количество тестов:** 45 ✅ (оптимизировано с 59)
|
**Общее количество тестов:** 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 от изменения
|
||||||
|
- ✅ Обычные клиенты могут удаляться нормально
|
||||||
|
|
||||||
|
**Оценка:** <20>︢ **Отличное покрытие**
|
||||||
|
**Важность:** 🔴 **Критично для работы POS системы**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **Тесты WalletService (7 тестов)** ✅ Хорошее покрытие
|
||||||
|
|
||||||
**Класс:** `WalletServiceTestCase`
|
**Класс:** `WalletServiceTestCase`
|
||||||
|
|
||||||
@@ -75,7 +91,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. **Тесты модели WalletTransaction (3 теста)** 🔴 Слабое покрытие
|
### 7. **Тесты модели WalletTransaction (3 теста)** 🔴 Слабое покрытие
|
||||||
|
|
||||||
**Класс:** `WalletTransactionModelTestCase`
|
**Класс:** `WalletTransactionModelTestCase`
|
||||||
|
|
||||||
@@ -138,13 +154,35 @@ return any(c.isdigit() for c in query)
|
|||||||
- ✅ Требуется хотя бы одна цифра для распознавания как телефона
|
- ✅ Требуется хотя бы одна цифра для распознавания как телефона
|
||||||
- ✅ Все 45 тестов проходят успешно
|
- ✅ Все 45 тестов проходят успешно
|
||||||
|
|
||||||
|
### 3. **Добавление тестов защиты системного клиента** ✅ Завершено
|
||||||
|
|
||||||
|
**Было:** 0 тестов защиты
|
||||||
|
**Стало:** 5 полноценных тестов
|
||||||
|
|
||||||
|
**Что сделано:**
|
||||||
|
- ✅ Создан новый класс `SystemCustomerProtectionTestCase` с 5 тестами
|
||||||
|
- ✅ Покрыты все критичные сценарии защиты:
|
||||||
|
- Создание системного клиента с правильными атрибутами
|
||||||
|
- Защита от удаления
|
||||||
|
- Защита email от изменения
|
||||||
|
- Защита флага is_system_customer
|
||||||
|
- Проверка, что обычные клиенты не затронуты
|
||||||
|
- ✅ Исправлена логика в `Customer.save()` - проверка original.is_system_customer
|
||||||
|
- ✅ Учтена tenant-система (используется TenantTestCase)
|
||||||
|
|
||||||
|
**Результат:**
|
||||||
|
- ✅ Все 5 тестов проходят успешно
|
||||||
|
- ✅ Критичная функциональность POS системы теперь покрыта тестами
|
||||||
|
- ✅ Невозможно случайно сломать системного клиента
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 СТАТИСТИКА ИЗМЕНЕНИЙ
|
## 🔄 СТАТИСТИКА ИЗМЕНЕНИЙ
|
||||||
|
|
||||||
**Было тестов:** 59
|
**Было тестов:** 59
|
||||||
**Стало тестов:** 45 (-14 тестов, -24%)
|
**После оптимизации:** 45 (-14 тестов, -24%)
|
||||||
**Покрытие:** Осталось на том же уровне 100%
|
**Текущее количество:** 50 (+5 новых тестов защиты)
|
||||||
|
**Покрытие:** Осталось на высоком уровне + добавлена критичная защита
|
||||||
|
|
||||||
**Улучшения кода:**
|
**Улучшения кода:**
|
||||||
- Убрано 56 строк дублирующегося кода тестов
|
- Убрано 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()`
|
- `pay_with_wallet()`
|
||||||
- Интеграция с заказами
|
- Интеграция с заказами
|
||||||
- Частичная оплата
|
- Частичная оплата
|
||||||
|
|
||||||
3. **Тесты permissions и изоляции данных** (15+ тестов)
|
4. **Тесты permissions и изоляции данных** (15+ тестов)
|
||||||
- Авторизация
|
- Авторизация
|
||||||
- Роли
|
- Роли
|
||||||
- Cross-tenant защита
|
- Cross-tenant защита
|
||||||
|
|
||||||
4. **Тесты View-функций** (30+ тестов)
|
5. **Тесты View-функций** (30+ тестов)
|
||||||
- customer_list
|
- customer_list
|
||||||
- customer_detail
|
- customer_detail
|
||||||
- customer_create/update/delete
|
- customer_create/update/delete
|
||||||
@@ -381,11 +431,11 @@ def test_only_spaces(self):
|
|||||||
|
|
||||||
### 🟠 ВАЖНЫЕ (добавить в течение месяца):
|
### 🟠 ВАЖНЫЕ (добавить в течение месяца):
|
||||||
|
|
||||||
5. **Тесты форм** (10+ тестов)
|
6. **Тесты форм** (10+ тестов)
|
||||||
- CustomerForm
|
- CustomerForm
|
||||||
- ContactChannelForm
|
- ContactChannelForm
|
||||||
|
|
||||||
6. **Тесты импорта/экспорта** (15+ тестов)
|
7. **Тесты импорта/экспорта** (15+ тестов)
|
||||||
- CSV импорт
|
- CSV импорт
|
||||||
- Excel экспорт
|
- Excel экспорт
|
||||||
- Обработка ошибок
|
- Обработка ошибок
|
||||||
@@ -394,9 +444,9 @@ def test_only_spaces(self):
|
|||||||
|
|
||||||
### 🟡 ЖЕЛАТЕЛЬНЫЕ (когда будет время):
|
### 🟡 ЖЕЛАТЕЛЬНЫЕ (когда будет время):
|
||||||
|
|
||||||
7. **Тесты ContactChannel** (5+ тестов)
|
8. **Тесты ContactChannel** (5+ тестов)
|
||||||
8. **Тесты расчётов долга** (10+ тестов)
|
9. **Тесты расчётов долга** (10+ тестов)
|
||||||
9. **Performance тесты** (поиск по 10000+ клиентам)
|
10. **Performance тесты** (поиск по 10000+ клиентам)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -421,18 +471,18 @@ def test_only_spaces(self):
|
|||||||
### Немедленно:
|
### Немедленно:
|
||||||
1. ✅ ~~Оптимизировать избыточные тесты поиска~~ **ВЫПОЛНЕНО!** (23→9 тестов)
|
1. ✅ ~~Оптимизировать избыточные тесты поиска~~ **ВЫПОЛНЕНО!** (23→9 тестов)
|
||||||
2. ✅ ~~Исправить логику `test_only_spaces`~~ **ВЫПОЛНЕНО!**
|
2. ✅ ~~Исправить логику `test_only_spaces`~~ **ВЫПОЛНЕНО!**
|
||||||
3. ⏳ Добавить базовые тесты модели Customer (20 тестов)
|
3. ✅ ~~Добавить тесты защиты системного клиента (5 тестов)~~ **ВЫПОЛНЕНО 28.12.2025!**
|
||||||
4. ⏳ Добавить тесты защиты системного клиента (5 тестов)
|
4. ⏳ Добавить базовые тесты модели Customer (15 тестов)
|
||||||
5. ⏳ Добавить тесты оплаты через кошелёк (10 тестов)
|
5. ⏳ Добавить тесты оплаты через кошелёк (10 тестов)
|
||||||
6. ⏳ Добавить тесты permissions (10 тестов)
|
6. ⏳ Добавить тесты permissions (10 тестов)
|
||||||
|
|
||||||
### В течение недели:
|
### В течение недели:
|
||||||
5. Добавить тесты Views (30 тестов)
|
7. Добавить тесты Views (30 тестов)
|
||||||
6. Добавить тесты форм (10 тестов)
|
8. Добавить тесты форм (10 тестов)
|
||||||
|
|
||||||
### В течение месяца:
|
### В течение месяца:
|
||||||
7. Добавить тесты импорта/экспорта (15 тестов)
|
9. Добавить тесты импорта/экспорта (15 тестов)
|
||||||
8. ~~Рефакторинг: сократить дублирующиеся тесты поиска~~ ✅ **ВЫПОЛНЕНО!**
|
10. ✅ ~~Рефакторинг: сократить дублирующиеся тесты поиска~~ **ВЫПОЛНЕНО!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -453,11 +503,12 @@ def test_only_spaces(self):
|
|||||||
**Прогресс улучшений:**
|
**Прогресс улучшений:**
|
||||||
- ✅ Рефакторинг тестов поиска: -14 тестов, +0% качества
|
- ✅ Рефакторинг тестов поиска: -14 тестов, +0% качества
|
||||||
- ✅ Исправлена логика валидации телефонов
|
- ✅ Исправлена логика валидации телефонов
|
||||||
- ⏳ Осталось добавить ~100 критичных тестов
|
- ✅ Добавлена защита системного клиента: +5 критичных тестов
|
||||||
|
- ⏳ Осталось добавить ~95 критичных тестов
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Отчёт подготовлен автоматически на основе анализа кода.*
|
*Отчёт подготовлен автоматически на основе анализа кода.*
|
||||||
*Дата создания: 27.12.2024*
|
*Дата создания: 27.12.2025*
|
||||||
*Последнее обновление: 27.12.2024 23:55*
|
*Последнее обновление: 28.12.2025 00:20*
|
||||||
*Выполненные улучшения: Рефакторинг избыточных тестов, исправление логики валидации*
|
*Выполненные улучшения: Рефакторинг избыточных тестов, исправление логики валидации, добавление защиты системного клиента*
|
||||||
|
|||||||
Reference in New Issue
Block a user