5fa9fede82
- EmployeeAdminPasswordTests: форма требует internal_line_type (поле телефонии, у модели default=ANALOG) — добавлено в данные формы - EmployeeDetailTests: в чужой карточке телефон отображается форматированным (format_ua_phone_display) — проверка обновлена Полный прогон (.venv): 788 тестов, 0 провалов. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
444 lines
20 KiB
Python
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)
|