feat(dav): expand_rrule + dav_make_calendar para recurrencia y multi-calendario

Dos funciones nuevas del grupo de capacidad `dav`:
- expand_rrule_py_infra (pure): expande una RRULE iCalendar a las fechas de
  cada ocurrencia dentro de un rango [from, to]. Solo stdlib (datetime, re).
  Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL, BYDAY. 9 tests.
- dav_make_calendar_py_infra (impure): crea una coleccion de calendario nueva
  via MKCALENDAR + PROPPATCH de nombre/color. Idempotente si ya existe. 11 tests.

Consumidas por la app osint_web (eventos recurrentes + creacion de agendas).
Pagina del grupo dav actualizada con ambas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:30:01 +02:00
parent a76760edba
commit 1c8a86594f
7 changed files with 919 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
"""Tests para expand_rrule."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from functions.infra.expand_rrule import expand_rrule
def test_golden_weekly_count_4():
# FREQ=WEEKLY;COUNT=4 a partir del 2026-01-05 (lunes) -> 4 fechas a 7 dias.
got = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;COUNT=4",
"20260101",
"20261231",
)
assert got == [
"20260105T090000",
"20260112T090000",
"20260119T090000",
"20260126T090000",
]
def test_edge_monthly_interval_2():
# INTERVAL=2 mensual: cada dos meses, dia 15.
got = expand_rrule(
"20260115",
"FREQ=MONTHLY;INTERVAL=2;COUNT=4",
"20260101",
"20261231",
all_day=True,
)
assert got == ["20260115", "20260315", "20260515", "20260715"]
def test_edge_weekly_byday_two_days():
# BYDAY con 2 dias en WEEKLY: MO y WE, dentro de cada semana del intervalo.
# dtstart 2026-01-05 (lunes). COUNT=4 -> MO,WE de la sem 1 y MO,WE de la sem 2.
got = expand_rrule(
"20260105",
"FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4",
"20260101",
"20261231",
all_day=True,
)
assert got == ["20260105", "20260107", "20260112", "20260114"]
def test_edge_all_day_vs_with_time():
# all_day=True -> YYYYMMDD; all_day=False conserva la hora del dtstart.
all_day = expand_rrule(
"20260105T143000",
"FREQ=DAILY;COUNT=2",
"20260101",
"20261231",
all_day=True,
)
assert all_day == ["20260105", "20260106"]
with_time = expand_rrule(
"20260105T143000",
"FREQ=DAILY;COUNT=2",
"20260101",
"20261231",
all_day=False,
)
assert with_time == ["20260105T143000", "20260106T143000"]
def test_until_recorta():
# UNTIL=20260120 recorta la serie semanal inclusive en esa fecha.
got = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;UNTIL=20260120T235959Z",
"20260101",
"20261231",
)
assert got == [
"20260105T090000",
"20260112T090000",
"20260119T090000",
]
def test_filtro_por_rango_excluye_fuera():
# COUNT=10 semanal, pero el rango solo cubre 3 ocurrencias intermedias.
got = expand_rrule(
"20260105T090000",
"FREQ=WEEKLY;COUNT=10",
"20260112",
"20260131",
)
assert got == [
"20260112T090000",
"20260119T090000",
"20260126T090000",
]
def test_dtstart_anterior_al_rango_pero_serie_entra():
# dtstart en 2025-12-29 (antes del rango), serie semanal entra en enero 2026.
got = expand_rrule(
"20251229T090000",
"FREQ=WEEKLY;COUNT=8",
"20260101",
"20260115",
)
assert got == [
"20260105T090000",
"20260112T090000",
]
def test_componente_no_soportado_se_ignora():
# BYMONTHDAY no soportado -> se ignora, no falla. Serie mensual normal.
got = expand_rrule(
"20260110",
"FREQ=MONTHLY;BYMONTHDAY=10;COUNT=3",
"20260101",
"20261231",
all_day=True,
)
assert got == ["20260110", "20260210", "20260310"]
def test_sin_count_ni_until_acota_a_range_end():
# Sin COUNT ni UNTIL: la generacion debe acotarse por range_end.
got = expand_rrule(
"20260101",
"FREQ=DAILY",
"20260101",
"20260105",
all_day=True,
)
assert got == [
"20260101",
"20260102",
"20260103",
"20260104",
"20260105",
]