test: full caldav + carddav test suite
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Executable
+215
@@ -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"
|
||||
@@ -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!
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
"""
|
||||
Reference in New Issue
Block a user