Files
mdcaldav/tests/test_writer.py
Tyler Perkins 6dd3829cfa
Some checks failed
CI / test (push) Failing after 6s
CI / image (push) Has been skipped
Write against the file, not a stale cache; stop indexing conflict files
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>
2026-08-02 15:58:16 -04:00

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()