diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-12 11:30:06 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-12 11:30:06 +0200 |
| commit | c2896d8896235df027dc38d7c93ffe4761d4b02d (patch) | |
| tree | 41caa59689c7e88b4196c326ba82ac60ccddbe25 /test_mailrules.py | |
| parent | 99bbd07a21bf43617eddfc53ae9065a6550d5155 (diff) | |
| download | mailctl-c2896d8896235df027dc38d7c93ffe4761d4b02d.tar.gz mailctl-c2896d8896235df027dc38d7c93ffe4761d4b02d.zip | |
feat(rules): save atomically, preserving unknown fields
Write to a temp file and rename, so the hook can never read a partial
file. Fields this version does not understand round-trip untouched,
which is what makes the format belong to neither tool.
Diffstat (limited to 'test_mailrules.py')
| -rwxr-xr-x | test_mailrules.py | 47 |
1 files changed, 47 insertions, 0 deletions
diff --git a/test_mailrules.py b/test_mailrules.py index e08f490..a3faa5e 100755 --- a/test_mailrules.py +++ b/test_mailrules.py @@ -190,6 +190,53 @@ def test_ordered_skips_disabled_rules(): assert [r.id for r in store.rules] == ["on", "off"] +def test_save_round_trips_unknown_fields(): + """The neutrality guarantee. If this tool strips a field qtmaildir + added, the file is this tool's file that qtmaildir may read.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "future_top_level": {"set_by": "another tool"}, + "rules": [{ + "id": "keeper", + "add": ["x"], + "query": "from:a@example.com", + "future_field": [1, 2, 3], + }], + }) + store = mailrules.load(path) + assert store.rules[0].unknown == {"future_field": [1, 2, 3]} + + mailrules.save(store, path) + + raw = json.loads(path.read_text()) + assert raw["future_top_level"] == {"set_by": "another tool"} + assert raw["rules"][0]["future_field"] == [1, 2, 3] + assert raw["rules"][0]["id"] == "keeper" + assert raw["version"] == 1 + + +def test_save_is_atomic(): + """A reader must never see a half-written file: the hook runs every ten + minutes and a truncated read would be a failed sync.""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "rules.json" + store = mailrules.Store(rules=[ + mailrules.Rule(id="a", query="from:a@example.com", add=["x"])]) + mailrules.save(store, path) + # The temp file the write went through must not be left behind. + assert [p.name for p in Path(tmp).iterdir()] == ["rules.json"] + assert json.loads(path.read_text())["rules"][0]["id"] == "a" + + +def test_save_creates_the_directory(): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "nested" / "rules.json" + mailrules.save(mailrules.Store(), path) + assert path.exists() + assert json.loads(path.read_text()) == {"version": 1, "rules": []} + + def run_all(): for name, fn in sorted(globals().items()): if name.startswith("test_") and callable(fn): |
