268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""Тесты `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)
|