Fix tasks reverting after a client marks them done
Completing a task in a CalDAV client wrote through to the markdown correctly, then the client silently un-completed it moments later. `to_ics` stamped DTSTAMP and LAST-MODIFIED with the wall clock on every serialization, and Radicale derives an item's ETag by hashing that serialization. So an unchanged task minted a fresh ETag on every read, at one-second granularity. A client that read the task, waited for the user to tick the checkbox, then PUT with `If-Match: <the etag it holds>` always got 412 Precondition Failed — and resolved that apparent conflict by re-downloading the server copy, discarding the completion. Persist a per-task `last_modified` in the sidecar instead, stamped only when the task's content or status actually changes, and serve DTSTAMP, LAST-MODIFIED and the Item's own last_modified from it. The ETag is now stable while the task is, and changes exactly when the task does. Schema goes to v2, migrated in place with ALTER TABLE: rebuilding the database would regenerate every UID and replace clients' whole task list. Also fixes a plain GET of an item returning 500 — Radicale asserts on `Item.last_modified`, which was never passed. Only REPORT was covered by tests, so nothing caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ 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
|
||||
@@ -216,3 +217,102 @@ def test_external_vim_edit_becomes_visible(server):
|
||||
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'<?xml version="1.0"?><propfind xmlns="DAV:">'
|
||||
b"<prop><getetag/></prop></propfind>"
|
||||
)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user