From ad8208789fd1bd59d8ecc51eed4f28217e6fb456 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Mon, 16 Feb 2026 17:31:48 +0300 Subject: [PATCH] feat: add LDAP testing infrastructure - Add LDAP docker container structure (01-base-structure.ldif) - Add .env.test with LDAP configuration for tests - Create tests/ldap_fixtures.py with LDAP-specific fixtures - Create tests/api/v1/employees/test_helpers.py with centralized helpers - Add LDAP markers to pytest.ini (ldap_required, ldap_optional) - Add comprehensive LDAP testing documentation - Fix test_department_head_rights.py to use centralized helpers New fixtures: - ldap_available: checks if LDAP server is accessible - ldap_config: returns LDAP configuration - ensure_ldap_enabled/disabled: force LDAP state for test - ldap_cleanup: auto-cleanup LDAP entries after test New helpers: - make_user(): centralized user creation - grant_permission(): permission assignment - make_department/position/role(): entity creation - extract_results(): paginated response helper Infrastructure: - OU=Users, OU=Departments, OU=Groups, OU=Positions, OU=Dismissed - Test groups: Developers, Managers - Test departments: IT, HR --- backend/docs/testing/LDAP_TESTING.md | 246 ++++++++++++++++++ backend/pytest.ini | 2 + .../scripts/ldap/init/01-base-structure.ldif | 67 +++++ backend/scripts/update_test_helpers.py | 130 +++++++++ .../employees/test_department_head_rights.py | 20 +- .../tests/api/v1/employees/test_helpers.py | 168 ++++++++++++ backend/tests/conftest.py | 3 + backend/tests/ldap_fixtures.py | 137 ++++++++++ 8 files changed, 755 insertions(+), 18 deletions(-) create mode 100644 backend/docs/testing/LDAP_TESTING.md create mode 100644 backend/scripts/ldap/init/01-base-structure.ldif create mode 100644 backend/scripts/update_test_helpers.py create mode 100644 backend/tests/api/v1/employees/test_helpers.py create mode 100644 backend/tests/ldap_fixtures.py diff --git a/backend/docs/testing/LDAP_TESTING.md b/backend/docs/testing/LDAP_TESTING.md new file mode 100644 index 00000000..f75e0fb4 --- /dev/null +++ b/backend/docs/testing/LDAP_TESTING.md @@ -0,0 +1,246 @@ +# Инструкция по запуску тестов с LDAP + +## Подготовка тестового окружения + +### 1. Запуск LDAP контейнера + +```bash +# Запустить LDAP с профилем +docker-compose --profile ldap up -d ldap + +# Проверить что контейнер запущен +docker ps | grep eusrr-ldap + +# Просмотр логов +docker-compose logs -f ldap +``` + +### 2. Настройка переменных окружения для тестов + +Скопируйте файл `.env.test` в `.env` или установите переменные: + +```bash +cp .env.test .env +``` + +Ключевые параметры для тестов: +```env +LDAP_ENABLED=true +LDAP_URI=ldap://localhost:389 +LDAP_BIND_DN=cn=admin,dc=eusrr,dc=local +LDAP_BIND_PASSWORD=change-me-redacted-secret +LDAP_TLS_REQUIRED=false + +# Базовые DN +LDAP_BASE_DN=dc=eusrr,dc=local +LDAP_USERS_BASE=OU=Users,dc=eusrr,dc=local +LDAP_DEPARTMENTS_BASE=OU=Departments,dc=eusrr,dc=local +LDAP_GROUPS_BASE=OU=Groups,dc=eusrr,dc=local +LDAP_POSITIONS_BASE=OU=Positions,dc=eusrr,dc=local +LDAP_DISMISSED_BASE=OU=Dismissed,dc=eusrr,dc=local +``` + +### 3. Инициализация LDAP структуры + +LDAP структура инициализируется автоматически при первом запуске контейнера из файла: +`backend/scripts/ldap/init/01-base-structure.ldif` + +Структура включает: +- OU=Users - пользователи +- OU=Departments - отделы +- OU=Groups - группы безопасности +- OU=Positions - должности +- OU=Dismissed - уволенные + +### 4. Проверка подключения к LDAP + +```bash +# Из контейнера +docker exec -it eusrr-ldap ldapsearch -x -H ldap://localhost -b "dc=eusrr,dc=local" -D "cn=admin,dc=eusrr,dc=local" -w change-me-redacted-secret + +# Или через phpLDAPadmin +docker-compose --profile ldap up -d ldap-admin +# Открыть http://localhost:8080 +# Login DN: cn=admin,dc=eusrr,dc=local +# Password: change-me-redacted-secret +``` + +## Запуск тестов + +### Все тесты employees API + +```bash +cd backend +../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v +``` + +### Только LDAP тесты + +```bash +# Тесты требующие LDAP +../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v -m ldap_required + +# Тесты опциональные (работают с LDAP и без) +../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v -m ldap_optional +``` + +### Тесты без LDAP + +```bash +# Отключить LDAP для тестов +LDAP_ENABLED=false ../.venv/Scripts/python -m pytest tests/api/v1/employees/ -v +``` + +### Параметризованные тесты (с LDAP и без) + +Многие тесты теперь параметризованы для проверки обоих режимов: + +```python +@pytest.mark.ldap_optional +@pytest.mark.parametrize("ldap_enabled", [True, False]) +def test_create_employee(ldap_enabled, settings): + settings.LDAP_ENABLED = ldap_enabled + # тест работает в обоих режимах +``` + +## Отладка + +### Просмотр LDAP логов в реальном времени + +```bash +docker-compose logs -f ldap +``` + +### Очистка LDAP данных + +```bash +# Остановить и удалить контейнер с данными +docker-compose --profile ldap down -v + +# Запустить заново +docker-compose --profile ldap up -d ldap +``` + +### Проверка LDAP операций из Django shell + +```bash +../.venv/Scripts/python manage.py shell +``` + +```python +from employees.ldap.infrastructure.connections import _ldap +from employees.ldap.directory_service import DirectoryService + +# Проверка подключения +with _ldap() as conn: + print(conn.bind()) # должно быть True + +# Проверка создания пользователя +ds = DirectoryService() +# ... тесты операций +``` + +## Структура тестов + +### Новые фикстуры (tests/ldap_fixtures.py): + +- `ldap_available` - проверяет доступность LDAP +- `ldap_config` - конфигурация LDAP для тестов +- `ensure_ldap_enabled` - принудительно включает LDAP для теста +- `ensure_ldap_disabled` - принудительно выключает LDAP для теста +- `ldap_cleanup` - автоматическая очистка после теста + +### Централизованные helpers (tests/api/v1/employees/test_helpers.py): + +- `make_user()` - создание пользователя +- `grant_permission()` - выдача разрешений +- `make_department()` - создание отдела +- `make_position()` - создание должности +- `extract_results()` - извлечение из пагинации + +## Маркеры pytest + +- `@pytest.mark.ldap_required` - тест требует LDAP (автоматически скипается если недоступен) +- `@pytest.mark.ldap_optional` - тест может работать с LDAP и без + +## Примеры тестов + +### Тест только с LDAP: + +```python +@pytest.mark.ldap_required +def test_ldap_sync(api_client, ldap_cleanup): + # Тест пропускается если LDAP недоступен + user = make_user(email="test@example.com") + ldap_cleanup.add_for_deletion(user.ldap_dn) + # ... +``` + +### Параметризованный тест: + +```python +@pytest.mark.ldap_optional +@pytest.mark.parametrize("with_ldap", [True, False]) +def test_employee_creation(api_client, with_ldap, settings): + if with_ldap: + settings.LDAP_ENABLED = True + else: + settings.LDAP_ENABLED = False + + # Тест проверяет оба сценария + # ... +``` + +## Troubleshooting + +### Ошибка: "LDAP server is unavailable" + +1. Проверьте что контейнер запущен: `docker ps | grep ldap` +2. Проверьте логи: `docker-compose logs ldap` +3. Проверьте порты: `netstat -an | grep 389` +4. Перезапустите контейнер: `docker-compose restart ldap` + +### Ошибка: "LDAP_POSITIONS_BASE is not configured" + +Добавьте в `.env`: +```env +LDAP_POSITIONS_BASE=OU=Positions,dc=eusrr,dc=local +``` + +### Тесты падают с "AttributeError: 'module' object has no attribute '_is_ldap_enabled'" + +После рефакторинга views разделены на модули. Обновите патчи: +```python +# Старый путь +@patch('api.v1.employees.views._is_ldap_enabled') + +# Новый путь +@patch('api.v1.employees.views._helpers._is_ldap_enabled') +``` + +## CI/CD Integration + +### GitHub Actions пример: + +```yaml +services: + ldap: + image: osixia/openldap:1.5.0 + env: + LDAP_DOMAIN: eusrr.local + LDAP_ADMIN_PASSWORD: change-me-redacted-secret + ports: + - 389:389 + +steps: + - name: Run tests with LDAP + env: + LDAP_ENABLED: true + LDAP_URI: ldap://localhost:389 + run: pytest tests/api/v1/employees/ -m ldap_required + + - name: Run tests without LDAP + env: + LDAP_ENABLED: false + run: pytest tests/api/v1/employees/ -m "not ldap_required" +``` diff --git a/backend/pytest.ini b/backend/pytest.ini index 4fab77ab..17a0be46 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -12,6 +12,8 @@ markers = integration: marks tests as integration tests slow: marks tests as slow running asyncio: marks tests as asynchronous tests + ldap_required: тест требует работающий LDAP сервер + ldap_optional: тест может работать с LDAP и без него (параметризован) # Исключить manual tests по умолчанию addopts = -m "not manual" --strict-markers diff --git a/backend/scripts/ldap/init/01-base-structure.ldif b/backend/scripts/ldap/init/01-base-structure.ldif new file mode 100644 index 00000000..f3442541 --- /dev/null +++ b/backend/scripts/ldap/init/01-base-structure.ldif @@ -0,0 +1,67 @@ +# Базовая структура для тестового LDAP +# Создается при инициализации контейнера OpenLDAP + +# OU для пользователей (Users) +dn: OU=Users,dc=eusrr,dc=local +objectClass: organizationalUnit +objectClass: top +ou: Users +description: Контейнер для пользователей EUSRR + +# OU для отделов (Departments) +dn: OU=Departments,dc=eusrr,dc=local +objectClass: organizationalUnit +objectClass: top +ou: Departments +description: Контейнер для отделов + +# OU для групп (Groups) +dn: OU=Groups,dc=eusrr,dc=local +objectClass: organizationalUnit +objectClass: top +ou: Groups +description: Контейнер для групп безопасности + +# OU для должностей (Positions) +dn: OU=Positions,dc=eusrr,dc=local +objectClass: organizationalUnit +objectClass: top +ou: Positions +description: Контейнер для должностей + +# OU для уволенных (Dismissed) +dn: OU=Dismissed,dc=eusrr,dc=local +objectClass: organizationalUnit +objectClass: top +ou: Dismissed +description: Контейнер для уволенных пользователей + +# Тестовый отдел IT +dn: OU=IT,OU=Departments,dc=eusrr,dc=local +objectClass: organizationalUnit +objectClass: top +ou: IT +description: IT Department + +# Тестовый отдел HR +dn: OU=HR,OU=Departments,dc=eusrr,dc=local +objectClass: organizationalUnit +objectClass: top +ou: HR +description: HR Department + +# Группа безопасности - Developers +dn: CN=Developers,OU=Groups,dc=eusrr,dc=local +objectClass: groupOfNames +objectClass: top +cn: Developers +description: Developers group +member: cn=admin,dc=eusrr,dc=local + +# Группа безопасности - Managers +dn: CN=Managers,OU=Groups,dc=eusrr,dc=local +objectClass: groupOfNames +objectClass: top +cn: Managers +description: Managers group +member: cn=admin,dc=eusrr,dc=local diff --git a/backend/scripts/update_test_helpers.py b/backend/scripts/update_test_helpers.py new file mode 100644 index 00000000..32b3edc8 --- /dev/null +++ b/backend/scripts/update_test_helpers.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +""" +Скрипт для обновления тестов - заменяет локальные helper функции на импорты из test_helpers. +""" +import re +import sys +from pathlib import Path + +# Файлы для обработки +test_dir = Path("tests/api/v1/employees") + +files_to_update = [ + "test_department_head_rights.py", + "test_department_membership_separation.py", + "test_department_roles.py", + "test_department_roles_extra.py", + "test_departments.py", + "test_email_verification_security.py", + "test_employee_actions.py", + "test_employees.py", + "test_employees_fields_in_list.py", + "test_ldap_optional_groups.py", + "test_positions.py", + "test_role_assignment.py", + "test_skills.py", +] + +def add_import_if_missing(content: str) -> str: + """Добавляет импорт test_helpers если его нет.""" + import_line = "from tests.api.v1.employees.test_helpers import make_user, grant_permission, make_department, extract_results" + + if "test_helpers" in content: + return content + + # Находим последний import + import_pattern = r'^(import |from )' + lines = content.split('\n') + last_import_idx = 0 + + for i, line in enumerate(lines): + if re.match(import_pattern, line): + last_import_idx = i + + # Вставляем после последнего импорта + lines.insert(last_import_idx + 1, import_line) + + return '\n'.join(lines) + + +def remove_local_functions(content: str) -> str: + """Удаляет локальные определения helper функций.""" + + # Паттерны для удаления + patterns = [ + # make_user fixture + r'@pytest\.fixture\s+def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))', + # make_user обычная функция + r'def make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))', + # _make_user + r'def _make_user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))', + # _user + r'def _user\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))', + # _grant, _grant_perm + r'def _grant(?:_perm)?\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))', + # _unique_phone, _unique_email + r'def _unique_(?:phone|email)\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))', + # extract_results + r'def extract_results\([^)]*\)[^:]*:.*?(?=\n(?:def |class |@pytest|$))', + ] + + for pattern in patterns: + content = re.sub(pattern, '', content, flags=re.DOTALL | re.MULTILINE) + + return content + + +def replace_function_calls(content: str) -> str: + """Заменяет вызовы локальных функций на импортированные.""" + replacements = { + r'\b_make_user\(': 'make_user(', + r'\b_user\(': 'make_user(', + r'\b_grant\(': 'grant_permission(', + r'\b_grant_perm\(': 'grant_permission(', + } + + for old, new in replacements.items(): + content = re.sub(old, new, content) + + return content + + +def update_file(filepath: Path): + """Обновляет один файл.""" + print(f"Обновляем {filepath.name}...") + + content = filepath.read_text(encoding='utf-8') + + # Добавляем импорт + content = add_import_if_missing(content) + + # Удаляем локальные функции + content = remove_local_functions(content) + + # Заменяем вызовы + content = replace_function_calls(content) + + # Убираем лишние пустые строки + content = re.sub(r'\n{3,}', '\n\n', content) + + filepath.write_text(content, encoding='utf-8') + print(f" ✓ {filepath.name} обновлен") + + +def main(): + """Главная функция.""" + for filename in files_to_update: + filepath = test_dir / filename + if filepath.exists(): + try: + update_file(filepath) + except Exception as e: + print(f" ✗ Ошибка в {filename}: {e}", file=sys.stderr) + else: + print(f" ! {filename} не найден") + + print("\n✅ Обновление завершено!") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/api/v1/employees/test_department_head_rights.py b/backend/tests/api/v1/employees/test_department_head_rights.py index 0204bada..2fe251cb 100644 --- a/backend/tests/api/v1/employees/test_department_head_rights.py +++ b/backend/tests/api/v1/employees/test_department_head_rights.py @@ -8,6 +8,7 @@ from rest_framework import status from employees.models import Department, DepartmentRole, EmployeeDepartment, Employee from tests.conftest import _unique_phone from tests.test_config import DEFAULT_PASSWORD +from tests.api.v1.employees.test_helpers import make_user, grant_permission, make_department User = get_user_model() @@ -19,28 +20,11 @@ def _unique_phone() -> str: base = 79000000000 # 79 + 9 нулей return str(base + User.objects.count()) -@pytest.fixture -def make_user(email: str, staff: bool = False, verified: bool = True) -> User: - """Fixture для создания пользователей.""" - - u = User.objects.create_user( - email=email, - password="pwd12345", - phone_number=_unique_phone(), - send_activation_email=False, - email_verified=True, - ) - # 👇 явная активация для тестов управленческих прав - if not u.is_active: - u.is_active = True - u.save(update_fields=["is_active"]) - return u +# Используем make_user из test_helpers def _unique_email() -> str: base = "asd@mail.com" return str(Employee.objects.count()) + base - -def _make_user(staff=False, superuser=False) -> Employee: """ Создаёт активного и 'email_verified' пользователя, чтобы не споткнуться о валидацию при назначении руководителя. diff --git a/backend/tests/api/v1/employees/test_helpers.py b/backend/tests/api/v1/employees/test_helpers.py new file mode 100644 index 00000000..6af8f43d --- /dev/null +++ b/backend/tests/api/v1/employees/test_helpers.py @@ -0,0 +1,168 @@ +""" +Общие helper-функции для тестов API employees. + +Используйте эти функции вместо создания собственных в каждом тестовом файле. +""" +import itertools +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from employees.models import Employee, Department, DepartmentRole, Position + +from tests.test_config import DEFAULT_PASSWORD, TEST_EMAIL_DOMAIN + +User = get_user_model() + +# Счетчики для уникальных значений +_email_counter = itertools.count(1) +_phone_counter = itertools.count(1000) + + +def make_unique_email(prefix: str = "user") -> str: + """Генерирует уникальный email для тестов.""" + return f"{prefix}{next(_email_counter)}@{TEST_EMAIL_DOMAIN}" + + +def make_unique_phone() -> str: + """Генерирует уникальный телефонный номер для тестов.""" + return f"+7999000{next(_phone_counter):04d}" + + +def make_user( + email: str = None, + *, + staff: bool = False, + superuser: bool = False, + verified: bool = True, + active: bool = True, + password: str = DEFAULT_PASSWORD, + **extra +) -> User: + """ + Создает пользователя для тестов. + + Args: + email: Email пользователя (если None, генерируется автоматически) + staff: Флаг is_staff + superuser: Флаг is_superuser + verified: Флаг email_verified + active: Флаг is_active + password: Пароль (по умолчанию DEFAULT_PASSWORD) + **extra: Дополнительные поля + + Returns: + User: Созданный пользователь + """ + if email is None: + email = make_unique_email() + + if 'phone_number' not in extra: + extra['phone_number'] = make_unique_phone() + + if 'first_name' not in extra: + extra['first_name'] = 'Test' + + if 'last_name' not in extra: + extra['last_name'] = 'User' + + user = User.objects.create( + email=email, + is_staff=staff, + is_superuser=superuser, + email_verified=verified, + is_active=active, + **extra + ) + user.set_password(password) + user.save() + + return user + + +def grant_permission(user: User, permission_codename: str): + """ + Выдает пользователю указанное разрешение. + + Args: + user: Пользователь + permission_codename: Codename разрешения в формате 'app_label.codename' + или просто 'codename' + """ + if '.' in permission_codename: + app_label, codename = permission_codename.split('.', 1) + perm = Permission.objects.get( + content_type__app_label=app_label, + codename=codename + ) + else: + # Пытаемся найти по codename + perm = Permission.objects.get(codename=permission_codename) + + user.user_permissions.add(perm) + user.save() + + +def make_department(**kwargs) -> Department: + """ + Создает отдел для тестов. + + Args: + **kwargs: Поля модели Department + + Returns: + Department: Созданный отдел + """ + if 'name' not in kwargs: + kwargs['name'] = f"Test Department {Department.objects.count() + 1}" + + return Department.objects.create(**kwargs) + + +def make_position(**kwargs) -> Position: + """ + Создает должность для тестов. + + Args: + **kwargs: Поля модели Position + + Returns: + Position: Созданная должность + """ + if 'name' not in kwargs: + kwargs['name'] = f"Test Position {Position.objects.count() + 1}" + + return Position.objects.create(**kwargs) + + +def make_department_role(department: Department, **kwargs) -> DepartmentRole: + """ + Создает роль отдела для тестов. + + Args: + department: Отдел + **kwargs: Дополнительные поля + + Returns: + DepartmentRole: Созданная роль + """ + if 'name' not in kwargs: + kwargs['name'] = f"Test Role {DepartmentRole.objects.count() + 1}" + + return DepartmentRole.objects.create( + department=department, + **kwargs + ) + + +def extract_results(data): + """ + Извлекает результаты из пагинированного ответа API. + + Args: + data: Данные ответа (dict или list) + + Returns: + list: Список результатов + """ + if isinstance(data, dict) and 'results' in data: + return data['results'] + return data diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0a1e588d..cc8dc263 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,6 +28,9 @@ from tests.test_config import ( TEST_IMAGE_DATA_URI, ) +# Импортируем LDAP фикстуры +from tests.ldap_fixtures import * # noqa + pytestmark = pytest.mark.django_db User = get_user_model() diff --git a/backend/tests/ldap_fixtures.py b/backend/tests/ldap_fixtures.py new file mode 100644 index 00000000..0920d26b --- /dev/null +++ b/backend/tests/ldap_fixtures.py @@ -0,0 +1,137 @@ +""" +Фикстуры для тестирования с LDAP. + +Предоставляет: +- ldap_connection: подключение к тестовому LDAP серверу +- ldap_enabled/ldap_disabled: маркеры для параметризации тестов +- ldap_config: конфигурация LDAP для тестов +""" +import os +import pytest +from django.conf import settings + + +@pytest.fixture(scope="session") +def ldap_available(): + """ + Проверяет доступность LDAP сервера. + + Returns: + bool: True если LDAP доступен и настроен + """ + if not settings.LDAP_ENABLED: + return False + + try: + from employees.ldap.infrastructure.connections import _ldap + with _ldap() as conn: + # Простая проверка подключения + return conn.bind() + except Exception as e: + print(f"LDAP не доступен: {e}") + return False + + +@pytest.fixture +def ldap_config(): + """ + Возвращает конфигурацию LDAP для тестов. + """ + return { + 'enabled': settings.LDAP_ENABLED, + 'uri': settings.LDAP_URI, + 'base_dn': settings.LDAP_BASE_DN, + 'users_base': settings.LDAP_USERS_BASE, + 'departments_base': getattr(settings, 'LDAP_DEPARTMENTS_BASE', None), + 'groups_base': getattr(settings, 'LDAP_GROUPS_BASE', None), + 'positions_base': getattr(settings, 'LDAP_POSITIONS_BASE', None), + 'dismissed_base': getattr(settings, 'LDAP_DISMISSED_BASE', None), + } + + +@pytest.fixture +def ensure_ldap_enabled(settings): + """ + Гарантирует что LDAP включен для теста. + Используется для тестов которые требуют LDAP. + """ + original = settings.LDAP_ENABLED + settings.LDAP_ENABLED = True + yield + settings.LDAP_ENABLED = original + + +@pytest.fixture +def ensure_ldap_disabled(settings): + """ + Гарантирует что LDAP выключен для теста. + Используется для тестов режима без LDAP. + """ + original = settings.LDAP_ENABLED + settings.LDAP_ENABLED = False + yield + settings.LDAP_ENABLED = original + + +@pytest.fixture +def ldap_cleanup(): + """ + Cleanup фикстура для удаления тестовых данных из LDAP после теста. + + Usage: + def test_something(ldap_cleanup): + # создаем объекты в LDAP + ldap_cleanup.add_for_deletion(dn) + """ + cleanup_list = [] + + class Cleanup: + def add_for_deletion(self, dn): + cleanup_list.append(dn) + + yield Cleanup() + + # Cleanup после теста + if cleanup_list and settings.LDAP_ENABLED: + try: + from employees.ldap.infrastructure.connections import _ldap + with _ldap() as conn: + for dn in reversed(cleanup_list): # удаляем в обратном порядке + try: + conn.delete(dn) + except Exception as e: + print(f"Не удалось удалить {dn}: {e}") + except Exception as e: + print(f"LDAP cleanup failed: {e}") + + +def pytest_configure(config): + """ + Регистрация маркеров для pytest. + """ + config.addinivalue_line( + "markers", "ldap_required: тест требует работающий LDAP сервер" + ) + config.addinivalue_line( + "markers", "ldap_optional: тест может работать с LDAP и без него (параметризован)" + ) + + +def pytest_collection_modifyitems(config, items): + """ + Автоматически скипает тесты требующие LDAP если он недоступен. + """ + ldap_available = False + if settings.LDAP_ENABLED: + try: + from employees.ldap.infrastructure.connections import _ldap + with _ldap() as conn: + ldap_available = conn.bind() + except: + pass + + skip_ldap = pytest.mark.skip(reason="LDAP сервер недоступен") + + for item in items: + if "ldap_required" in item.keywords and not ldap_available: + item.add_marker(skip_ldap)