"""End-to-end: a real CalDAV client against Radicale + this storage plugin.
SPEC 12 test 10. Exercises the actual HTTP surface (PROPFIND, REPORT, PUT,
DELETE) rather than calling the storage API directly.
"""
from __future__ import annotations
import threading
from pathlib import Path
from urllib.parse import urlparse
from wsgiref.simple_server import WSGIRequestHandler, make_server
import pytest
import mdcaldav.storage as storage_module
from mdcaldav.config import Config
caldav = pytest.importorskip("caldav")
radicale = pytest.importorskip("radicale")
class _QuietHandler(WSGIRequestHandler):
def log_message(self, *args): # keep test output readable
pass
@pytest.fixture
def server(vault: Path, tmp_path: Path):
from radicale import Application
from radicale import config as radicale_config
cfg = Config()
cfg.vault.path = vault
cfg.index.db = tmp_path / "index.db"
storage_module.set_config(cfg)
configuration = radicale_config.load()
configuration.update(
{
"storage": {"type": "mdcaldav.storage"},
"auth": {"type": "none"},
"logging": {"level": "critical"},
},
"test",
)
httpd = make_server("localhost", 0, Application(configuration), handler_class=_QuietHandler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
url = f"http://localhost:{httpd.server_port}"
try:
yield url, vault
finally:
httpd.shutdown()
thread.join(timeout=5)
storage_module.set_config(Config())
def _calendars(url: str):
client = caldav.DAVClient(url=url, username="test", password="test")
return client.principal().calendars()
def test_root_propfind_reports_current_user_principal(server):
"""Regression: the root collection's path must be "" or Radicale drops it.
A root item whose path was the storage's own user name got filtered out of
the multistatus, so clients could not discover the principal at all. Only
surfaced under htpasswd auth, hence this direct check.
"""
import base64
import urllib.request
url, _ = server
body = (
b''
b""
)
# Radicale answers 401 for current-user-principal when unauthenticated.
token = base64.b64encode(b"tyler:x").decode()
request = urllib.request.Request(
url + "/",
data=body,
method="PROPFIND",
headers={"Depth": "0", "Authorization": f"Basic {token}"},
)
payload = urllib.request.urlopen(request, timeout=10).read().decode()
assert "current-user-principal" in payload
assert "/" in payload
def test_collections_are_discoverable(server):
url, _ = server
names = {cal.url.path.rstrip("/").rsplit("/", 1)[-1] for cal in _calendars(url)}
assert {"daily", "projects"} <= names
def test_todos_are_listed_with_fields(server):
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todos = daily.todos(include_completed=True)
assert todos
summaries = {str(t.icalendar_component.get("SUMMARY")) for t in todos}
assert "Water the seedlings" in summaries
assert "Book the soil test" in summaries
soil = next(
t
for t in todos
if str(t.icalendar_component.get("SUMMARY")) == "Book the soil test"
)
assert int(soil.icalendar_component.get("PRIORITY")) == 1
def test_subtasks_carry_related_to(server):
url, _ = server
projects = next(
c for c in _calendars(url) if c.url.path.rstrip("/").endswith("projects")
)
todos = projects.todos(include_completed=True)
by_summary = {str(t.icalendar_component.get("SUMMARY")): t for t in todos}
parent = by_summary["Trench the main line"]
child = by_summary["Rent the trencher"]
related = child.icalendar_component.get("RELATED-TO")
assert str(related) == str(parent.icalendar_component.get("UID"))
assert related.params.get("RELTYPE") == "PARENT"
def test_completing_a_todo_rewrites_the_markdown(server):
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Book the soil test"
)
todo.complete()
text = (vault / "daily/2026-01-07.md").read_text()
assert "- [x] [#A] Book the soil test" in text
assert "- [ ] [#B] Call the arborist" in text # neighbour untouched
def test_description_edit_round_trips_to_markdown(server):
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
todo.icalendar_component["DESCRIPTION"] = "Ask about the oak"
todo.save()
text = (vault / "daily/2026-01-07.md").read_text()
assert "- [ ] [#B] Call the arborist\n - Ask about the oak\n" in text
def test_creating_a_todo_lands_in_the_collections_inbox(server):
"""A task created in `daily` must stay in `daily`, not jump to another list."""
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
daily.save_todo(summary="Buy netting", priority=1)
assert "- [ ] [#A] Buy netting" in (vault / "daily/inbox.md").read_text()
assert not (vault / "inbox.md").exists()
summaries = {
str(t.icalendar_component.get("SUMMARY"))
for t in daily.todos(include_completed=True)
}
assert "Buy netting" in summaries # visible in the list it was created in
def test_created_todo_keeps_the_client_uid(server):
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
created = daily.save_todo(summary="Mulch the beds")
uid = str(created.icalendar_component.get("UID"))
fetched = daily.todo_by_uid(uid)
assert str(fetched.icalendar_component.get("SUMMARY")) == "Mulch the beds"
def test_deleting_a_todo_removes_the_line(server):
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
todo.delete()
text = (vault / "daily/2026-01-07.md").read_text()
assert "Call the arborist" not in text
assert "Book the soil test" in text
def test_external_vim_edit_becomes_visible(server):
"""A change made on disk shows up on the next client read."""
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
rel = vault / "daily/2026-01-07.md"
rel.write_text(rel.read_text() + "- [ ] Order more compost\n")
summaries = {
str(t.icalendar_component.get("SUMMARY"))
for t in daily.todos(include_completed=True)
}
assert "Order more compost" in summaries
def _etag(url: str, href: str) -> str:
"""The ETag a client would cache for one task."""
import re
import urllib.request
body = (
b''
b""
)
request = urllib.request.Request(
url + href,
data=body,
method="PROPFIND",
headers={"Depth": "0", "Content-Type": "application/xml"},
)
payload = urllib.request.urlopen(request, timeout=10).read().decode()
match = re.search(r"<[^>]*getetag[^>]*>([^<]+)<", payload)
assert match, payload
return match.group(1)
def test_etag_is_stable_while_the_task_is_unchanged(server):
"""Regression: an unchanged task must keep its ETag across reads.
DTSTAMP/LAST-MODIFIED were stamped with the wall clock on every
serialization, and Radicale hashes the serialization to make the ETag. Every
read therefore minted a new ETag, so a client's conditional PUT always hit
412 and the client reverted its own edit — a task ticked off in the app
would silently un-tick itself moments later.
"""
import time
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
href = urlparse(str(todo.url)).path
first = _etag(url, href)
time.sleep(1.1) # cross a second boundary — the old bug's granularity
assert _etag(url, href) == first
def test_conditional_completion_survives_a_delay(server):
"""The end-to-end symptom: tick a task off, and have it stay ticked off."""
import time
import urllib.request
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
href = urlparse(str(todo.url)).path
uid = str(todo.icalendar_component.get("UID"))
held = _etag(url, href)
time.sleep(1.1) # the user takes a moment before hitting the checkbox
payload = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VTODO\r\n"
f"UID:{uid}\r\nDTSTAMP:20260802T120000Z\r\nSUMMARY:Call the arborist\r\n"
"STATUS:COMPLETED\r\nPERCENT-COMPLETE:100\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
)
request = urllib.request.Request(
url + href,
data=payload.encode(),
method="PUT",
headers={"Content-Type": "text/calendar", "If-Match": held},
)
response = urllib.request.urlopen(request, timeout=10)
assert response.status in (200, 201, 204)
assert "- [x] [#B] Call the arborist" in (vault / "daily/2026-01-07.md").read_text()
assert _etag(url, href) != held # the client must see the change
def test_item_get_returns_the_task(server):
"""Regression: Item was built without `last_modified`, so GET asserted (500)."""
import urllib.request
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
href = urlparse(str(todo.url)).path
response = urllib.request.urlopen(url + href, timeout=10)
assert response.status == 200
assert "SUMMARY:Call the arborist" in response.read().decode()