Files
ACORE/apps/visit_manager/tests.py
T
lokses 3e969e29dc visit_manager: события календаря
Модель и сервис событий календаря, вью и страница календаря.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:44 +03:00

308 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import datetime
from datetime import date, timedelta
from django.core.exceptions import ValidationError
from django.test import TestCase
from apps.visit_manager.models import Holiday, WorkShift
class EasterCalculationTests(TestCase):
"""Православная Пасха (юлианская + 13 дней) — сверка с известными датами."""
def test_known_orthodox_easter_dates(self):
calc = Holiday.objects._calculate_easter
self.assertEqual(calc(2023), date(2023, 4, 16))
self.assertEqual(calc(2024), date(2024, 5, 5))
self.assertEqual(calc(2025), date(2025, 4, 20))
self.assertEqual(calc(2026), date(2026, 4, 12))
class HolidayLogicTests(TestCase):
"""Чистая логика Holiday (без БД): компенсация, валидация, даты."""
def test_compensation_on_non_working_holiday(self):
h = Holiday(date=date(2025, 1, 1), is_working_day=False, overtime_rate=2.0)
res = h.calculate_work_compensation(hours_worked=8, base_hourly_rate=100)
self.assertEqual(res['regular_pay'], 0)
self.assertEqual(res['holiday_bonus'], 1600) # 8 * 100 * 2.0
self.assertEqual(res['total_pay'], 1600)
self.assertTrue(res['is_holiday_work'])
def test_compensation_on_working_holiday(self):
h = Holiday(date=date(2025, 1, 1), is_working_day=True, overtime_rate=2.0)
res = h.calculate_work_compensation(hours_worked=8, base_hourly_rate=100)
self.assertEqual(res['regular_pay'], 800)
self.assertEqual(res['holiday_bonus'], 0)
self.assertFalse(res['is_holiday_work'])
def test_clean_rejects_non_positive_overtime_rate(self):
with self.assertRaises(ValidationError):
Holiday(name='X', date=date(2025, 1, 1), overtime_rate=0).clean()
def test_clean_accepts_positive_overtime_rate(self):
Holiday(name='X', date=date(2025, 1, 1), overtime_rate=1.5).clean() # не должно бросать
def test_days_until_future_and_past(self):
future = Holiday(name='F', date=date.today() + timedelta(days=5))
self.assertEqual(future.days_until, 5)
past = Holiday(name='P', date=date.today() - timedelta(days=1))
self.assertIsNone(past.days_until)
def test_is_today(self):
self.assertTrue(Holiday(name='T', date=date.today()).is_today)
self.assertFalse(Holiday(name='N', date=date.today() + timedelta(days=1)).is_today)
def test_year_property(self):
self.assertEqual(Holiday(name='Y', date=date(2024, 8, 24)).year, 2024)
def test_next_occurrence_recurring(self):
h = Holiday(name='Indep', date=date(2020, 8, 24), is_recurring=True)
self.assertEqual(h.get_next_occurrence(date(2025, 1, 1)), date(2025, 8, 24))
# уже прошло в этом году → следующий год
self.assertEqual(h.get_next_occurrence(date(2025, 9, 1)), date(2026, 8, 24))
def test_next_occurrence_non_recurring(self):
h = Holiday(name='One', date=date(2020, 8, 24), is_recurring=False)
self.assertIsNone(h.get_next_occurrence(date(2025, 1, 1)))
class HolidayManagerTests(TestCase):
"""Менеджер/queryset: генерация украинских праздников, поиск, периоды."""
def test_create_ukrainian_holidays_count_and_idempotent(self):
created = Holiday.objects.create_ukrainian_holidays(2025)
self.assertEqual(len(created), 12) # 9 фиксированных + 3 пасхальных
# повторный вызов ничего не дублирует (get_or_create по дате)
self.assertEqual(len(Holiday.objects.create_ukrainian_holidays(2025)), 0)
def test_fixed_and_easter_holidays_present(self):
Holiday.objects.create_ukrainian_holidays(2025)
self.assertTrue(Holiday.objects.is_holiday(date(2025, 1, 1))) # Новий рік
self.assertTrue(Holiday.objects.is_holiday(date(2025, 8, 24))) # Незалежність
self.assertTrue(Holiday.objects.is_holiday(date(2025, 4, 20))) # Пасха 2025
def test_is_holiday_false_for_ordinary_day(self):
Holiday.objects.create_ukrainian_holidays(2025)
self.assertFalse(Holiday.objects.is_holiday(date(2025, 3, 15)))
def test_is_working_holiday(self):
Holiday.objects.create(name='Робоче свято', date=date(2025, 6, 2), is_working_day=True)
Holiday.objects.create(name='Вихідне свято', date=date(2025, 6, 3), is_working_day=False)
self.assertTrue(bool(Holiday.objects.is_working_holiday(date(2025, 6, 2))))
self.assertFalse(bool(Holiday.objects.is_working_holiday(date(2025, 6, 3))))
def test_for_year_and_for_period(self):
Holiday.objects.create(name='A', date=date(2024, 12, 31))
Holiday.objects.create(name='B', date=date(2025, 1, 1))
Holiday.objects.create(name='C', date=date(2025, 6, 1))
self.assertEqual(Holiday.objects.for_year(2025).count(), 2)
period = Holiday.objects.for_period(date(2024, 12, 1), date(2025, 1, 15))
self.assertEqual(period.count(), 2) # 2024-12-31 и 2025-01-01
def test_get_next_holiday(self):
Holiday.objects.create(name='Past', date=date(2025, 1, 1))
Holiday.objects.create(name='Next', date=date(2025, 5, 1))
nxt = Holiday.objects.get_next_holiday(from_date=date(2025, 3, 1))
self.assertEqual(nxt.name, 'Next')
class WorkShiftTests(TestCase):
"""WorkShift.work_hours / is_complete — без обращения к БД."""
def test_work_hours_from_duration(self):
ws = WorkShift(duration=timedelta(hours=8, minutes=30))
self.assertEqual(ws.work_hours, 8.5)
def test_work_hours_none_without_duration(self):
self.assertIsNone(WorkShift().work_hours)
def test_is_complete_requires_both_logs(self):
self.assertFalse(WorkShift().is_complete)
self.assertFalse(WorkShift(log_in_id=1).is_complete)
self.assertTrue(WorkShift(log_in_id=1, log_out_id=2).is_complete)
class WorkingHoursTests(TestCase):
"""add_working_hours: рабочее окно 09:00–18:00, выходные и праздники."""
def _monday_9am(self):
from django.utils import timezone
d = date(2026, 7, 6)
while d.weekday() != 0:
d += timedelta(days=1)
return timezone.make_aware(datetime.datetime.combine(d, datetime.time(9, 0)))
def test_within_same_working_day(self):
from apps.visit_manager.services import add_working_hours
start = self._monday_9am()
due = add_working_hours(start, 5)
self.assertEqual(due.date(), start.date())
self.assertEqual(due.hour, 14)
def test_rolls_to_next_working_day(self):
from apps.visit_manager.services import add_working_hours
start = self._monday_9am()
due = add_working_hours(start, 11) # 9г пн -> 18:00, +2г -> вт 11:00
self.assertEqual(due.date(), start.date() + timedelta(days=1))
self.assertEqual(due.hour, 11)
def test_skips_weekend(self):
from django.utils import timezone
from apps.visit_manager.services import add_working_hours
friday = self._monday_9am().date() + timedelta(days=4)
start = timezone.make_aware(datetime.datetime.combine(friday, datetime.time(17, 0)))
due = add_working_hours(start, 2) # пт 1г -> сб/нд пропуск -> пн 10:00
self.assertEqual(due.weekday(), 0)
self.assertEqual(due.hour, 10)
def test_skips_holiday(self):
from django.utils import timezone
from apps.visit_manager.services import add_working_hours
start = self._monday_9am()
# робимо вівторок неробочим святом → 11 робочих годин перекотяться на середу
Holiday.objects.create(name='Тест-свято', date=start.date() + timedelta(days=1),
is_working_day=False)
due = add_working_hours(start, 11)
self.assertEqual(due.date(), start.date() + timedelta(days=2))
self.assertEqual(due.hour, 11)
class AttendanceAPIAuthzTests(TestCase):
"""Object-level authz для /api/attendance/."""
@classmethod
def setUpTestData(cls):
from django.contrib.auth import get_user_model
from core.models import Directory, DirectoryType
from apps.locations.models import Pharmacy
import io
from PIL import Image
Employee = get_user_model()
org_type = DirectoryType.objects.create(code='organization', name='Организация')
region_type = DirectoryType.objects.create(code='region', name='Регион')
region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса')
org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС')
cls.location = Pharmacy.objects.create(
organization=org, location_region=region,
pharmacy_number=101, location_type='pharmacy',
)
cls.staff = Employee.objects.create_user(
username='att_staff', hire_date=date(2020, 1, 1),
first_name='S', last_name='Staff', is_staff=True,
mattermost_sync_enabled=False, ad_sync_enabled=False,
)
cls.other_location = Pharmacy.objects.create(
organization=org, location_region=region,
pharmacy_number=102, location_type='pharmacy',
)
loc_kw = dict(
user_location_type='pharmacy', user_pharmacy=cls.location,
)
cls.alice = Employee.objects.create_user(
username='att_alice', hire_date=date(2020, 1, 1),
first_name='A', last_name='Alice',
mattermost_sync_enabled=False, ad_sync_enabled=False,
**loc_kw,
)
cls.bob = Employee.objects.create_user(
username='att_bob', hire_date=date(2020, 1, 1),
first_name='B', last_name='Bob', manager=cls.alice,
mattermost_sync_enabled=False, ad_sync_enabled=False,
**loc_kw,
)
cls.carol = Employee.objects.create_user(
username='att_carol', hire_date=date(2020, 1, 1),
first_name='C', last_name='Carol',
mattermost_sync_enabled=False, ad_sync_enabled=False,
**loc_kw,
)
buf = io.BytesIO()
Image.new('RGB', (8, 8), color='red').save(buf, format='JPEG')
cls.photo_b64 = __import__('base64').b64encode(buf.getvalue()).decode()
def _register(self, user, employee_id, location_id=None):
self.client.force_login(user)
if user.is_staff: # staff проходит обязательный 2FA-гейт
user.totp_enabled = True
user.save(update_fields=['totp_enabled'])
session = self.client.session
session['2fa_verified'] = True
session.save()
return self.client.post(
'/api/attendance/register/',
{
'employee_id': employee_id,
'location_id': location_id or self.location.pk,
'direction': 'in',
'photo_b64': self.photo_b64,
},
content_type='application/json',
)
def test_user_can_register_self(self):
resp = self._register(self.alice, self.alice.pk)
self.assertEqual(resp.status_code, 201)
def test_user_cannot_register_other(self):
resp = self._register(self.alice, self.carol.pk)
self.assertEqual(resp.status_code, 403)
def test_staff_can_register_other(self):
resp = self._register(self.staff, self.carol.pk)
self.assertEqual(resp.status_code, 201)
def test_user_cannot_register_at_other_location(self):
resp = self._register(self.alice, self.alice.pk, location_id=self.other_location.pk)
self.assertEqual(resp.status_code, 403)
def test_staff_can_register_at_any_location(self):
resp = self._register(self.staff, self.carol.pk, location_id=self.other_location.pk)
self.assertEqual(resp.status_code, 201)
def test_list_scoped_to_self_and_reports(self):
from apps.visit_manager.services import register_attendance
import base64
photo = base64.b64decode(self.photo_b64)
register_attendance(
employee_id=self.alice.pk, location_id=self.location.pk,
direction='in', photo_bytes=photo,
)
register_attendance(
employee_id=self.bob.pk, location_id=self.location.pk,
direction='in', photo_bytes=photo,
)
register_attendance(
employee_id=self.carol.pk, location_id=self.location.pk,
direction='in', photo_bytes=photo,
)
self.client.force_login(self.alice)
resp = self.client.get('/api/attendance/')
self.assertEqual(resp.status_code, 200)
ids = {row['employee_id'] for row in resp.json()}
self.assertIn(self.alice.pk, ids)
self.assertIn(self.bob.pk, ids)
self.assertNotIn(self.carol.pk, ids)
def test_list_other_employee_forbidden(self):
self.client.force_login(self.alice)
resp = self.client.get(f'/api/attendance/?employee_id={self.carol.pk}')
self.assertEqual(resp.status_code, 403)
def test_photo_streams_not_redirect(self):
from apps.visit_manager.services import register_attendance
import base64
photo = base64.b64decode(self.photo_b64)
log = register_attendance(
employee_id=self.alice.pk, location_id=self.location.pk,
direction='in', photo_bytes=photo,
)
self.client.force_login(self.alice)
resp = self.client.get(f'/api/attendance/{log.pk}/photo/')
self.assertEqual(resp.status_code, 200)
self.assertFalse(300 <= resp.status_code < 400)
self.assertIn(resp['Content-Type'], ('image/jpeg', 'image/png', 'image/webp'))
self.assertTrue(b''.join(resp.streaming_content) or resp.content)