"""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 = (
''
'