Two deployment bugs that both present as "the client says done but the markdown never changes, and nothing errors". 1. upload() diffed the client's VTODO against the cached in-memory task. When that cache disagreed with disk the diff came out empty, apply() returned early, and Radicale answered 2xx having written nothing. The cache goes stale on NFS: refresh_if_stale() compares stat(), and a `- [ ]` to `- [x]` toggle does not change the file's size, so mtime is the only signal — which NFS attribute caching hides for the length of the attr timeout. Add Index.refresh_file(), which compares the actual bytes, and call it before diffing. 2. docker/mdcaldav.toml set `exclude`, which REPLACES the built-in list rather than extending it, silently dropping the Syncthing patterns. Against a real Syncthing vault this reindexes `*.sync-conflict-*` (and `.stversions/`, which holds whole historical copies of every note): the task list gains duplicates, and completing one can write to a conflict file the user never opens while the note they are watching stays put. Drop the override so the defaults apply. Also switch the container to a polling observer. inotify reports nothing for a network mount written by another host, so `watch = true` was a silent no-op exactly where it was most wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""Write-back tests: surgical edits, atomicity, conflicts (SPEC 12, tests 3/8)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from mdcaldav.config import Config
|
|
from mdcaldav.ical import apply_to_task
|
|
from mdcaldav.index import Index
|
|
from mdcaldav.model import Status
|
|
from mdcaldav.writer import ConflictError, NotAllowedError, Writer
|
|
|
|
|
|
@pytest.fixture
|
|
def env(vault: Path, tmp_path: Path):
|
|
cfg = Config()
|
|
cfg.vault.path = vault
|
|
cfg.index.db = tmp_path / "index.db"
|
|
index = Index(cfg)
|
|
index.rescan()
|
|
yield cfg, index, Writer(cfg, index), vault
|
|
index.close()
|
|
|
|
|
|
def uid_of(index: Index, title: str, rel: str | None = None) -> str:
|
|
matches = [
|
|
t
|
|
for t in index.tasks.values()
|
|
if t.title == title and (rel is None or t.source.rel_path == rel)
|
|
]
|
|
assert len(matches) == 1, f"{title!r} matched {len(matches)}"
|
|
return matches[0].uid
|
|
|
|
|
|
def diff_lines(before: str, after: str) -> list[str]:
|
|
return [
|
|
line
|
|
for line in difflib.unified_diff(
|
|
before.splitlines(), after.splitlines(), lineterm="", n=0
|
|
)
|
|
if line[:1] in "+-" and not line.startswith(("---", "+++"))
|
|
]
|
|
|
|
|
|
def test_completion_changes_exactly_one_line(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
before = (vault / rel).read_text()
|
|
|
|
writer.apply(uid_of(index, "Water the seedlings", rel), {"status": Status.COMPLETED})
|
|
|
|
after = (vault / rel).read_text()
|
|
assert diff_lines(before, after) == [
|
|
"-- [ ] Water the seedlings",
|
|
"+- [x] Water the seedlings",
|
|
]
|
|
|
|
|
|
def test_completion_toggles_back(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
before = (vault / rel).read_bytes()
|
|
uid = uid_of(index, "Water the seedlings", rel)
|
|
|
|
writer.apply(uid, {"status": Status.COMPLETED})
|
|
writer.apply(uid, {"status": Status.NEEDS_ACTION})
|
|
|
|
assert (vault / rel).read_bytes() == before # round trip is byte-identical
|
|
|
|
|
|
def test_nested_task_completion_leaves_siblings_alone(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-05.md"
|
|
before = (vault / rel).read_text()
|
|
|
|
writer.apply(
|
|
uid_of(index, "Decide on trellis height", rel), {"status": Status.COMPLETED}
|
|
)
|
|
|
|
after = (vault / rel).read_text()
|
|
assert diff_lines(before, after) == [
|
|
"- - [ ] Decide on trellis height",
|
|
"+ - [x] Decide on trellis height",
|
|
]
|
|
|
|
|
|
def test_retitle_preserves_indent_bullet_and_priority(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
writer.apply(uid_of(index, "Book the soil test", rel), {"title": "Book the soil test today"})
|
|
assert "- [ ] [#A] Book the soil test today" in (vault / rel).read_text()
|
|
|
|
|
|
def test_priority_set_clear_and_change(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
|
|
uid = uid_of(index, "Water the seedlings", rel)
|
|
writer.apply(uid, {"priority": 1})
|
|
assert "- [ ] [#A] Water the seedlings" in (vault / rel).read_text()
|
|
|
|
writer.apply(uid, {"priority": 9})
|
|
assert "- [ ] [#C] Water the seedlings" in (vault / rel).read_text()
|
|
|
|
writer.apply(uid, {"priority": None})
|
|
assert "- [ ] Water the seedlings" in (vault / rel).read_text()
|
|
assert "[#" not in (vault / rel).read_text().split("Water the seedlings")[0].splitlines()[-1]
|
|
|
|
|
|
def test_description_replaced_in_place(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-05.md"
|
|
writer.apply(
|
|
uid_of(index, "Order the cover crop", rel), {"description": "Invoice filed"}
|
|
)
|
|
text = (vault / rel).read_text()
|
|
assert " - Invoice filed" in text
|
|
assert "Arrived on the 12th" not in text
|
|
|
|
|
|
def test_description_added_where_none_existed(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
writer.apply(
|
|
uid_of(index, "Call the arborist", rel), {"description": "Ask about the oak"}
|
|
)
|
|
text = (vault / rel).read_text()
|
|
assert "- [ ] [#B] Call the arborist\n - Ask about the oak\n" in text
|
|
|
|
|
|
def test_description_removed(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-05.md"
|
|
writer.apply(uid_of(index, "Order the cover crop", rel), {"description": None})
|
|
text = (vault / rel).read_text()
|
|
assert "Arrived on the 12th" not in text
|
|
assert "- [x] Order the cover crop\n - [x] Sharpen the pruners" in text
|
|
|
|
|
|
def test_untouched_files_are_not_rewritten(env):
|
|
_, index, writer, vault = env
|
|
others = {
|
|
rel: (vault / rel).read_bytes()
|
|
for rel in ("daily/2026-01-06.md", "projects/irrigation.md", "reference/glossary.md")
|
|
}
|
|
writer.apply(
|
|
uid_of(index, "Water the seedlings", "daily/2026-01-07.md"),
|
|
{"status": Status.COMPLETED},
|
|
)
|
|
for rel, content in others.items():
|
|
assert (vault / rel).read_bytes() == content
|
|
|
|
|
|
def test_crlf_and_missing_final_newline_preserved(env, tmp_path: Path):
|
|
cfg, index, writer, vault = env
|
|
rel = "daily/crlf.md"
|
|
(vault / rel).write_bytes(b"# T\r\n\r\n- [ ] alpha\r\n- [ ] omega") # no final newline
|
|
index.rescan()
|
|
|
|
writer.apply(uid_of(index, "alpha", rel), {"status": Status.COMPLETED})
|
|
|
|
data = (vault / rel).read_bytes()
|
|
assert data == b"# T\r\n\r\n- [x] alpha\r\n- [ ] omega"
|
|
|
|
|
|
def test_unicode_title_edit_keeps_bytes_aligned(env):
|
|
_, index, writer, vault = env
|
|
rel = "daily/uni.md"
|
|
(vault / rel).write_text("- [ ] café → naïve\n- [ ] after\n", encoding="utf-8")
|
|
index.rescan()
|
|
|
|
writer.apply(uid_of(index, "café → naïve", rel), {"status": Status.COMPLETED})
|
|
assert (vault / rel).read_text() == "- [x] café → naïve\n- [ ] after\n"
|
|
|
|
|
|
def test_conflict_when_task_deleted_underneath(env):
|
|
"""The task the client addressed no longer exists → refuse, don't guess."""
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
uid = uid_of(index, "Water the seedlings", rel)
|
|
|
|
text = (vault / rel).read_text()
|
|
(vault / rel).write_text(text.replace("- [ ] Water the seedlings\n", ""))
|
|
before = (vault / rel).read_bytes()
|
|
|
|
with pytest.raises(ConflictError):
|
|
writer.apply(uid, {"status": Status.COMPLETED})
|
|
assert (vault / rel).read_bytes() == before # not corrupted
|
|
|
|
|
|
def test_external_retitle_is_the_same_task(env):
|
|
"""Identity rule 2 treats an in-place retitle as the same task, so the
|
|
write lands on the renamed line rather than failing. The client's own
|
|
staleness is caught a layer up by Radicale's If-Match/ETag check."""
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
uid = uid_of(index, "Water the seedlings", rel)
|
|
|
|
text = (vault / rel).read_text()
|
|
(vault / rel).write_text(text.replace("Water the seedlings", "Water the beds"))
|
|
|
|
writer.apply(uid, {"status": Status.COMPLETED})
|
|
assert "- [x] Water the beds" in (vault / rel).read_text()
|
|
|
|
|
|
def test_stale_spans_are_never_used(env):
|
|
"""An edit that shifts line offsets must not corrupt a later write."""
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
uid = uid_of(index, "Water the seedlings", rel)
|
|
|
|
# Insert lines above the target, invalidating every cached byte offset.
|
|
text = (vault / rel).read_text()
|
|
(vault / rel).write_text(text.replace("## TODO\n", "## TODO\n\nSome new prose here.\n\n"))
|
|
|
|
writer.apply(uid, {"status": Status.COMPLETED})
|
|
result = (vault / rel).read_text()
|
|
assert "- [x] Water the seedlings" in result
|
|
assert "Some new prose here." in result
|
|
assert "- [ ] [#A] Book the soil test" in result # neighbours intact
|
|
|
|
|
|
def test_unrelated_external_edit_is_absorbed(env):
|
|
"""A change elsewhere in the file must not block an unrelated write."""
|
|
_, index, writer, vault = env
|
|
rel = "daily/2026-01-07.md"
|
|
uid = uid_of(index, "Water the seedlings", rel)
|
|
|
|
text = (vault / rel).read_text()
|
|
(vault / rel).write_text(text.replace("# Wednesday", "# Weds"))
|
|
|
|
writer.apply(uid, {"status": Status.COMPLETED})
|
|
assert "- [x] Water the seedlings" in (vault / rel).read_text()
|
|
|
|
|
|
def test_delete_cascades_to_subtree(env):
|
|
_, index, writer, vault = env
|
|
rel = "projects/irrigation.md"
|
|
writer.delete(uid_of(index, "Trench the main line", rel))
|
|
text = (vault / rel).read_text()
|
|
assert "Trench the main line" not in text
|
|
assert "Rent the trencher" not in text # cascaded
|
|
assert "frost line" not in text # its description went too
|
|
assert "Price the manifold" in text # sibling untouched
|
|
|
|
|
|
def test_delete_reject_policy(env):
|
|
cfg, index, writer, vault = env
|
|
cfg.write.delete_children = "reject"
|
|
with pytest.raises(NotAllowedError):
|
|
writer.delete(uid_of(index, "Trench the main line", "projects/irrigation.md"))
|
|
|
|
|
|
def test_delete_disabled(env):
|
|
cfg, index, writer, _ = env
|
|
cfg.write.allow_delete = False
|
|
with pytest.raises(NotAllowedError):
|
|
writer.delete(uid_of(index, "Reseal the base", "daily/2026-01-06.md"))
|
|
|
|
|
|
def test_create_lands_in_inbox(env):
|
|
_, index, writer, vault = env
|
|
uid = writer.create({"title": "Buy netting", "priority": 1})
|
|
text = (vault / "inbox.md").read_text()
|
|
assert "## Inbox" in text
|
|
assert "- [ ] [#A] Buy netting" in text
|
|
assert index.tasks[uid].title == "Buy netting"
|
|
|
|
|
|
def test_create_disabled(env):
|
|
cfg, _, writer, _ = env
|
|
cfg.write.allow_create = False
|
|
with pytest.raises(NotAllowedError):
|
|
writer.create({"title": "nope"})
|
|
|
|
|
|
def test_atomic_write_leaves_no_temp_files(env):
|
|
_, index, writer, vault = env
|
|
writer.apply(
|
|
uid_of(index, "Water the seedlings", "daily/2026-01-07.md"),
|
|
{"status": Status.COMPLETED},
|
|
)
|
|
assert not list((vault / "daily").glob(".*tmp*"))
|
|
|
|
|
|
def test_upload_writes_even_when_the_index_is_stale(vault, tmp_path):
|
|
"""Regression: a stale index made completions silently no-op.
|
|
|
|
`upload` diffed the client's VTODO against the cached in-memory task. If
|
|
that cache disagreed with disk, the diff came out empty, `apply` returned
|
|
early, and Radicale answered 2xx without touching the markdown — the client
|
|
showed the task done while the file still said `- [ ]`.
|
|
|
|
Reproduced the way NFS produces it: the file changes, but the stat() the
|
|
index recorded still matches, so `refresh_if_stale` sees nothing. A status
|
|
toggle does not change the file's size, so only mtime could betray it —
|
|
and NFS attribute caching hides that for the length of the attr timeout.
|
|
"""
|
|
from mdcaldav.config import Config
|
|
from mdcaldav.index import Index
|
|
from mdcaldav.model import Status
|
|
|
|
cfg = Config()
|
|
cfg.vault.path = vault
|
|
cfg.index.db = tmp_path / "index.db"
|
|
index = Index(cfg)
|
|
index.rescan()
|
|
writer = Writer(cfg, index)
|
|
|
|
rel = "daily/2026-01-07.md"
|
|
path = vault / rel
|
|
uid = next(
|
|
t.uid
|
|
for t in index.tasks.values()
|
|
if t.title == "Call the arborist" and t.source.rel_path == rel
|
|
)
|
|
|
|
# Complete it, so index and disk agree that it is done.
|
|
writer.apply(uid, {"status": Status.COMPLETED})
|
|
assert "- [x] [#B] Call the arborist" in path.read_text()
|
|
|
|
# Now it is un-ticked in vim, and the change is invisible to stat():
|
|
# same size, and we pin mtime/size back to what the index recorded.
|
|
recorded = index.db.execute(
|
|
"SELECT mtime, size FROM files WHERE rel_path = ?", (rel,)
|
|
).fetchone()
|
|
path.write_text(path.read_text().replace("- [x] [#B] Call", "- [ ] [#B] Call"))
|
|
os.utime(path, (recorded["mtime"], recorded["mtime"]))
|
|
assert path.stat().st_size == recorded["size"]
|
|
assert not index.refresh_if_stale() # the staleness is genuinely hidden
|
|
assert index.tasks[uid].status is Status.COMPLETED # index disagrees with disk
|
|
|
|
# The client PUTs "completed" again. Diffing against the stale cache yields
|
|
# no changes at all; only re-reading the file reveals the real difference.
|
|
assert apply_to_task(index.tasks[uid], {"status": Status.COMPLETED}, cfg) == {}
|
|
|
|
index.refresh_file(rel)
|
|
changes = apply_to_task(index.tasks[uid], {"status": Status.COMPLETED}, cfg)
|
|
assert changes == {"status": Status.COMPLETED}
|
|
writer.apply(uid, changes)
|
|
|
|
assert "- [x] [#B] Call the arborist" in path.read_text()
|