fix(caldav): fix generation of events

keep information of: ATTENDEE, ORGANIZER, CATEGORIES, STATUS, TRANSP, VALARM, X-*

    this fix answer in all calldav GET
This commit is contained in:
Edouard Vanbelle
2026-07-14 23:58:03 +02:00
parent e360d093bb
commit cb6c29a063
7 changed files with 554 additions and 204 deletions
+23 -9
View File
@@ -173,10 +173,16 @@ HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 7 – GET the master. It must STILL be the master (with
# RRULE + original SUMMARY). Pre-fix the exception would have
# clobbered this row and Step 7 would see the exception's
# SUMMARY ("… rescheduled") without the RRULE.
# Step 7 – GET the URL — must return the FULL calendar-object-
# resource: master VEVENT (with RRULE + original SUMMARY) AND
# the exception VEVENT (with RECURRENCE-ID + rescheduled
# SUMMARY) concatenated in ONE VCALENDAR body. This is the
# phase-4 read-side contract per RFC 4791 §4.1 + RFC 5545
# §3.6.1 — one URL per UID, one VCALENDAR containing every
# component.
#
# Pre-phase-4 this GET returned ONLY the master and clients
# never saw the exception, so their next-PUT dropped it.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/caldav/{{calendar_id}}/daily-e2e-528.ics
Authorization: Bearer {{admin_token}}
@@ -185,7 +191,8 @@ HTTP 200
[Asserts]
body contains "FREQ=DAILY;COUNT=10"
body contains "SUMMARY:Daily standup"
body not contains "SUMMARY:Daily standup — rescheduled"
body contains "SUMMARY:Daily standup — rescheduled"
body contains "RECURRENCE-ID:20260103T090000Z"
# ─────────────────────────────────────────────────────────────
@@ -216,9 +223,15 @@ HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 9 – Master survives the exception update. Pre-fix this
# would fail: the old delete-by-UID-then-insert path would
# have removed the master when the exception-only PUT landed.
# Step 9 – After the exception-only PUT: bundled GET returns
# the master (unchanged, still carries RRULE + original
# SUMMARY) AND the newly-updated exception (SUMMARY now
# "rescheduled AGAIN" from Step 8).
#
# Pre-phase-3 the exception-only PUT wiped the master row.
# Pre-phase-4 the master survived but the exception was
# invisible in the GET body.
# Post-phase-4: both survive, both visible.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/caldav/{{calendar_id}}/daily-e2e-528.ics
Authorization: Bearer {{admin_token}}
@@ -227,7 +240,8 @@ HTTP 200
[Asserts]
body contains "FREQ=DAILY;COUNT=10"
body contains "SUMMARY:Daily standup"
body not contains "SUMMARY:Daily standup — rescheduled"
body contains "SUMMARY:Daily standup — rescheduled AGAIN"
body contains "RECURRENCE-ID:20260103T090000Z"
# ─────────────────────────────────────────────────────────────
+14 -31
View File
@@ -12,16 +12,13 @@ DTSTART / DTEND / DESCRIPTION / LOCATION / RRULE / DTSTAMP /
CREATED / LAST-MODIFIED). Anything not in that list is silently
dropped even though the original `ical_data` is stored intact.
Tests split into two groups:
* **Sanity** — properties the server emits on GET; they must
round-trip. Regressions here would be genuine server bugs.
* **xfail (documented gaps)** — properties the server currently
drops. `@pytest.mark.xfail(strict=False)` lets the suite stay
green while making the gap visible in the pytest summary. If
a future server fix makes one of these survive, pytest
reports it as `XPASS` — an alert to remove the marker.
Every test is a strict round-trip pin: PUT a vCalendar body
carrying the property, GET the URL, assert the property is
present in the response. Post-phase-4 the emitter serves each
row's stored `ical_data` verbatim (folded per UID), so a
regression on any property here means either the storage
layer stopped preserving ical_data OR the emitter reverted
to DTO-field regeneration.
"""
from __future__ import annotations
@@ -30,7 +27,6 @@ import textwrap
import uuid
import caldav
import pytest
# ─────────────────────────────────────────────────────────────
@@ -151,24 +147,16 @@ def test_uid_and_dtstamp_are_preserved(fresh_calendar: caldav.Calendar) -> None:
# ─────────────────────────────────────────────────────────────
# Documented gaps — properties the server currently drops on
# GET. `xfail(strict=False)` means "expected to fail; don't fail
# the suite, but flag XPASS if it starts passing". When the
# read-side fix lands, remove the marker.
# Extended round-trips — properties beyond the DTO-structured
# columns. Post-phase-4 the emitter serves each row's stored
# `ical_data` verbatim (folded per UID), so ATTENDEE, ORGANIZER,
# CATEGORIES, STATUS+TRANSP, VALARM (nested), custom X-* all
# survive PUT → GET. A regression on any of these means either
# storage stopped preserving ical_data OR the emitter reverted
# to DTO regeneration.
# ─────────────────────────────────────────────────────────────
_EMITTER_GAP_REASON = (
"GET regenerates the body from DTO fields via write_vevent "
"(caldav_handler.rs:~770) which only emits UID / SUMMARY / "
"DTSTART / DTEND / DESCRIPTION / LOCATION / RRULE / DTSTAMP / "
"CREATED / LAST-MODIFIED. Every other iCal property is stored "
"in ical_data on the row but silently dropped on read. "
"Fix path: either serve ical_data verbatim on GET, or extend "
"the DTO to carry the full property set."
)
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
def test_attendee_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
uid = f"cov-attendee-{uuid.uuid4().hex[:8]}"
body = _minimal_event(
@@ -185,7 +173,6 @@ def test_attendee_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
assert "alice@example.com" in fetched
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
def test_organizer_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
uid = f"cov-organizer-{uuid.uuid4().hex[:8]}"
body = _minimal_event(
@@ -199,7 +186,6 @@ def test_organizer_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
assert "bob@example.com" in fetched
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
def test_categories_survive_round_trip(fresh_calendar: caldav.Calendar) -> None:
uid = f"cov-cats-{uuid.uuid4().hex[:8]}"
body = _minimal_event(
@@ -213,7 +199,6 @@ def test_categories_survive_round_trip(fresh_calendar: caldav.Calendar) -> None:
assert "ENGINEERING" in fetched
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
def test_status_and_transp_survive_round_trip(
fresh_calendar: caldav.Calendar,
) -> None:
@@ -233,7 +218,6 @@ def test_status_and_transp_survive_round_trip(
assert "TRANSP:TRANSPARENT" in fetched
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
def test_valarm_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
"""VALARM is a nested sub-component of VEVENT (RFC 5545 §3.6.6)
and drives every "remind me 15 min before" popup. It lives
@@ -268,7 +252,6 @@ def test_valarm_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
assert "TRIGGER:-PT15M" in fetched
@pytest.mark.xfail(reason=_EMITTER_GAP_REASON, strict=False)
def test_custom_x_property_survives_round_trip(
fresh_calendar: caldav.Calendar,
) -> None:
+51 -31
View File
@@ -182,19 +182,25 @@ def test_recurring_master_plus_exception_preserves_master(
body = _get_master_ical(fresh_calendar, uid)
assert "RRULE:FREQ=DAILY;COUNT=10" in body, (
"Master row lost its RRULE — the exception overwrote the master. "
"This is the exact regression from #528.\nMaster body: " + body
"This is the exact regression from #528.\nBundle body: " + body
)
assert "SUMMARY:Daily standup" in body
# NOTE: not asserting the exception row is client-visible here.
# RFC 4791 §4.1 + RFC 5545 §3.8.4.4 model a recurring event with
# per-instance overrides as ONE calendar-object-resource whose
# VCALENDAR contains the master VEVENT + all exception VEVENTs.
# OxiCloud currently persists them as separate rows but the
# GET/PROPFIND emitter returns only the master (see phase-4
# follow-up on branch feat/caldav-read-side). Once phase 4
# lands, add: assert "RECURRENCE-ID" in body and
# assert "rescheduled" in body.
# Phase-4 read-side unification: the GET response is the
# WHOLE calendar-object-resource — master + all exception
# VEVENTs concatenated in one VCALENDAR per RFC 4791 §4.1 +
# RFC 5545 §3.6.1. The exception's SUMMARY and its
# RECURRENCE-ID must therefore appear alongside the master's
# RRULE. Pre-phase-4 the emitter served only the master row
# and clients silently dropped the exception on next-PUT.
assert "SUMMARY:Daily standup — rescheduled" in body, (
"Exception VEVENT missing from bundled GET body — phase-4 "
"read-side regression.\nBundle body: " + body
)
assert "RECURRENCE-ID" in body, (
"Exception RECURRENCE-ID missing from bundled GET body — "
"clients need it to correlate the override with the master.\n"
"Bundle body: " + body
)
def test_exception_only_put_does_not_wipe_master(
@@ -255,24 +261,29 @@ def test_exception_only_put_does_not_wipe_master(
),
)
# Master URL GET must still return the master. Pre-fix the
# exception-only PUT would have replaced the master (keyed by
# UID with no recurrence_id filter) — this is the data-loss
# half of #528.
# Bundled GET returns the WHOLE calendar-object-resource:
# master row (unchanged since Step 1 seed) + the updated
# exception row (SUMMARY "rescheduled AGAIN" from the
# exception-only PUT above).
# Pre-phase-3 the exception-only PUT wiped the master.
# Pre-phase-4 the master survived but the exception was
# invisible in the GET body.
# Post-phase-4: both survive AND both are visible.
body = _get_master_ical(fresh_calendar, uid)
assert "RRULE:FREQ=DAILY;COUNT=10" in body
assert "SUMMARY:Daily standup" in body
assert "rescheduled" not in body, (
"GET on the master URL returned the exception's data — the "
"master was clobbered by the exception-only PUT."
assert "RRULE:FREQ=DAILY;COUNT=10" in body, (
"Master row lost its RRULE — data-loss regression from #528.\n"
"Bundle body: " + body
)
assert "SUMMARY:Daily standup" in body, (
"Master's original SUMMARY missing from bundle body — the "
"master row was clobbered by the exception-only PUT.\n"
"Bundle body: " + body
)
assert "SUMMARY:Daily standup — rescheduled AGAIN" in body, (
"Updated exception SUMMARY missing — the second exception-only "
"PUT either failed to update or the emitter dropped the exception "
"row from the bundle.\nBundle body: " + body
)
# NOTE: exception-row survival is not asserted client-side
# today — the emitter only surfaces the master. Phase 4
# (feat/caldav-read-side) will fold master + exceptions into a
# single VCALENDAR body; once landed, add an assertion that the
# updated exception's SUMMARY ("rescheduled AGAIN") is present
# in the same GET body as the master's RRULE.
# ─────────────────────────────────────────────────────────────
@@ -322,7 +333,16 @@ def test_all_day_recurring_master_plus_exception(
f"Master body: {data}"
)
assert "SUMMARY:Weekly review" in data
# NOTE: exception row is stored server-side but not yet visible
# in the GET body. Phase 4 will fold it in — assertion to add
# once that lands: assert "RECURRENCE-ID;VALUE=DATE:20260112" in data.
# Phase-4 bundle: exception row visible in the GET body.
# DATE-form RECURRENCE-ID (with the `;VALUE=DATE` parameter)
# survives verbatim because we serve stored ical_data
# instead of regenerating.
assert "SUMMARY:Weekly review — moved" in data, (
"All-day exception SUMMARY missing from bundled GET body:\n"
+ data
)
assert "RECURRENCE-ID;VALUE=DATE:20260112" in data, (
"DATE-form RECURRENCE-ID lost — either the exception row "
"isn't in the bundle or the emitter mangled the property "
"parameter.\nBundle body: " + data
)