test: добавлены тесты для сигналов и migration команда

- Добавлена management команда migrate_requests_to_actions для миграции существующих заявок
- Добавлен conftest.py с фикстурой user_factory для тестов requests_app
- Добавлены 13 тестов для проверки автоматизации создания EmployeeAction
- Покрытие: немедленные/отложенные события, дубликаты, условия срабатывания, маппинги
This commit is contained in:
lizerk 2026-03-18 09:42:13 +03:00
parent c5a8455421
commit 46daefcdec
5 changed files with 524 additions and 0 deletions

View File

@ -0,0 +1 @@
# Management commands package

View File

@ -0,0 +1 @@
# Management commands

View File

@ -0,0 +1,183 @@
"""
Management команда для миграции существующих заявок в кадровые события.
Использование:
python manage.py migrate_requests_to_actions [--dry-run]
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from requests_app.models import Request
from requests_app.enums import RequestStatus
from employees.models import EmployeeAction
from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING
class Command(BaseCommand):
help = 'Создает EmployeeAction для всех одобренных заявок, у которых их еще нет'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Показать что будет создано, но не создавать',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('=== DRY RUN MODE ==='))
# Объединяем все маппинги
all_mappings = {**IMMEDIATE_ACTION_MAPPING, **SCHEDULED_ACTION_MAPPING}
# Находим все одобренные заявки с типами, которые требуют EmployeeAction
approved_requests = Request.objects.filter(
status=RequestStatus.APPROVED,
type__in=all_mappings.keys()
).select_related('employee', 'approver').order_by('created_at')
total_requests = approved_requests.count()
self.stdout.write(
f'Найдено {total_requests} одобренных заявок типов: {list(all_mappings.keys())}'
)
created_count = 0
skipped_count = 0
errors = []
for request in approved_requests:
action_type = all_mappings.get(request.type)
if not action_type:
continue
# Проверяем, существует ли уже событие для этой заявки
existing = EmployeeAction.objects.filter(
extra__request_id=request.id,
action=action_type
).exists()
if existing:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f' SKIP: Request #{request.id} ({request.type}) - '
f'EmployeeAction уже существует'
)
)
continue
# Определяем дату события
action_date = request.date_from or request.decided_at or request.created_at or timezone.now()
# Формируем комментарий
action_comment = f"Заявление #{request.id}"
if request.comment:
action_comment += f": {request.comment[:200]}"
# Готовим extra данные
extra_data = {
'request_id': request.id,
'approved_by': request.approver.id if request.approver else None,
'migrated': True, # Помечаем что создано миграцией
'migration_date': timezone.now().isoformat()
}
if dry_run:
# action_date может быть date или datetime
date_str = action_date.date() if hasattr(action_date, 'date') else action_date
self.stdout.write(
self.style.SUCCESS(
f' WOULD CREATE: Request #{request.id} ({request.type}) → '
f'EmployeeAction ({action_type}) for {request.employee} '
f'on {date_str}'
)
)
created_count += 1
else:
try:
with transaction.atomic():
action = EmployeeAction.objects.create(
employee=request.employee,
action=action_type,
date=action_date,
comment=action_comment,
extra=extra_data
)
# Применяем эффекты (деактивация, LDAP sync)
self._apply_effects(action)
created_count += 1
self.stdout.write(
self.style.SUCCESS(
f' ✓ Created: EmployeeAction #{action.id} ({action_type}) '
f'from Request #{request.id} for {request.employee}'
)
)
except Exception as e:
errors.append((request.id, str(e)))
self.stdout.write(
self.style.ERROR(
f' ✗ Failed: Request #{request.id} - {e}'
)
)
# Итоговая статистика
self.stdout.write('\n' + '='*70)
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - изменения не применены'))
self.stdout.write(
self.style.SUCCESS(
f'Обработано заявок: {total_requests}'
)
)
if created_count:
verb = 'Будет создано' if dry_run else 'Создано'
self.stdout.write(
self.style.SUCCESS(
f'{verb} событий: {created_count}'
)
)
if skipped_count:
self.stdout.write(
self.style.WARNING(
f'Пропущено (уже существуют): {skipped_count}'
)
)
if errors:
self.stdout.write(
self.style.ERROR(
f'\nОшибки ({len(errors)}):'
)
)
for req_id, error in errors:
self.stdout.write(f' Request #{req_id}: {error}')
if not dry_run and created_count > 0:
self.stdout.write(
self.style.SUCCESS(
'\n✓ Миграция завершена успешно!'
)
)
def _apply_effects(self, action):
"""Применяет эффекты кадрового события (как в EmployeeActionViewSet)."""
try:
from api.v1.employees.views.actions import EmployeeActionViewSet
viewset = EmployeeActionViewSet()
viewset._apply_effects(action)
except Exception as e:
# Не критично если эффекты не применились
self.stdout.write(
self.style.WARNING(
f' Warning: Could not apply effects for action #{action.id}: {e}'
)
)

View File

@ -0,0 +1,41 @@
"""
Conftest для тестов requests_app - импортирует общие фикстуры.
"""
import pytest
from django.conf import settings
@pytest.fixture
def user_factory():
"""Фабрика для создания пользователей."""
from employees.models import Employee
def _make(
email: str = None,
*,
first_name: str = "Test",
last_name: str = "User",
active: bool = True,
**extra
):
import uuid
if not email:
email = f"test+{uuid.uuid4().hex[:8]}@example.com"
# Генерируем уникальные phone_number и username
unique_id = uuid.uuid4().hex[:8]
if 'phone_number' not in extra:
extra['phone_number'] = f"+7900{unique_id}"
if 'username' not in extra:
extra['username'] = f"user_{unique_id}"
user = Employee.objects.create(
email=email,
first_name=first_name,
last_name=last_name,
is_active=active,
**extra
)
return user
return _make

View File

@ -0,0 +1,298 @@
"""
Тесты для автоматического создания EmployeeAction из заявок (signals).
"""
import pytest
from datetime import date, timedelta
from unittest.mock import patch, MagicMock
from django.utils import timezone
from requests_app.models import Request
from requests_app.enums import RequestStatus
from employees.models import EmployeeAction
from employees.signals import IMMEDIATE_ACTION_MAPPING, SCHEDULED_ACTION_MAPPING
@pytest.mark.django_db
class TestRequestSignals:
"""Тесты сигналов создания EmployeeAction из Request."""
def test_immediate_action_dismissal(self, user_factory):
"""Увольнение создаёт EmployeeAction немедленно при одобрении."""
employee = user_factory()
approver = user_factory()
# Создаём заявку на увольнение
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
date_from=date.today(),
comment='Увольнение по собственному желанию'
)
# Проверяем что события ещё нет
assert not EmployeeAction.objects.filter(
extra__request_id=request.id
).exists()
# Одобряем заявку
request.status = RequestStatus.APPROVED
request.approver = approver
request.decided_at = timezone.now()
request.save()
# Проверяем что событие создалось
action = EmployeeAction.objects.get(
employee=employee,
action='dismissed',
extra__request_id=request.id
)
assert action.action == 'dismissed'
assert action.extra['request_id'] == request.id
assert action.extra['approved_by'] == approver.id
assert action.extra['immediate'] is True
assert 'Заявление #' in action.comment
def test_immediate_action_transfer(self, user_factory):
"""Перевод создаёт EmployeeAction немедленно при одобрении."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='transfer',
status=RequestStatus.PENDING,
date_from=date.today(),
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Проверяем событие
action = EmployeeAction.objects.get(
employee=employee,
action='transferred',
extra__request_id=request.id
)
assert action.action == 'transferred'
assert action.extra['immediate'] is True
@patch('employees.signals._schedule_delayed_action')
def test_scheduled_action_vacation(self, mock_schedule, user_factory):
"""Отпуск запускает планирование через Celery."""
employee = user_factory()
approver = user_factory()
future_date = date.today() + timedelta(days=7)
request = Request.objects.create(
employee=employee,
type='vacation',
status=RequestStatus.PENDING,
date_from=future_date,
date_to=future_date + timedelta(days=10),
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Проверяем что вызвалась функция планирования
mock_schedule.assert_called_once()
called_request = mock_schedule.call_args[0][0]
assert called_request.id == request.id
@patch('employees.signals._schedule_delayed_action')
def test_scheduled_action_sick_leave(self, mock_schedule, user_factory):
"""Больничный запускает планирование через Celery."""
employee = user_factory()
approver = user_factory()
future_date = date.today() + timedelta(days=3)
request = Request.objects.create(
employee=employee,
type='sick_leave',
status=RequestStatus.PENDING,
date_from=future_date,
)
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
mock_schedule.assert_called_once()
def test_no_duplicate_actions(self, user_factory):
"""Повторное сохранение одобренной заявки не создаёт дубликаты."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
date_from=date.today(),
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Первое событие создалось
assert EmployeeAction.objects.filter(
employee=employee,
action='dismissed',
extra__request_id=request.id
).count() == 1
# Меняем комментарий и сохраняем снова
request.comment = 'Обновлённый комментарий'
request.save()
# Дубликат не создался
assert EmployeeAction.objects.filter(
employee=employee,
action='dismissed',
extra__request_id=request.id
).count() == 1
def test_signal_not_fired_on_creation(self, user_factory):
"""Сигнал не срабатывает при создании (только при одобрении)."""
employee = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
)
# События нет
assert not EmployeeAction.objects.filter(
extra__request_id=request.id
).exists()
def test_signal_not_fired_on_rejection(self, user_factory):
"""Сигнал не срабатывает при отклонении."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
)
# Отклоняем
request.status = RequestStatus.REJECTED
request.approver = approver
request.save()
# События нет
assert not EmployeeAction.objects.filter(
extra__request_id=request.id
).exists()
def test_unknown_request_type_no_action(self, user_factory):
"""Неизвестный тип заявки не создаёт события."""
employee = user_factory()
approver = user_factory()
# Используем тип, который не в маппингах
request = Request.objects.create(
employee=employee,
type='other', # Не в IMMEDIATE/SCHEDULED маппингах
status=RequestStatus.PENDING,
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# События нет
assert not EmployeeAction.objects.filter(
employee=employee,
extra__request_id=request.id
).exists()
def test_action_date_from_request_date_from(self, user_factory):
"""Дата события берётся из date_from заявки."""
employee = user_factory()
approver = user_factory()
specific_date = date(2026, 6, 15)
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
date_from=specific_date,
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
action = EmployeeAction.objects.get(
employee=employee,
action='dismissed',
extra__request_id=request.id
)
# Проверяем дату (может быть datetime, поэтому берём .date())
action_date = action.date.date() if hasattr(action.date, 'date') else action.date
assert action_date == specific_date
@patch('employees.signals._apply_action_effects')
def test_effects_applied_for_immediate_actions(self, mock_apply, user_factory):
"""Эффекты применяются для немедленных действий."""
employee = user_factory()
approver = user_factory()
request = Request.objects.create(
employee=employee,
type='dismissal',
status=RequestStatus.PENDING,
)
# Одобряем
request.status = RequestStatus.APPROVED
request.approver = approver
request.save()
# Проверяем что _apply_action_effects была вызвана
mock_apply.assert_called_once()
created_action = mock_apply.call_args[0][0]
assert created_action.action == 'dismissed'
@pytest.mark.django_db
class TestRequestMappings:
"""Тесты корректности маппингов типов заявок."""
def test_immediate_mapping_complete(self):
"""Проверка что все немедленные маппинги правильные."""
assert IMMEDIATE_ACTION_MAPPING == {
'transfer': 'transferred',
'dismissal': 'dismissed',
}
def test_scheduled_mapping_complete(self):
"""Проверка что все отложенные маппинги правильные."""
assert SCHEDULED_ACTION_MAPPING == {
'vacation': 'on_leave',
'sick_leave': 'on_sick_leave',
}
def test_no_overlap_in_mappings(self):
"""Проверка что маппинги не пересекаются."""
immediate_keys = set(IMMEDIATE_ACTION_MAPPING.keys())
scheduled_keys = set(SCHEDULED_ACTION_MAPPING.keys())
assert immediate_keys.isdisjoint(scheduled_keys), \
"Request types должны быть либо immediate, либо scheduled, но не оба"