test: full caldav + carddav test suite

This commit is contained in:
Edouard Vanbelle
2026-07-14 20:34:19 +02:00
parent 7966c7178a
commit a9e05ec47c
9 changed files with 1695 additions and 0 deletions
+5
View File
@@ -101,6 +101,11 @@ tests/e2e/blob-report/
tests/e2e/playwright/.cache/
tests/e2e/playwright/.auth/
tests/webdav/storage-litmus/
tests/caldav/storage/
tests/caldav/.venv/
tests/caldav/__pycache__/
tests/caldav/.pytest_cache/
tests/caldav/server.log
# Test fixtures generated on-the-fly by tests/api/run.sh
tests/fixtures/chunk-over-cap-*.bin
+21
View File
@@ -199,6 +199,27 @@ api-test:
echo "XXX litmus webdav not found, ignore test"
fi
# CalDAV client-driven conformance suite.
#
# Drives OxiCloud through the maintained `python-caldav` client library
# — the same VObject/RFC 5545 stack Thunderbird / DAVx⁵ / Gnome Calendar
# use. Complements Hurl coverage (which exercises raw HTTP) by proving
# a real client can round-trip recurring events, per-instance overrides
# (RFC 5545 §3.8.4.4), and all-day masters (the shape #528 was filed
# against).
#
# Not chained into `api-test` because it needs python3; run explicitly.
# The orchestrator spawns its own postgres + server on port 8091 so it
# can run in parallel with api-test/webdav.
test-caldav:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v python3 >/dev/null 2>&1; then
echo "XXX python3 not found — skipping CalDAV client-driven tests"
exit 0
fi
./tests/caldav/run-pycaldav.sh
# ---------------------------------------------------------------------------
# SvelteKit frontend (frontend/) — the only frontend. These `fe-*` recipes
# drive its dev server, build, lint and tests.
+226
View File
@@ -0,0 +1,226 @@
"""Shared pytest fixtures for the pycaldav conformance suite.
Environment (injected by `run-pycaldav.sh`):
OXICLOUD_CALDAV_URL — base CalDAV URL, e.g. http://localhost:8091/caldav/
OXICLOUD_CALDAV_USERNAME — admin username
OXICLOUD_CALDAV_APP_PASSWORD — app password (NOT the account password)
The suite deliberately talks to the same URL a real CalDAV client
would — via HTTP Basic + an app password, no JWT. That's how
Thunderbird, Apple Calendar, DAVx⁵ and Gnome Calendar all connect.
"""
from __future__ import annotations
import logging
import os
import re
import uuid
import caldav
import pytest
# ─────────────────────────────────────────────────────────────
# Silence pycaldav's chatty logging during test setup.
#
# python-caldav's `make_calendar()` internally does MKCALENDAR +
# PROPPATCH-displayname. OxiCloud's MKCALENDAR assigns its own
# server-side UUID (spec deviation, see fresh_calendar fixture),
# so the follow-up PROPPATCH lands on a URL the server doesn't
# know → 500 / 404. pycaldav catches and moves on ("calendar
# server does not support display name on calendar? Ignoring"),
# but its handler logs at CRITICAL with `exc_info=True`, dumping
# a full XMLSyntaxError traceback under pytest's "Captured log
# setup" section on every test. That noise dwarfed real
# assertion output.
#
# Filtering at logger level here has nothing to capture, so the
# traceback disappears from the pytest output.
# ─────────────────────────────────────────────────────────────
logging.getLogger("caldav").setLevel(logging.ERROR)
logging.getLogger("caldav.davclient").setLevel(logging.ERROR)
# pycaldav uses `logging.critical(..., exc_info=True)` on the ROOT
# logger for the "expected XML, got JSON" case. `setLevel(ERROR)`
# does NOT hide CRITICAL (CRITICAL > ERROR), so use the override
# switch instead: `logging.disable(CRITICAL)` disables every level
# up to and INCLUDING CRITICAL, killing pycaldav's setup traceback
# spam outright. run-pycaldav.sh also passes `--show-capture=no`
# so any remaining captured output is hidden on failure — defence
# in depth, since one clean-output knob is easier to forget than two.
logging.getLogger().setLevel(logging.ERROR)
logging.disable(logging.CRITICAL)
def _env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(
f"Missing required env var {name}. Run this suite via "
"tests/caldav/run-pycaldav.sh (or `just test-caldav`) which "
"bootstraps admin + app password before invoking pytest."
)
return value
@pytest.fixture(scope="session")
def caldav_url() -> str:
return _env("OXICLOUD_CALDAV_URL")
@pytest.fixture(scope="session")
def caldav_username() -> str:
return _env("OXICLOUD_CALDAV_USERNAME")
@pytest.fixture(scope="session")
def caldav_app_password() -> str:
return _env("OXICLOUD_CALDAV_APP_PASSWORD")
@pytest.fixture(scope="session")
def dav_client(
caldav_url: str, caldav_username: str, caldav_app_password: str
) -> caldav.DAVClient:
"""The single DAVClient used across the session — python-caldav
reuses one requests.Session under the hood."""
return caldav.DAVClient(
url=caldav_url,
username=caldav_username,
password=caldav_app_password,
)
@pytest.fixture
def fresh_calendar(dav_client: caldav.DAVClient):
"""A brand-new calendar per test. The name is randomised so parallel
workers (`pytest -n auto` in the future) don't collide, and every
test teardown drops the calendar — no cross-test bleed.
Server-URL rebind: OxiCloud's MKCALENDAR assigns its own UUID and
ignores the URL slug the client PUT to (design choice — the URL
slug becomes the display name when the request body is empty; the
canonical URL is `/caldav/<server-uuid>/`). python-caldav's
`make_calendar()` returns a Calendar bound to the client-derived
URL, which then 404s on every subsequent op. Re-discover the
server-authoritative URL by listing the principal's calendars and
matching by displayname."""
principal = dav_client.principal()
name = f"pycaldav-{uuid.uuid4().hex[:12]}"
principal.make_calendar(name=name)
calendar = next(
(c for c in principal.calendars() if c.get_display_name() == name),
None,
)
if calendar is None:
raise RuntimeError(
f"MKCALENDAR completed but the new calendar '{name}' did not "
"appear in principal.calendars() — server-side provisioning "
"issue."
)
yield calendar
try:
calendar.delete()
except Exception:
# Teardown is best-effort — if a test crashed the server, we
# don't want the teardown crash to mask the real failure.
pass
# ─────────────────────────────────────────────────────────────
# CardDAV fixtures — python-caldav has no first-class CardDAV
# support, so these drive the server via raw HTTP through the
# same authenticated DAVClient session. Kept in this conftest
# (not a sibling tests/carddav/ dir) for now — one venv, one
# `just test-caldav` entry point. If the CardDAV coverage
# grows past ~one file's worth, promote to tests/carddav/ with
# its own runner.
# ─────────────────────────────────────────────────────────────
@pytest.fixture(scope="session")
def carddav_url(caldav_url: str) -> str:
"""CardDAV base URL derived from the CalDAV URL — the
orchestrator only exports `OXICLOUD_CALDAV_URL`, but the
server mounts both under the same origin. Swap `/caldav/`
for `/carddav/`."""
if "/caldav/" not in caldav_url:
raise RuntimeError(
f"OXICLOUD_CALDAV_URL={caldav_url!r} does not contain "
"'/caldav/'; can't derive the CardDAV counterpart."
)
return caldav_url.replace("/caldav/", "/carddav/", 1)
@pytest.fixture
def fresh_addressbook(dav_client: caldav.DAVClient, carddav_url: str):
"""Create a fresh CardDAV address book and return its
server-authoritative URL as a string.
Same URL-rebind hazard as `fresh_calendar`: OxiCloud's MKCOL
assigns its own UUID and ignores the URL slug we PUT to
(RFC 6352 leaves this implementation-defined). Discover the
canonical URL via PROPFIND Depth 1 on the CardDAV root and
match by displayname.
Yields the URL (string, trailing `/`); teardown DELETEs it
on best-effort."""
name = f"pycarddav-{uuid.uuid4().hex[:12]}"
mkcol_url = carddav_url.rstrip("/") + f"/{name}/"
r = dav_client.request(mkcol_url, method="MKCOL", body="")
if r.status not in (200, 201):
raise RuntimeError(
f"MKCOL {mkcol_url} → HTTP {r.status}\n{r.raw!r}"
)
propfind_body = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<D:propfind xmlns:D="DAV:">'
"<D:prop><D:displayname/><D:resourcetype/></D:prop>"
"</D:propfind>"
)
r = dav_client.request(
carddav_url,
method="PROPFIND",
body=propfind_body,
headers={"Depth": "1", "Content-Type": "application/xml"},
)
if r.status < 200 or r.status >= 300:
raise RuntimeError(
f"PROPFIND {carddav_url} → HTTP {r.status}\n{r.raw!r}"
)
xml = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
# Naive but sufficient: iterate <D:response> blocks; pick the
# one whose block text contains our chosen displayname; pull
# its <D:href> as the canonical URL slug.
href = None
for block in re.finditer(
r"<D:response>(.*?)</D:response>", xml, flags=re.DOTALL
):
chunk = block.group(1)
if name in chunk:
m = re.search(r"<D:href>(/carddav/[^<]+/)</D:href>", chunk)
if m:
href = m.group(1)
break
if href is None:
raise RuntimeError(
f"MKCOL succeeded but PROPFIND did not surface an address "
f"book with displayname '{name}':\n{xml}"
)
# href from the server is a path (e.g. `/carddav/<uuid>/`);
# combine with the URL origin to get an absolute URL usable in
# subsequent `dav_client.request()` calls.
origin = re.match(r"^(https?://[^/]+)", carddav_url).group(1)
ab_url = f"{origin}{href}"
yield ab_url
try:
dav_client.request(ab_url, method="DELETE")
except Exception:
pass
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# CalDAV end-to-end conformance test using python-caldav.
#
# python-caldav (https://github.com/python-caldav/caldav) is the same
# maintained client library used to test radicale, xandikos, davical.
# Driving OxiCloud through it exercises the code paths that real
# clients (Thunderbird, Apple Calendar, Gnome Calendar, DAVx⁵) hit —
# it's the closest cognate to what `litmus` does for WebDAV, but for
# the CalDAV surface.
#
# Usage (from repo root via justfile):
# just test-caldav
#
# Or directly:
# bash tests/caldav/run-pycaldav.sh
#
# Requires: python3 (>= 3.10 for python-caldav 1.x), curl, jq, docker
# The `caldav` library + pytest are installed into a per-run venv at
# `tests/caldav/.venv/`, gitignored.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
COMMON="$REPO_ROOT/tests/common"
CALDAV_DIR="$REPO_ROOT/tests/caldav"
# shellcheck source=test.env
source "$CALDAV_DIR/test.env"
SERVER_PORT="${base_url##*:}"
log() { echo "[caldav] $*"; }
die() { echo "[caldav] ERROR: $*" >&2; exit 1; }
# ── Dependency checks ─────────────────────────────────────────────────────────
if ! command -v python3 >/dev/null 2>&1; then
die "python3 not found. Install a recent Python 3."
fi
if ! command -v jq >/dev/null 2>&1; then
die "jq not found."
fi
if ! command -v curl >/dev/null 2>&1; then
die "curl not found."
fi
# ── Teardown (always runs on exit) ────────────────────────────────────────────
SERVER_PID=""
SUITE_EXIT=0
cleanup() {
# If pytest failed, show the last chunk of server log so
# someone debugging doesn't have to hunt for the file.
if [[ $SUITE_EXIT -ne 0 && -n "${SERVER_LOG:-}" && -f "$SERVER_LOG" ]]; then
log "── server log tail (last 40 lines) ─────────────────────────"
tail -n 40 "$SERVER_LOG" >&2
log "── end server log tail ─────────────────────────────────────"
fi
if [[ -n "$SERVER_PID" ]]; then
log "Stopping OxiCloud (pid $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
bash "$COMMON/stop-db.sh"
}
trap cleanup EXIT
# ── 1. Start postgres ────────────────────────────────────────────────────────
bash "$COMMON/spawn-db.sh"
# ── 2. Start OxiCloud ────────────────────────────────────────────────────────
set -a
# shellcheck source=../common/server.env
source "$COMMON/server.env"
OXICLOUD_SERVER_PORT=$SERVER_PORT
OXICLOUD_STORAGE_PATH="$CALDAV_DIR/storage"
set +a
# Wipe storage between runs so a stale run doesn't leak into fresh state.
# Regex-gated via wipe-storage.sh so we can never `rm -rf /`.
# shellcheck source=../common/wipe-storage.sh
source "$COMMON/wipe-storage.sh"
wipe_storage "$OXICLOUD_STORAGE_PATH"
BUILD_TARGET="${BUILD_TARGET:-debug}"
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
# ALWAYS build — a `cargo check` / `cargo clippy` during development
# leaves the target/ metadata fresh but NEVER produces or updates the
# binary at target/<profile>/oxicloud. Skipping the rebuild on
# "binary already exists" then runs pytest against a stale binary,
# which manifests as impossible-looking test failures (e.g. "phase 3
# routing broken" when the binary is from phase 2). Cargo's
# incremental compile makes this near-free when nothing changed.
log "Building OxiCloud ($BUILD_TARGET) — incremental compile, fast when up-to-date..."
case "$BUILD_TARGET" in
debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;;
release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;;
*) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;;
esac
[[ -x "$OXICLOUD_BIN" ]] || die "Build completed but $OXICLOUD_BIN is missing"
log "Starting OxiCloud ($BUILD_TARGET) on port $SERVER_PORT..."
# `--config` pins the env file, suppressing the default `.env` probe so
# a developer's repo-root `.env` can never leak into a test run.
#
# Redirect server stdout/stderr to a log file — otherwise every audit
# line + tower-http error line interleaves with pytest's per-test
# output, drowning PASSED/XFAIL markers under log spam. Cat the tail
# of the log on cleanup so failures still surface the last events.
SERVER_LOG="$CALDAV_DIR/server.log"
: > "$SERVER_LOG"
"$OXICLOUD_BIN" --config "$COMMON/server.env" >"$SERVER_LOG" 2>&1 &
SERVER_PID=$!
log "Server log: $SERVER_LOG (tail -f to watch live)"
log "Waiting for server at $base_url..."
deadline=$(( $(date +%s) + 60 ))
until curl -sf "$base_url/ready" >/dev/null 2>&1; do
[[ $(date +%s) -ge $deadline ]] && die "Server did not become ready within 60s"
sleep 1
done
log "Server ready."
# ── 3. Bootstrap admin + app password ────────────────────────────────────────
SETUP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$username\",\"email\":\"$email\",\"password\":\"$password\"}" \
"$base_url/api/setup")
case "$SETUP_STATUS" in
201) log "Admin account created." ;;
403) log "Admin account already exists." ;;
*) die "Unexpected /api/setup status: $SETUP_STATUS" ;;
esac
LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \
-d "{\"username\":\"$username\",\"password\":\"$password\"}" \
"$base_url/api/auth/login")
JWT=$(jq -r '.access_token' <<<"$LOGIN_RESP")
[[ -z "$JWT" || "$JWT" == "null" ]] && die "Login failed: $LOGIN_RESP"
log "Logged in as $username."
# Real CalDAV clients authenticate via app password (Basic Auth), not
# JWT — same rule as WebDAV. Session/account passwords are deliberately
# refused on DAV surfaces (memory: DAV surfaces require app passwords
# only). python-caldav uses HTTP Basic; the app password IS the credential.
APP_PW_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{"label":"pycaldav-test"}' \
"$base_url/api/auth/app-passwords")
APP_PASSWORD=$(jq -r '.password' <<<"$APP_PW_RESP")
[[ -z "$APP_PASSWORD" || "$APP_PASSWORD" == "null" ]] && die "App password creation failed: $APP_PW_RESP"
log "App password created."
# ── 4. Python venv + install caldav + pytest ─────────────────────────────────
VENV="$CALDAV_DIR/.venv"
if [[ ! -d "$VENV" ]]; then
log "Creating Python venv at $VENV..."
python3 -m venv "$VENV"
fi
# shellcheck source=/dev/null
source "$VENV/bin/activate"
# Pin the major to avoid a surprise API break on `caldav` 2.x if/when
# that lands. `pytest` version is loose — no reason to over-constrain
# a test-only dep.
if ! python3 -c "import caldav" 2>/dev/null; then
log "Installing python-caldav + pytest into venv..."
pip install --quiet 'caldav>=1.3,<2.0' 'pytest>=7,<9'
fi
# ── 5. Run pytest ────────────────────────────────────────────────────────────
log "Running pytest suite in $CALDAV_DIR/"
export OXICLOUD_CALDAV_URL="$base_url/caldav/"
export OXICLOUD_CALDAV_USERNAME="$username"
export OXICLOUD_CALDAV_APP_PASSWORD="$APP_PASSWORD"
cd "$CALDAV_DIR"
# `--show-capture=no` hides pytest's "Captured log setup/call" section
# entirely on failure. pycaldav emits a full lxml XMLSyntaxError
# traceback via `logging.critical(..., exc_info=True)` on every
# make_calendar() when the server ignores the URL slug — genuine
# assertion output was drowning in it. Real test failures still show
# the assertion line + short traceback via --tb=short.
#
# Don't let a pytest non-zero exit skip the cleanup trap — capture
# the status, invoke cleanup (which tails the server log on failure),
# then re-emit the exit code.
set +e
pytest -v --tb=short --show-capture=no "$@"
SUITE_EXIT=$?
set -e
if [[ $SUITE_EXIT -eq 0 ]]; then
log "pycaldav suite passed."
else
log "pycaldav suite failed (exit $SUITE_EXIT)."
fi
# Always show where the server log is — useful for post-mortem
# ("why did the server log an error next to that XFAIL?") even
# on green runs. On failure the cleanup trap has already dumped
# the tail; the file itself sticks around until the next run
# truncates it.
log "Server log preserved at: $SERVER_LOG"
exit "$SUITE_EXIT"
+11
View File
@@ -0,0 +1,11 @@
# Test credentials for local/CI CalDAV client-driven tests — NOT real secrets.
#
# Uses a distinct port from api-test/webdav (8087) and webdav-drive-root
# (8089) so it can run concurrently with those suites if the developer
# opens multiple terminals. The orchestrator (`run-pycaldav.sh`) spawns
# its own postgres + server tied to this port.
base_url=http://localhost:8091
username=admin
email=admin@example.com
# gitguardian:ignore
password=TestPassword1!
+290
View File
@@ -0,0 +1,290 @@
"""CardDAV (RFC 6352) surface coverage.
python-caldav has no CardDAV support (the library name is a bit
misleading — it's CalDAV-only). These tests drive the server via
raw HTTP through the SAME authenticated `dav_client` session used
by the CalDAV tests, so credentials + connection reuse stay
consistent with the rest of the suite.
Fixtures:
* `carddav_url` — CardDAV base URL, derived from OXICLOUD_CALDAV_URL
by replacing `/caldav/` with `/carddav/`.
* `fresh_addressbook` — a brand-new address book per test; yields
the server-authoritative URL as a string; teardown DELETEs it.
Same emitter-gap caveats as `test_ical_coverage.py`: the server
regenerates vCard bodies from stored DTO fields on GET, so
properties beyond FN / N / EMAIL may be silently dropped. Tests
here split into sanity (must round-trip) vs xfail (documented
gaps).
"""
from __future__ import annotations
import textwrap
import uuid
import caldav
import pytest
# ─────────────────────────────────────────────────────────────
# Helpers — mirror the CalDAV pattern. Raw HTTP through the
# authenticated pycaldav session; no client-library abstractions.
# ─────────────────────────────────────────────────────────────
def _dedent_vcard(body: str) -> str:
"""RFC 6350 §3.2 mandates CRLF between properties, same as
iCalendar. Normalise text-block indentation and line endings."""
return textwrap.dedent(body).strip().replace("\n", "\r\n") + "\r\n"
def _put_vcard(
dav_client: caldav.DAVClient, addressbook_url: str, uid: str, body: str
) -> None:
url = addressbook_url.rstrip("/") + f"/{uid}.vcf"
r = dav_client.request(
url,
method="PUT",
body=body,
headers={"Content-Type": "text/vcard; charset=utf-8"},
)
if r.status < 200 or r.status >= 300:
raise AssertionError(
f"PUT {url} → HTTP {r.status}\nbody: {body!r}\nresponse: {r.raw!r}"
)
def _get_vcard(
dav_client: caldav.DAVClient, addressbook_url: str, uid: str
) -> str:
url = addressbook_url.rstrip("/") + f"/{uid}.vcf"
r = dav_client.request(url, method="GET")
if r.status < 200 or r.status >= 300:
raise AssertionError(f"GET {url} → HTTP {r.status}\n{r.raw!r}")
return r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
def _delete_vcard(
dav_client: caldav.DAVClient, addressbook_url: str, uid: str
) -> int:
url = addressbook_url.rstrip("/") + f"/{uid}.vcf"
r = dav_client.request(url, method="DELETE")
return r.status
def _minimal_vcard(uid: str, **extras: str) -> str:
"""Build a minimal RFC 6350 vCard 4.0 body with the given
extra property lines injected before END:VCARD."""
base = f"""\
BEGIN:VCARD
VERSION:4.0
UID:{uid}
FN:Coverage Contact
N:Coverage;Contact;;;
"""
body = textwrap.dedent(base).rstrip() + "\n"
for line in extras.values():
body += line + "\n"
body += "END:VCARD\n"
return body.replace("\n", "\r\n")
# ─────────────────────────────────────────────────────────────
# Sanity — properties the server round-trips.
# ─────────────────────────────────────────────────────────────
def test_vcard_basic_round_trip(
dav_client: caldav.DAVClient, fresh_addressbook: str
) -> None:
"""The core CardDAV contract: PUT a vCard, GET it back, body
contains at least the UID + FN we sent. FN (formatted name)
is RFC 6350 §6.2.1 REQUIRED — a vCard without it is invalid,
and the server must preserve it verbatim."""
uid = f"cov-basic-{uuid.uuid4().hex[:8]}"
body = _minimal_vcard(uid)
_put_vcard(dav_client, fresh_addressbook, uid, body)
fetched = _get_vcard(dav_client, fresh_addressbook, uid)
assert f"UID:{uid}" in fetched, f"UID missing from GET:\n{fetched}"
assert "FN:Coverage Contact" in fetched, (
f"FN dropped on round-trip:\n{fetched}"
)
def test_vcard_email_survives_round_trip(
dav_client: caldav.DAVClient, fresh_addressbook: str
) -> None:
"""EMAIL (RFC 6350 §6.4.2) — one of the two properties most
real contact clients set. Loss here would break sync with
every address-book UI."""
uid = f"cov-email-{uuid.uuid4().hex[:8]}"
body = _minimal_vcard(
uid,
email="EMAIL;TYPE=work:coverage.contact@example.com",
)
_put_vcard(dav_client, fresh_addressbook, uid, body)
fetched = _get_vcard(dav_client, fresh_addressbook, uid)
assert "coverage.contact@example.com" in fetched, (
f"EMAIL dropped on round-trip:\n{fetched}"
)
def test_vcard_delete_removes_it(
dav_client: caldav.DAVClient, fresh_addressbook: str
) -> None:
"""PUT → DELETE → GET must 404. Regression guard against
delete-doesn't-actually-delete bugs (which have surfaced in
other DAV surfaces during D7 work)."""
uid = f"cov-del-{uuid.uuid4().hex[:8]}"
_put_vcard(dav_client, fresh_addressbook, uid, _minimal_vcard(uid))
status = _delete_vcard(dav_client, fresh_addressbook, uid)
assert 200 <= status < 300, f"DELETE returned HTTP {status}"
# Re-fetch should 404. `_get_vcard` raises on non-2xx; catch it.
url = fresh_addressbook.rstrip("/") + f"/{uid}.vcf"
r = dav_client.request(url, method="GET")
assert r.status == 404, (
f"GET after DELETE expected 404; got HTTP {r.status}"
)
def test_addressbook_shows_up_in_propfind(
dav_client: caldav.DAVClient,
carddav_url: str,
fresh_addressbook: str,
) -> None:
"""Sanity: the just-created address book is listed by a
PROPFIND Depth 1 on the CardDAV root. Same shape a real
client uses to enumerate address books at login."""
propfind_body = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<D:propfind xmlns:D="DAV:">'
"<D:prop><D:displayname/><D:resourcetype/></D:prop>"
"</D:propfind>"
)
r = dav_client.request(
carddav_url,
method="PROPFIND",
body=propfind_body,
headers={"Depth": "1", "Content-Type": "application/xml"},
)
assert 200 <= r.status < 300, f"PROPFIND → HTTP {r.status}"
xml = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
# `fresh_addressbook` is an absolute URL; the href in the
# PROPFIND response is the path portion. Extract and check.
import urllib.parse
ab_path = urllib.parse.urlparse(fresh_addressbook).path
assert ab_path in xml, (
f"Fresh address book path {ab_path} missing from PROPFIND:\n{xml}"
)
# ─────────────────────────────────────────────────────────────
# Documented gaps — vCard properties the server currently drops
# on GET. Same shape as the CalDAV emitter gap: server rebuilds
# the response body from stored DTO fields; properties not in
# the DTO surface are silently dropped.
# ─────────────────────────────────────────────────────────────
_TEL_URI_PARSER_BUG_REASON = (
"contact_service.rs::parse_vcard splits the TEL line by ':' "
"and takes .nth(1) as the number — a URI-form value like "
"`TEL;TYPE=cell;VALUE=uri:tel:+15551234567` gets sliced to "
"'tel' (the middle segment), losing the actual phone number. "
"Real clients (Apple Contacts, DAVx⁵) commonly emit the URI "
"form. Fix: split on the FIRST ':' only, or parse the "
"parameter list properly. Own fix branch."
)
_ADR_UNPARSED_REASON = (
"contact_service.rs::parse_vcard has NO handler for ADR — the "
"structured-address property (RFC 6350 §6.3.1) is silently "
"dropped at PUT time. DTO carries an `address: Vec<Address>` "
"field the emitter honours; parser just never populates it. "
"Fix: extend the match with an ADR branch that splits on ';' "
"into (pobox, ext, street, city, region, postal, country) — "
"mirror the emitter's format at contact_service.rs::195-ish."
)
def test_vcard_org_and_title_survive_round_trip(
dav_client: caldav.DAVClient, fresh_addressbook: str
) -> None:
"""ORG + TITLE (RFC 6350 §6.6.4 / §6.6.1). Business-card
fields — losing them means everyone's job title disappears
from address-book UIs after the first sync.
Passes today: parse_vcard has ORG / TITLE branches; the
emitter (contact_to_vcard) rewrites both from DTO fields."""
uid = f"cov-org-{uuid.uuid4().hex[:8]}"
body = _minimal_vcard(
uid,
org="ORG:Acme Corporation;R&D",
title="TITLE:Principal Engineer",
)
_put_vcard(dav_client, fresh_addressbook, uid, body)
fetched = _get_vcard(dav_client, fresh_addressbook, uid)
assert "Acme Corporation" in fetched
assert "Principal Engineer" in fetched
def test_vcard_note_survives_round_trip(
dav_client: caldav.DAVClient, fresh_addressbook: str
) -> None:
"""NOTE (RFC 6350 §6.7.2). Free-form text field every contact
UI exposes. Passes today: parse_vcard strips NOTE:, emitter
re-emits with newline escaping."""
uid = f"cov-note-{uuid.uuid4().hex[:8]}"
body = _minimal_vcard(
uid,
note="NOTE:Met at KubeCon 2026. Prefers email over phone.",
)
_put_vcard(dav_client, fresh_addressbook, uid, body)
fetched = _get_vcard(dav_client, fresh_addressbook, uid)
assert "KubeCon 2026" in fetched
@pytest.mark.xfail(reason=_TEL_URI_PARSER_BUG_REASON, strict=False)
def test_vcard_tel_uri_form_survives_round_trip(
dav_client: caldav.DAVClient, fresh_addressbook: str
) -> None:
"""TEL (RFC 6350 §6.4.1) with URI-form value + TYPE parameter —
the shape Apple Contacts / DAVx⁵ send for every phone number.
See _TEL_URI_PARSER_BUG_REASON."""
uid = f"cov-tel-{uuid.uuid4().hex[:8]}"
body = _minimal_vcard(
uid,
tel="TEL;TYPE=cell;VALUE=uri:tel:+15551234567",
)
_put_vcard(dav_client, fresh_addressbook, uid, body)
fetched = _get_vcard(dav_client, fresh_addressbook, uid)
assert "+15551234567" in fetched
@pytest.mark.xfail(reason=_ADR_UNPARSED_REASON, strict=False)
def test_vcard_adr_survives_round_trip(
dav_client: caldav.DAVClient, fresh_addressbook: str
) -> None:
"""ADR (RFC 6350 §6.3.1) with structured components. Semicolon
is the structured-value separator. See _ADR_UNPARSED_REASON —
parser has no ADR branch at all."""
uid = f"cov-adr-{uuid.uuid4().hex[:8]}"
body = _minimal_vcard(
uid,
adr="ADR;TYPE=home:;;42 Rue de Rivoli;Paris;;75001;France",
)
_put_vcard(dav_client, fresh_addressbook, uid, body)
fetched = _get_vcard(dav_client, fresh_addressbook, uid)
assert "Rue de Rivoli" in fetched
assert "Paris" in fetched
+287
View File
@@ -0,0 +1,287 @@
"""Non-recurring iCalendar property coverage via python-caldav.
Complements `test_recurring.py` (the #528 regression suite) by
sweeping the property surface of a single, non-recurring VEVENT.
Real CalDAV clients send many properties beyond DTSTART/DTEND +
SUMMARY; whether those survive a PUT → GET round-trip is what
this file measures.
The GET path in `caldav_handler.rs::write_vevent` regenerates
the response body from the stored DTO fields (UID / SUMMARY /
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.
"""
from __future__ import annotations
import textwrap
import uuid
import caldav
import pytest
# ─────────────────────────────────────────────────────────────
# Helpers (mirror the raw-HTTP-PUT / master-URL-GET pattern
# from test_recurring.py). Kept local to this file for now;
# fold into conftest.py if a third test file wants them.
# ─────────────────────────────────────────────────────────────
def _dedent(ical: str) -> str:
return textwrap.dedent(ical).strip().replace("\n", "\r\n") + "\r\n"
def _put_ical(calendar: caldav.Calendar, uid: str, body: str) -> None:
url = str(calendar.url).rstrip("/") + f"/{uid}.ics"
r = calendar.client.request(
url,
method="PUT",
body=body,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if r.status < 200 or r.status >= 300:
raise AssertionError(
f"PUT {url} → HTTP {r.status}\nbody: {body!r}\nresponse: {r.raw!r}"
)
def _get_ical(calendar: caldav.Calendar, uid: str) -> str:
url = str(calendar.url).rstrip("/") + f"/{uid}.ics"
r = calendar.client.request(url, method="GET")
if r.status < 200 or r.status >= 300:
raise AssertionError(f"GET {url} → HTTP {r.status}")
return r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
def _minimal_event(uid: str, **extra_lines: str) -> str:
"""Build a minimal VEVENT with the given extra iCal property lines
injected before END:VEVENT. Values in `extra_lines` should be full
property lines (name+value), one per key. The key exists only so
tests can override without clobbering; it isn't emitted."""
base = f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav coverage//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260101T090000Z
DTEND:20260101T093000Z
SUMMARY:Coverage event
"""
body = textwrap.dedent(base).rstrip() + "\n"
for line in extra_lines.values():
body += line + "\n"
body += "END:VEVENT\nEND:VCALENDAR\n"
return body.replace("\n", "\r\n")
# ─────────────────────────────────────────────────────────────
# Sanity — properties the server DOES emit on GET.
# ─────────────────────────────────────────────────────────────
def test_description_with_escaped_chars_round_trips(
fresh_calendar: caldav.Calendar,
) -> None:
"""RFC 5545 §3.3.11 mandates comma / semicolon / newline
escaping in TEXT values. A Description with all three must
survive PUT → GET.
Note: our own generate_event_ical only escapes newlines
(`\\n`), not commas or semicolons — this test guards the
minimum bar. A stricter test could assert exact escape
handling; deferred until the emitter is RFC-strict."""
uid = f"cov-desc-{uuid.uuid4().hex[:8]}"
# RFC 5545 escapes: `\n` for newline, `\,` for comma, `\;` for
# semicolon. Client sends them ALREADY escaped in the wire body.
body = _minimal_event(
uid,
description=r"DESCRIPTION:multi-line\ntext with a comma\, and a semi\;colon.",
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "multi-line" in fetched
# Server currently emits `\n` back but may drop `\,` / `\;`
# escapes — accept either the escaped or unescaped form here so
# the sanity check tolerates the current emitter without failing
# on the strict spec detail.
assert (
"comma" in fetched.lower()
), f"DESCRIPTION body lost the comma text entirely:\n{fetched}"
def test_location_survives_round_trip(fresh_calendar: caldav.Calendar) -> None:
uid = f"cov-loc-{uuid.uuid4().hex[:8]}"
body = _minimal_event(
uid,
location="LOCATION:Room 3B\\, Building 42",
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "Room 3B" in fetched, f"LOCATION lost:\n{fetched}"
def test_uid_and_dtstamp_are_preserved(fresh_calendar: caldav.Calendar) -> None:
"""Belt-and-braces sanity — UID is the resource identifier and
DTSTAMP is required by RFC 5545 §3.8.7.2 on every VEVENT. Both
are emitted from DTO fields, so both round-trip cleanly."""
uid = f"cov-uid-{uuid.uuid4().hex[:8]}"
body = _minimal_event(uid)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert f"UID:{uid}" in fetched
assert "DTSTAMP:" in fetched
# ─────────────────────────────────────────────────────────────
# 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.
# ─────────────────────────────────────────────────────────────
_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(
uid,
attendee=(
"ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED;RSVP=TRUE:"
"mailto:alice@example.com"
),
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "ATTENDEE" in fetched, f"ATTENDEE dropped:\n{fetched}"
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(
uid,
organizer="ORGANIZER;CN=Bob:mailto:bob@example.com",
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "ORGANIZER" in fetched
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(
uid,
categories="CATEGORIES:MEETING,ENGINEERING,SPRINT-42",
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "CATEGORIES" in fetched
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:
"""STATUS (RFC 5545 §3.8.1.11) and TRANSP (§3.8.2.7) drive
"tentative vs confirmed" and "shows as busy vs free" in every
calendar client UI. Losing them silently is user-visible."""
uid = f"cov-status-{uuid.uuid4().hex[:8]}"
body = _minimal_event(
uid,
status="STATUS:TENTATIVE",
transp="TRANSP:TRANSPARENT",
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "STATUS:TENTATIVE" in fetched
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
entirely in ical_data on the row and is invisible to the DTO.
Dropping it on GET means alarms silently disappear after the
first client sync."""
uid = f"cov-alarm-{uuid.uuid4().hex[:8]}"
body = _dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav coverage//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260101T090000Z
DTEND:20260101T093000Z
SUMMARY:Event with alarm
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT15M
DESCRIPTION:15 min reminder
END:VALARM
END:VEVENT
END:VCALENDAR
"""
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "BEGIN:VALARM" in fetched, f"VALARM block dropped:\n{fetched}"
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:
"""Custom `X-*` properties (RFC 5545 §3.8.8.2). Apple Calendar
uses `X-APPLE-*`, DAVx⁵ uses `X-MOZ-*`, and Nextcloud uses
`X-NEXTCLOUD-*`. Dropping them breaks client-specific UI cues
without corrupting core interop."""
uid = f"cov-xprop-{uuid.uuid4().hex[:8]}"
body = _minimal_event(
uid,
xprop="X-MOZ-LASTACK:20260101T090000Z",
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_ical(fresh_calendar, uid)
assert "X-MOZ-LASTACK" in fetched
+328
View File
@@ -0,0 +1,328 @@
"""End-to-end regression for AtalayaLabs/OxiCloud#528 via python-caldav.
The Hurl coverage in `tests/api/caldav_recurring.hurl` exercises the
raw HTTP surface; this file drives the SAME behaviour through the
python-caldav client library — the same VObject + RFC 5545 stack that
Thunderbird, DAVx⁵ and Gnome Calendar use. If a real client's shape
diverges from what our Hurl fixtures send, this suite catches it.
Two access paths need distinguishing:
* URL GET on `/caldav/<cal>/<uid>.ics` — routes through
`find_event_by_ical_uid` which is master-only. This is what
single-file iCal clients (older Thunderbird, Apple Reminders'
quick-lookup) hit.
* calendar-query REPORT — returns every calendar-object-resource
matching the filter, so a UID with both a master AND per-instance
overrides yields multiple entries. This is what modern CalDAV
clients (Thunderbird 2024+, DAVx⁵, Apple Calendar) use for
initial sync and delta refresh.
The suite exercises both paths — mixing them up is what tripped the
first draft (calendar.event_by_uid → REPORT under the hood, returned
the exception, tests failed).
"""
from __future__ import annotations
import textwrap
import uuid
import caldav
# ─────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────
def _dedent(ical: str) -> str:
"""Strip test-source indentation and normalise line endings to
CRLF, which RFC 5545 §3.1 mandates."""
return textwrap.dedent(ical).strip().replace("\n", "\r\n") + "\r\n"
def _put_ical(calendar: caldav.Calendar, uid: str, body: str) -> None:
"""PUT the raw iCalendar body directly via pycaldav's authenticated
session — bypassing pycaldav's `save_event()`.
Empirically, `save_event(body)` re-parses the body through pycaldav's
icalendar/vobject stack and re-serialises before PUTting. When the
body contains a master VEVENT + a per-instance override sharing the
same UID, that internal re-serialisation dropped the master and only
sent the override — the exact behaviour the #528 fix must defend
against. Bypassing that layer sends the bytes verbatim, mirroring
what a real client (Thunderbird / DAVx⁵ / Apple Calendar) puts on
the wire.
"""
url = str(calendar.url).rstrip("/") + f"/{uid}.ics"
response = calendar.client.request(
url,
method="PUT",
body=body,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if response.status < 200 or response.status >= 300:
raise AssertionError(
f"PUT {url} → HTTP {response.status}\nbody sent: {body!r}\n"
f"response: {response.raw!r}"
)
def _get_master_ical(calendar: caldav.Calendar, uid: str) -> str:
"""Direct URL GET on `/caldav/<cal>/<uid>.ics` — routes through
the master-only lookup on the server. Returns the raw response
body (text/calendar).
This bypasses pycaldav's REPORT-based `event_by_uid()` which
would return every row matching the UID (master + exceptions)
and force the caller to filter.
"""
url = str(calendar.url).rstrip("/") + f"/{uid}.ics"
response = calendar.client.request(url, method="GET")
if response.status < 200 or response.status >= 300:
raise AssertionError(
f"GET {url} → HTTP {response.status}\n"
f"body: {response.raw!r}"
)
return response.raw.decode("utf-8") if isinstance(response.raw, bytes) else response.raw
# ─────────────────────────────────────────────────────────────
# Baseline: prove the pipe works before we push it
# ─────────────────────────────────────────────────────────────
def test_non_recurring_event_round_trip(fresh_calendar: caldav.Calendar) -> None:
uid = f"e2e-baseline-{uuid.uuid4().hex[:8]}"
body = _dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav e2e//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260101T090000Z
DTEND:20260101T093000Z
SUMMARY:Baseline event
END:VEVENT
END:VCALENDAR
"""
)
_put_ical(fresh_calendar, uid, body)
fetched = _get_master_ical(fresh_calendar, uid)
assert "SUMMARY:Baseline event" in fetched
assert f"UID:{uid}" in fetched
# ─────────────────────────────────────────────────────────────
# #528 timed flavour
# ─────────────────────────────────────────────────────────────
def test_recurring_master_plus_exception_preserves_master(
fresh_calendar: caldav.Calendar,
) -> None:
uid = f"e2e-daily-{uuid.uuid4().hex[:8]}"
# (1) Master only — the shape a client sends when the user first
# creates a recurring event.
master_only = _dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav e2e//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260101T090000Z
DTEND:20260101T093000Z
SUMMARY:Daily standup
RRULE:FREQ=DAILY;COUNT=10
END:VEVENT
END:VCALENDAR
"""
)
_put_ical(fresh_calendar, uid, master_only)
# (2) Master + per-instance override — the shape a client sends
# when the user modifies a single occurrence in the UI.
with_exception = _dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav e2e//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260101T090000Z
DTEND:20260101T093000Z
SUMMARY:Daily standup
RRULE:FREQ=DAILY;COUNT=10
END:VEVENT
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260103T110000Z
DTEND:20260103T120000Z
SUMMARY:Daily standup — rescheduled
RECURRENCE-ID:20260103T090000Z
END:VEVENT
END:VCALENDAR
"""
)
_put_ical(fresh_calendar, uid, with_exception)
# Master URL GET must return the master row. Pre-fix this would
# have returned the exception's data (the last VEVENT in the
# body clobbered the row).
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
)
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.
def test_exception_only_put_does_not_wipe_master(
fresh_calendar: caldav.Calendar,
) -> None:
uid = f"e2e-daily-{uuid.uuid4().hex[:8]}"
# Seed: master + override.
_put_ical(
fresh_calendar,
uid,
_dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav e2e//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260101T090000Z
DTEND:20260101T093000Z
SUMMARY:Daily standup
RRULE:FREQ=DAILY;COUNT=10
END:VEVENT
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART:20260103T110000Z
DTEND:20260103T120000Z
SUMMARY:Daily standup — rescheduled
RECURRENCE-ID:20260103T090000Z
END:VEVENT
END:VCALENDAR
"""
),
)
# Client's next action: user edits the same overridden occurrence
# again. Thunderbird / Apple Calendar re-send ONLY the exception.
_put_ical(
fresh_calendar,
uid,
_dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav e2e//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T110000Z
DTSTART:20260103T120000Z
DTEND:20260103T130000Z
SUMMARY:Daily standup — rescheduled AGAIN
RECURRENCE-ID:20260103T090000Z
END:VEVENT
END:VCALENDAR
"""
),
)
# 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.
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."
)
# 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.
# ─────────────────────────────────────────────────────────────
# #528 all-day flavour — the exact shape the ticket was filed
# against. The DATE-form `DTSTART;VALUE=DATE:...` line was
# invisible to the pre-fix substring parser, so the whole
# body 500'd.
# ─────────────────────────────────────────────────────────────
def test_all_day_recurring_master_plus_exception(
fresh_calendar: caldav.Calendar,
) -> None:
uid = f"e2e-allday-{uuid.uuid4().hex[:8]}"
body = _dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav e2e//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART;VALUE=DATE:20260105
DTEND;VALUE=DATE:20260106
SUMMARY:Weekly review
RRULE:FREQ=WEEKLY;COUNT=4
END:VEVENT
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T100000Z
DTSTART;VALUE=DATE:20260113
DTEND;VALUE=DATE:20260114
SUMMARY:Weekly review — moved
RECURRENCE-ID;VALUE=DATE:20260112
END:VEVENT
END:VCALENDAR
"""
)
_put_ical(fresh_calendar, uid, body)
# Master URL GET returns the master row with the RRULE intact.
# Pre-parser-rewrite the whole PUT 500'd because the param-
# carrying DTSTART line was invisible to the scanner.
data = _get_master_ical(fresh_calendar, uid)
assert "RRULE:FREQ=WEEKLY;COUNT=4" in data, (
"Master lost its RRULE (or the whole PUT was rejected).\n"
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.
+312
View File
@@ -0,0 +1,312 @@
"""CalDAV REPORT method coverage via python-caldav.
The REPORT verb (RFC 4791 §7) is how clients do bulk sync + filtered
lookup. Three subtypes matter for OxiCloud's server surface:
* `calendar-query` (§7.8) — filter events by time-range /
property. `search(start=..., end=...)` in pycaldav emits this.
* `calendar-multiget` (§7.9) — batch fetch by href list. Used
when the client already knows which UIDs it wants.
* `sync-collection` (§7.9 / RFC 6578) — token-based delta sync.
Not exercised here yet — the server delegates to `list_events`
(no per-token filtering), so a coverage test would just
replicate the calendar-query case. Leave for later once real
sync-token support lands.
The tests seed a fresh calendar with three timed events an hour
apart, then exercise each REPORT shape. Row-count assertions are
safe here because the seeded events are all masters (non-recurring),
so master/exception folding doesn't apply — one URL per UID matches
one row in DB.
"""
from __future__ import annotations
import textwrap
import uuid
from datetime import datetime, timezone
import caldav
import pytest
_TIME_RANGE_PARSER_BUG_REASON = (
"caldav_adapter.rs:~105 + ~172 parses time-range start/end as "
"RFC 3339 (`2026-01-01T09:30:00Z`), but CalDAV clients send "
"iCalendar DATE-TIME (`20260101T093000Z` — RFC 4791 §9.9). "
"Parse fails, time_range becomes None, handle_report falls "
"through to list_events → returns every event regardless of "
"window. Fix: chrono::NaiveDateTime::parse_from_str with "
"`%Y%m%dT%H%M%SZ` (RFC 3339 as fallback). Own fix branch, "
"e.g. fix/caldav-time-range-parser."
)
# ─────────────────────────────────────────────────────────────
# Helpers — mirror the pattern from test_recurring.py /
# test_ical_coverage.py. Deliberately duplicated for now;
# promote to conftest.py once a fourth test file shows up.
# ─────────────────────────────────────────────────────────────
def _dedent(ical: str) -> str:
return textwrap.dedent(ical).strip().replace("\n", "\r\n") + "\r\n"
def _put_ical(calendar: caldav.Calendar, uid: str, body: str) -> None:
url = str(calendar.url).rstrip("/") + f"/{uid}.ics"
r = calendar.client.request(
url,
method="PUT",
body=body,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if r.status < 200 or r.status >= 300:
raise AssertionError(
f"PUT {url} → HTTP {r.status}\nbody: {body!r}\nresponse: {r.raw!r}"
)
def _seed_three_events(calendar: caldav.Calendar) -> list[str]:
"""Seed three non-recurring events, one hour apart, starting
2026-01-01T09:00 UTC. Returns the list of UIDs in wall-clock
order (index 0 = earliest).
Non-recurring is deliberate: it isolates REPORT semantics from
master/exception folding (which is phase-4 territory)."""
uids: list[str] = []
times = [
("20260101T090000Z", "20260101T093000Z", "Morning standup"),
("20260101T100000Z", "20260101T110000Z", "Mid-morning sync"),
("20260101T140000Z", "20260101T150000Z", "Afternoon review"),
]
for start, end, summary in times:
uid = f"report-{uuid.uuid4().hex[:8]}"
_put_ical(
calendar,
uid,
_dedent(
f"""\
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pycaldav report coverage//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20260101T080000Z
DTSTART:{start}
DTEND:{end}
SUMMARY:{summary}
END:VEVENT
END:VCALENDAR
"""
),
)
uids.append(uid)
return uids
# ─────────────────────────────────────────────────────────────
# calendar-query REPORT
# ─────────────────────────────────────────────────────────────
@pytest.mark.xfail(reason=_TIME_RANGE_PARSER_BUG_REASON, strict=False)
def test_calendar_query_time_range_returns_events_in_window(
fresh_calendar: caldav.Calendar,
) -> None:
"""A time-range filter that spans the middle of the seeded
day should return only the events whose (DTSTART, DTEND)
overlaps the window. RFC 4791 §9.9 defines overlap: an event
overlaps a range if DTSTART < range_end AND DTEND > range_start."""
uids = _seed_three_events(fresh_calendar)
# Window: 09:30 → 12:00 UTC. Overlaps events 0 (09:00–09:30
# touches the boundary at 09:30; RFC excludes exact touch)
# and event 1 (10:00–11:00, wholly inside). Excludes event 2
# (14:00–15:00, well outside).
window_start = datetime(2026, 1, 1, 9, 30, tzinfo=timezone.utc)
window_end = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
found = fresh_calendar.search(
start=window_start,
end=window_end,
event=True,
expand=False,
)
found_uids = {_uid_from_event_data(e.data) for e in found}
# Event 1 (10:00–11:00) is definitely in-window; event 2 (14:00–
# 15:00) is definitely out. Event 0's overlap is boundary-
# dependent (server interpretation varies at exact-touch). The
# strong invariant: event 1 in, event 2 out.
assert uids[1] in found_uids, (
f"Event 1 (mid-morning, wholly inside window) missing from "
f"time-range REPORT. Got: {found_uids}"
)
assert uids[2] not in found_uids, (
f"Event 2 (afternoon, wholly outside window) leaked into "
f"time-range REPORT. Got: {found_uids}"
)
@pytest.mark.xfail(reason=_TIME_RANGE_PARSER_BUG_REASON, strict=False)
def test_calendar_query_time_range_after_all_events_returns_empty(
fresh_calendar: caldav.Calendar,
) -> None:
"""A window that starts after every seeded event returns
zero results — proves the range filter is actually applied,
not silently ignored (which would surface as "all events
returned regardless of window")."""
_seed_three_events(fresh_calendar)
window_start = datetime(2027, 1, 1, 0, 0, tzinfo=timezone.utc)
window_end = datetime(2027, 1, 2, 0, 0, tzinfo=timezone.utc)
found = fresh_calendar.search(
start=window_start,
end=window_end,
event=True,
expand=False,
)
assert found == [], (
f"Expected empty result for window one year past all seeded "
f"events; got {len(found)} entries."
)
@pytest.mark.xfail(reason=_TIME_RANGE_PARSER_BUG_REASON, strict=False)
def test_calendar_query_time_range_before_all_events_returns_empty(
fresh_calendar: caldav.Calendar,
) -> None:
"""Symmetric to the after-window case."""
_seed_three_events(fresh_calendar)
window_start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
window_end = datetime(2025, 1, 2, 0, 0, tzinfo=timezone.utc)
found = fresh_calendar.search(
start=window_start,
end=window_end,
event=True,
expand=False,
)
assert found == []
def test_calendar_query_no_filter_returns_every_event(
fresh_calendar: caldav.Calendar,
) -> None:
"""`calendar.events()` (pycaldav) issues a calendar-query without
a time-range — the server routes this via `list_events`, so
every event in the calendar surfaces. Row count = 3 seeded
events (all non-recurring, so 1 URL per row)."""
uids = _seed_three_events(fresh_calendar)
all_events = fresh_calendar.events()
found_uids = {_uid_from_event_data(e.data) for e in all_events}
for expected in uids:
assert expected in found_uids, (
f"Seeded event {expected} missing from unfiltered "
f"calendar-query REPORT. Got: {found_uids}"
)
# ─────────────────────────────────────────────────────────────
# calendar-multiget REPORT
# ─────────────────────────────────────────────────────────────
def test_calendar_multiget_by_href_returns_the_targeted_events(
fresh_calendar: caldav.Calendar,
) -> None:
"""calendar-multiget takes an explicit href list and returns
exactly those. Two hrefs → two responses. The server's
`get_events_by_ical_uids` (indexed `ical_uid = ANY(...)`) is
what pays for this instead of listing the whole calendar."""
uids = _seed_three_events(fresh_calendar)
base = str(fresh_calendar.url).rstrip("/") + "/"
# Target the first two events; skip event 2.
hrefs = [f"{base}{uids[0]}.ics", f"{base}{uids[1]}.ics"]
xml = _multiget_body(hrefs)
r = fresh_calendar.client.request(
str(fresh_calendar.url),
method="REPORT",
body=xml,
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
)
assert 200 <= r.status < 300, (
f"REPORT calendar-multiget → HTTP {r.status}\nbody: {r.raw!r}"
)
xml_body = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
assert uids[0] in xml_body, (
f"Requested UID {uids[0]} missing from multiget response."
)
assert uids[1] in xml_body, (
f"Requested UID {uids[1]} missing from multiget response."
)
assert uids[2] not in xml_body, (
f"UID {uids[2]} (not requested) leaked into multiget response."
)
def test_calendar_multiget_unknown_href_is_silently_absent(
fresh_calendar: caldav.Calendar,
) -> None:
"""CalDAV multiget semantics: a requested href that doesn't
exist is silently absent from the response (not an error).
Some servers emit a `<D:status>404</D:status>` per-href entry;
the minimum bar is that the server must NOT 500 and must NOT
invent data."""
uids = _seed_three_events(fresh_calendar)
base = str(fresh_calendar.url).rstrip("/") + "/"
ghost_uid = f"does-not-exist-{uuid.uuid4().hex[:8]}"
hrefs = [f"{base}{uids[0]}.ics", f"{base}{ghost_uid}.ics"]
r = fresh_calendar.client.request(
str(fresh_calendar.url),
method="REPORT",
body=_multiget_body(hrefs),
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
)
assert 200 <= r.status < 300, (
f"REPORT multiget with an unknown href must not 500 — got "
f"HTTP {r.status}\nresponse: {r.raw!r}"
)
xml_body = r.raw.decode("utf-8") if isinstance(r.raw, bytes) else r.raw
assert uids[0] in xml_body, (
"Existing UID missing from multiget that also targeted a ghost href."
)
# ─────────────────────────────────────────────────────────────
# Low-level helpers
# ─────────────────────────────────────────────────────────────
def _uid_from_event_data(data: str) -> str | None:
"""Pull the UID out of a raw iCalendar body. Cheap enough for a
handful of events per test."""
for line in data.replace("\r\n", "\n").split("\n"):
if line.startswith("UID:"):
return line[4:].strip()
return None
def _multiget_body(hrefs: list[str]) -> str:
"""Assemble a minimal RFC 4791 §7.9 calendar-multiget REPORT
XML body for the given href list."""
href_xml = "\n ".join(f"<D:href>{h}</D:href>" for h in hrefs)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
{href_xml}
</C:calendar-multiget>
"""