"""Тесты `services/ics.py::build_invite` («.ics», блок C). Пишутся ДО закрепления поведения в реализации (TDD) — покрывают парсимость результата, соответствие VTIMEZONE и TZID DTSTART, RRULE всех 4 типов повторения, рост SEQUENCE и корректность DTSTART повторяющейся серии. """ import uuid from datetime import UTC, datetime from typing import Any import pytest from icalendar import Calendar from models.conference import Conference from services.ics import ConferenceHasNoScheduleError, build_invite from services.recurrence import RecurrenceRule, expand_occurrences def _prop(component: Any, name: str) -> Any: """Снять широкий `Union` типов свойств `icalendar` при статической проверке mypy. `Component.__getitem__` типизирован как union из полутора десятков возможных типов значений (`vText`/`vRecur`/`vDDDTypes`/...) — конкретный тип известен только в рантайме по имени ключа; без этой обёртки пришлось бы городить `# type: ignore` на каждой строке доступа к свойству события. """ return component[name] def _conference(**overrides: object) -> Conference: defaults: dict[str, object] = { "id": uuid.uuid4(), "number": "123456789", "slug": "test-slug-1", "title": "Синк по проекту", "owner_id": None, "status": "scheduled", "is_pinned": False, "is_closed": False, "scheduled_at": datetime(2026, 8, 3, 10, 0, tzinfo=UTC), "duration_minutes": 45, "ics_sequence": 0, } defaults.update(overrides) return Conference(**defaults) def _recurrence(**overrides: object) -> dict[str, object]: defaults: dict[str, object] = { "type": "weekly", "weekdays": [0, 2], "anchor_date": "2026-08-03", "time_local": "09:00", "timezone": "Europe/Moscow", "duration_minutes": 30, } defaults.update(overrides) return defaults class TestBuildInviteOneOff: def test_result_parses_back_with_icalendar(self) -> None: conference = _conference() raw = build_invite( conference, organizer_email="owner@example.com", join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) parsed = Calendar.from_ical(raw) assert parsed.get("method") == "REQUEST" def test_dtstart_uses_display_timezone(self) -> None: conference = _conference() raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) parsed = Calendar.from_ical(raw) event = parsed.walk("VEVENT")[0] dtstart = _prop(event, "dtstart") assert str(dtstart.params["TZID"]) == "Europe/Moscow" assert dtstart.dt.hour == 13 # 10:00 UTC -> 13:00 MSK (UTC+3) def test_vtimezone_matches_dtstart_tzid(self) -> None: conference = _conference() raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) parsed = Calendar.from_ical(raw) event = parsed.walk("VEVENT")[0] tzid = str(_prop(event, "dtstart").params["TZID"]) vtimezones = parsed.walk("VTIMEZONE") assert any(str(_prop(vt, "tzid")) == tzid for vt in vtimezones) def test_uid_is_stable_and_sequence_matches_conference(self) -> None: conference = _conference(ics_sequence=2) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) event = Calendar.from_ical(raw).walk("VEVENT")[0] assert str(_prop(event, "uid")) == f"{conference.id}@vidconf" assert int(_prop(event, "sequence")) == 2 def test_sequence_grows_when_ics_sequence_increments(self) -> None: conference = _conference(ics_sequence=0) raw_before = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) conference.ics_sequence = 1 raw_after = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) seq_before = int(_prop(Calendar.from_ical(raw_before).walk("VEVENT")[0], "sequence")) seq_after = int(_prop(Calendar.from_ical(raw_after).walk("VEVENT")[0], "sequence")) assert seq_after > seq_before def test_conference_without_schedule_raises(self) -> None: conference = _conference(scheduled_at=None) with pytest.raises(ConferenceHasNoScheduleError): build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) class TestBuildInviteRecurring: def test_weekly_rrule_byday(self) -> None: conference = _conference( scheduled_at=None, recurrence=_recurrence(type="weekly", weekdays=[0, 2]) ) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) event = Calendar.from_ical(raw).walk("VEVENT")[0] rrule = _prop(event, "rrule") assert rrule["FREQ"] == ["WEEKLY"] assert rrule["BYDAY"] == ["MO", "WE"] def test_biweekly_rrule_interval_two(self) -> None: conference = _conference( scheduled_at=None, recurrence=_recurrence(type="biweekly", weekdays=[1], anchor_date="2026-08-04"), ) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) event = Calendar.from_ical(raw).walk("VEVENT")[0] rrule = _prop(event, "rrule") assert rrule["FREQ"] == ["WEEKLY"] assert rrule["INTERVAL"] == [2] assert rrule["BYDAY"] == ["TU"] assert rrule["WKST"] == ["MO"] def test_monthly_rrule_bymonthday(self) -> None: conference = _conference( scheduled_at=None, recurrence=_recurrence( type="monthly", weekdays=[], day_of_month=15, anchor_date="2026-08-15" ), ) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) event = Calendar.from_ical(raw).walk("VEVENT")[0] rrule = _prop(event, "rrule") assert rrule["FREQ"] == ["MONTHLY"] assert rrule["BYMONTHDAY"] == [15] def test_monthly_rrule_31_maps_to_last_day(self) -> None: conference = _conference( scheduled_at=None, recurrence=_recurrence( type="monthly", weekdays=[], day_of_month=31, anchor_date="2026-08-31" ), ) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) event = Calendar.from_ical(raw).walk("VEVENT")[0] assert _prop(event, "rrule")["BYMONTHDAY"] == [-1] def test_every_n_days_rrule_daily_interval(self) -> None: conference = _conference( scheduled_at=None, recurrence=_recurrence( type="every_n_days", weekdays=[], interval_days=3, anchor_date="2026-08-03" ), ) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) event = Calendar.from_ical(raw).walk("VEVENT")[0] rrule = _prop(event, "rrule") assert rrule["FREQ"] == ["DAILY"] assert rrule["INTERVAL"] == [3] def test_dtstart_is_first_occurrence_from_expand_occurrences(self) -> None: recurrence = _recurrence(type="weekly", weekdays=[0, 2], anchor_date="2026-08-03") conference = _conference(scheduled_at=None, recurrence=recurrence) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) event = Calendar.from_ical(raw).walk("VEVENT")[0] dtstart = _prop(event, "dtstart").dt rule = RecurrenceRule.model_validate(recurrence) t_from = datetime(2026, 8, 3, tzinfo=UTC) t_to = datetime(2026, 9, 1, tzinfo=UTC) expected_first = expand_occurrences(rule, t_from, t_to)[0] expected_local = expected_first.astimezone(dtstart.tzinfo) assert dtstart == expected_local assert str(_prop(event, "dtstart").params["TZID"]) == "Europe/Moscow" def test_recurring_vtimezone_matches_rule_timezone(self) -> None: conference = _conference( scheduled_at=None, recurrence=_recurrence(type="weekly", weekdays=[0], timezone="America/New_York"), ) raw = build_invite( conference, organizer_email=None, join_url="https://vidconf.example/j/test-slug-1", display_timezone="Europe/Moscow", ) parsed = Calendar.from_ical(raw) event = parsed.walk("VEVENT")[0] tzid = str(_prop(event, "dtstart").params["TZID"]) assert tzid == "America/New_York" vtimezones = parsed.walk("VTIMEZONE") assert any(str(_prop(vt, "tzid")) == tzid for vt in vtimezones)