""" Тесты core: смоук всех страниц портала, заявки IT, справочники, структура и перестройка дерева Directory. """ from datetime import date from unittest import mock from django.contrib.auth import get_user_model from django.core.management import call_command from django.db.models import Count from django.test import TestCase from apps.it_manager.models import Ticket from apps.mattermost_connector.models import MattermostUser from core.models import Directory, DirectoryType from core.views import _build_chat_context Employee = get_user_model() _employee_seq = 0 def make_employee(**overrides): """Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)""" global _employee_seq _employee_seq += 1 defaults = { 'username': f'core.user{_employee_seq}', 'email': f'core.user{_employee_seq}@company.local', 'first_name': 'Іван', 'last_name': 'Тестовий', 'hire_date': date(2026, 1, 1), 'user_location_type': 'office', 'mattermost_sync_enabled': False, 'ad_sync_enabled': False, } defaults.update(overrides) return Employee.objects.create(**defaults) from core.test_utils import login_2fa class PortalPagesSmokeTests(TestCase): """Все страницы портала отдают 200 — и как HTMX-фрагмент, и в полном шелле""" PAGES = [ '/dashboard/', '/pharmacies/', '/structure/', '/dislocation/', '/rro/', '/timetrack/', '/calendar/', '/reports/', '/documents/', '/news/', '/tasks/', '/email/', '/chat/', '/databases/', '/purchases/', '/hr-portal/', '/help/', '/it-support/', '/ad-mapping/', '/it-manager/', ] @classmethod def setUpTestData(cls): from apps.tasks.models import Task from apps.locations.models.pharmacies import Pharmacy from core.models import Directory, DirectoryType cls.user = make_employee(is_staff=True, is_superuser=True) # /tasks// — детальная страница задачи, для неё нужен реальный pk cls.task = Task.objects.create(title='Смоук-задача', creator=cls.user) # /pharmacies// — те саме для аптеки region_type, _ = DirectoryType.objects.get_or_create( code='region', defaults={'name': 'Регіони', 'hierarchy_type': 'strict'}, ) region = Directory.objects.create(directory_type=region_type, name='Смоук-регіон') org_type, _ = DirectoryType.objects.get_or_create( code='organization', defaults={'name': 'Організації', 'hierarchy_type': 'strict'}, ) org = Directory.objects.create(directory_type=org_type, name='Смоук-організація') cls.pharmacy = Pharmacy.objects.create( location_type='pharmacy', location_region=region, organization=org, pharmacy_number=1, street='Смоук-тестова', house_number='1', ) def test_all_pages_render(self): login_2fa(self.client, self.user) detail_urls = [ f'/tasks/{self.task.pk}/', f'/employees/{self.user.pk}/', f'/pharmacies/{self.pharmacy.pk}/', ] for url in self.PAGES + detail_urls: with self.subTest(url=url): fragment = self.client.get(url, HTTP_HX_REQUEST='true') self.assertEqual(fragment.status_code, 200, url) full = self.client.get(url) self.assertEqual(full.status_code, 200, url) def test_legacy_section_redirects(self): login_2fa(self.client, self.user) settings_target = f'/employees/{self.user.pk}/?tab=settings' cases = { '/recruiting/': '/hr-portal/?tab=vacancies', '/settings/': settings_target, } for url, location in cases.items(): with self.subTest(url=url): for kwargs in ({}, {'HTTP_HX_REQUEST': 'true'}): resp = self.client.get(url, **kwargs) self.assertEqual(resp.status_code, 302, url) self.assertEqual(resp['Location'], location, url) class EmployeeDetailTests(TestCase): """Детальна сторінка співробітника: PII бачать лише staff/сам/керівник""" @classmethod def setUpTestData(cls): cls.staff = make_employee(is_staff=True) cls.manager = make_employee(personal_phone='+380501111111') cls.employee = make_employee( manager=cls.manager, personal_phone='+380502222222', # email не должен содержать логин — иначе проверка «логин скрыт от # руководителя» ложно срабатывает на подстроку рабочего email. email='subordinate.mail@company.local', ) cls.stranger = make_employee() def test_staff_sees_pii(self): login_2fa(self.client, self.staff) html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertIn('Личный телефон', html) self.assertIn('Интеграции', html) self.assertIn(self.employee.personal_phone, html) self.assertIn(self.employee.username, html) def test_manager_sees_subordinate_pii(self): login_2fa(self.client, self.manager) html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertIn('Личный телефон', html) self.assertIn(self.employee.personal_phone, html) # Логин — только себе и администратору портала, не руководителю. self.assertNotIn(self.employee.username, html) def test_self_sees_own_pii(self): login_2fa(self.client, self.employee) html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertIn('Личный телефон', html) self.assertIn(self.employee.personal_phone, html) self.assertIn(self.employee.username, html) def test_stranger_does_not_see_pii(self): login_2fa(self.client, self.stranger) response = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true') html = response.content.decode() self.assertEqual(response.status_code, 200) self.assertIn(self.employee.last_name, html) self.assertNotIn('Личный телефон', html) self.assertNotIn('Интеграции', html) self.assertNotIn(self.employee.personal_phone, html) self.assertNotIn(self.employee.username, html) def test_manager_chain_and_subordinates_shown(self): login_2fa(self.client, self.staff) html = self.client.get(f'/employees/{self.manager.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertIn(self.employee.last_name, html) html2 = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertIn(self.manager.last_name, html2) def test_unknown_employee_404(self): login_2fa(self.client, self.staff) response = self.client.get('/employees/999999/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 404) def test_anonymous_redirected(self): response = self.client.get(f'/employees/{self.employee.pk}/') self.assertEqual(response.status_code, 302) def test_anonymous_is_redirected_to_login(self): response = self.client.get('/dashboard/') self.assertEqual(response.status_code, 302) self.assertIn('login', response.url) def test_login_page_renders(self): response = self.client.get('/login/') self.assertEqual(response.status_code, 200) def test_chat_button_shown_for_employee_with_mattermost(self): login_2fa(self.client, self.staff) target = make_employee(mattermost_user_id='mm-xyz') html = self.client.get(f'/employees/{target.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertIn('Написать в чат', html) self.assertIn('/chat/?dm=mm-xyz', html) def test_chat_button_hidden_on_own_page(self): target = make_employee(mattermost_user_id='mm-self') login_2fa(self.client, target) html = self.client.get(f'/employees/{target.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertNotIn('Написать в чат', html) def test_chat_button_hidden_without_mattermost(self): login_2fa(self.client, self.staff) html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() self.assertNotIn('Написать в чат', html) class ITSupportViewTests(TestCase): def setUp(self): self.user = make_employee() login_2fa(self.client, self.user) def test_post_creates_ticket_for_requester(self): response = self.client.post('/it-support/', { 'title': 'Не працює принтер', 'description': 'Кабінет 204, принтер не друкує', 'ticket_type': 'repair', 'priority': 'high', }, HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) ticket = Ticket.objects.get(requester=self.user) self.assertEqual(ticket.title, 'Не працює принтер') self.assertEqual(ticket.ticket_type.code, 'repair') self.assertEqual(ticket.priority.code, 'high') self.assertContains(response, ticket.number) def test_invalid_choices_fall_back_to_defaults(self): self.client.post('/it-support/', { 'title': 'Питання', 'description': 'Опис', 'ticket_type': 'hacked_value', 'priority': 'hacked_value', }, HTTP_HX_REQUEST='true') ticket = Ticket.objects.get(requester=self.user) self.assertEqual(ticket.ticket_type.code, 'other') self.assertEqual(ticket.priority.code, 'medium') def test_empty_title_does_not_create_ticket(self): self.client.post('/it-support/', { 'title': '', 'description': 'Опис', }, HTTP_HX_REQUEST='true') self.assertFalse(Ticket.objects.exists()) def test_my_tickets_shows_only_own(self): other = make_employee() from core.models.directories import Directory Ticket.objects.create( title='Чужа заявка', description='x', ticket_type_id=Directory.objects.get(directory_type__code='ticket_type', code='other').id, requester=other, ) response = self.client.get('/it-support/', HTTP_HX_REQUEST='true') self.assertNotContains(response, 'Чужа заявка') class DatabasesViewTests(TestCase): def setUp(self): self.user = make_employee() from django.contrib.auth.models import Group self.user.groups.add(Group.objects.get(name='Офисный сотрудник')) # в тестовой базе уже есть типы, созданные data-миграциями (equipment_brand) self.base_types = DirectoryType.objects.count() self.base_records = Directory.objects.count() self.base_active = Directory.objects.filter(is_active=True).count() self.dtype = DirectoryType.objects.create( code='test_type', name='Тестовий довідник', hierarchy_type='strict', ) Directory.objects.create(directory_type=self.dtype, name='Запис 1') Directory.objects.create(directory_type=self.dtype, name='Запис 2', is_active=False) def test_counts_and_listing(self): login_2fa(self.client, self.user) response = self.client.get('/databases/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) self.assertContains(response, 'Тестовий довідник') self.assertEqual(response.context['types_count'], self.base_types + 1) self.assertEqual(response.context['records_count'], self.base_records + 2) self.assertEqual(response.context['active_records_count'], self.base_active + 1) def test_no_django_admin_link_on_portal_page(self): """Справочники правятся в портале — ссылки в Django admin на странице нет.""" login_2fa(self.client, self.user) response = self.client.get('/databases/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) self.assertNotContains(response, '/admin/core/directory/') from django.contrib.auth.models import Group staff = make_employee(is_staff=True) staff.groups.add(Group.objects.get(name='Офисный сотрудник')) login_2fa(self.client, staff) response = self.client.get('/databases/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) self.assertNotContains(response, '/admin/core/directory/') class StructureViewTests(TestCase): def setUp(self): self.dtype = DirectoryType.objects.create( code='department', name='Відділи', hierarchy_type='strict', ) self.dept = Directory.objects.create(directory_type=self.dtype, name='IT-відділ') def _grant_structure(self, user): from django.contrib.auth.models import Group user.groups.add(Group.objects.get(name='Офисный сотрудник')) def test_department_head_heuristic(self): head = make_employee(first_name='Керівник', last_name='Відділу', department=self.dept) self._grant_structure(head) make_employee(department=self.dept, manager=head) make_employee(department=self.dept, manager=head) login_2fa(self.client, head) response = self.client.get('/structure/', HTTP_HX_REQUEST='true') self.assertEqual(response.status_code, 200) departments = response.context['departments'] dept = next(d for d in departments if d.pk == self.dept.pk) self.assertEqual(dept.head, head) self.assertEqual(dept.employees_count, 3) def test_empty_department_has_no_head(self): user = make_employee() self._grant_structure(user) login_2fa(self.client, user) response = self.client.get('/structure/', HTTP_HX_REQUEST='true') departments = response.context['departments'] dept = next(d for d in departments if d.pk == self.dept.pk) self.assertIsNone(dept.head) self.assertContains(response, 'Пустой') class RebuildDirectoryTreeTests(TestCase): def setUp(self): self.dtype = DirectoryType.objects.create( code='test_tree', name='Дерево', hierarchy_type='strict', ) self.root = Directory.objects.create(directory_type=self.dtype, name='Корінь') self.child = Directory.objects.create( directory_type=self.dtype, name='Дитина', parent=self.root, ) def test_command_repairs_broken_paths(self): # ломаем дерево так, как это делают data-миграции и импорт Directory.objects.update(_path='') call_command('rebuild_directory_tree', verbosity=0) self.root.refresh_from_db() self.child.refresh_from_db() self.assertTrue(self.root._path) self.assertTrue(self.child._path) self.assertTrue(self.child._path.startswith(self.root._path)) duplicates = ( Directory.objects.values('_path') .annotate(cnt=Count('id')).filter(cnt__gt=1) ) self.assertFalse(duplicates.exists()) class ChatColleaguesTests(TestCase): def setUp(self): self.me = make_employee(mattermost_user_id='mm-me', first_name='Іван', last_name='Мене') self.colleague = make_employee(mattermost_user_id='mm-colleague', first_name='Петро', last_name='Колега') self.inactive_colleague = make_employee(mattermost_user_id='mm-inactive', first_name='Гість', last_name='Неактивний') MattermostUser.objects.create( django_user=self.me, mattermost_id='mm-me', username='me', email=self.me.email, is_active=True, ) MattermostUser.objects.create( django_user=self.colleague, mattermost_id='mm-colleague', username='colleague', email=self.colleague.email, is_active=True, ) MattermostUser.objects.create( django_user=self.inactive_colleague, mattermost_id='mm-inactive', username='inactive', email=self.inactive_colleague.email, is_active=False, ) def _build(self): request = mock.Mock() request.user = self.me with mock.patch('core.views.ConnectorConfig.get_for') as get_for: get_for.return_value = mock.Mock(extra_config={'public_url': 'https://chat.example.com'}) with mock.patch( 'apps.mattermost_connector.services.token_service.ensure_personal_access_token', return_value='fake-token', ): return _build_chat_context(request) def test_colleagues_excludes_self_and_inactive(self): context = self._build() self.assertEqual(context['chat_state'], 'ready') self.assertEqual(context['chat_config']['apiBase'], '/chat/api') ids = [c['mattermostId'] for c in context['chat_colleagues']] self.assertIn('mm-colleague', ids) self.assertNotIn('mm-me', ids) self.assertNotIn('mm-inactive', ids) def test_colleague_name_from_full_name(self): context = self._build() names = {c['mattermostId']: c['name'] for c in context['chat_colleagues']} self.assertEqual(names['mm-colleague'], 'Петро Колега') from django.core.cache import cache as _cache from django.test import Client as _Client class LoginThrottleTests(TestCase): def setUp(self): _cache.clear() self.client = _Client() def test_blocks_after_max_attempts(self): for _ in range(5): r = self.client.post('/login/', {'username': 'nobody', 'password': 'bad'}) self.assertEqual(r.status_code, 200) r = self.client.post('/login/', {'username': 'nobody', 'password': 'bad'}) self.assertEqual(r.status_code, 429) def tearDown(self): _cache.clear() class LogoutMethodTests(TestCase): def test_get_not_allowed(self): self.assertEqual(self.client.get('/logout/').status_code, 405) def test_post_redirects(self): self.assertEqual(self.client.post('/logout/').status_code, 302)