Files
lokses 5fa9fede82 tests: актуализация под телефонию и форматирование телефона
- EmployeeAdminPasswordTests: форма требует internal_line_type (поле
  телефонии, у модели default=ANALOG) — добавлено в данные формы
- EmployeeDetailTests: в чужой карточке телефон отображается
  форматированным (format_ua_phone_display) — проверка обновлена

Полный прогон (.venv): 788 тестов, 0 провалов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-01 15:47:09 +02:00

444 lines
20 KiB
Python

"""
Тесты 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 django.urls import reverse
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/', '/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>/ — детальная страница задачи, для неё нужен реальный pk
cls.task = Task.objects.create(title='Смоук-задача', creator=cls.user)
# /pharmacies/<pk>/ — те саме для аптеки
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,
'/ad-mapping/': reverse('admin:ad_connector_match_employees'),
'/ad-ou-manage/': reverse('admin:ad_connector_ou_manage'),
'/ad-import/': reverse('admin:ad_connector_import_employees'),
}
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):
from core.utils.validators import format_ua_phone_display
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(format_ua_phone_display(self.employee.personal_phone), html)
self.assertIn(self.employee.username, html)
def test_manager_sees_subordinate_pii(self):
from core.utils.validators import format_ua_phone_display
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(format_ua_phone_display(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, 'Пустой')
def test_staff_add_department_link_uses_unfold_admin(self):
from django.urls import reverse
staff = make_employee(is_staff=True)
self._grant_structure(staff)
login_2fa(self.client, staff)
response = self.client.get('/structure/', HTTP_HX_REQUEST='true')
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse('admin:core_directory_add'))
self.assertNotContains(response, '/admin/core/directory/add/')
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)