diet-guard/diet_guard/tests/test_prune_peers.py
Krzysztof kuhy Rudnicki 0b658f55f6 fix(sync): prune the revision marker too, not just the device directory
Pruning 20 dead devices made the narrow pull *slower* -- ~90ms to ~15s --
which is the opposite of the point, and I caught it only because I measured
after running rather than assuming the win.

`_device_paths` listed the four data files a device publishes but not its
entry under `revs/`. `_sync_refresh._candidate_peers` deliberately enumerates
peers from the revision map instead of calling `list_directory` (that call is
~445ms mirrored), so every orphaned marker resurrected a deleted device as a
candidate and bought a round trip to discover it was gone. 19 orphans, 19
wasted probes, every tick.

The marker is now part of the device's path set, so it is backed up and
deleted with everything else, and a regression test pins it by name rather
than by count. The 19 already-orphaned markers on the remote were cleaned up
separately.

Verified after the cleanup: narrow pull ~84ms, full tick 8.9s (was ~27s),
gate --check ~105ms, and the log is untouched at 441 entries / 117 foods with
peer_revs correctly down to 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
2026-08-21 13:38:34 +02:00

215 lines
7.5 KiB
Python

"""Tests for pruning device directories nothing writes to any more.
This is the one command that *deletes* data on a shared remote, so the tests
lean on the refusals rather than the happy path: a protected id, a peer with a
record no one else has, and ``--apply`` without a backup must each stop it.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from diet_guard import _cli, _cli_prune, _prune_peers
from diet_guard._device import device_id
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _client(logs: dict[str, dict[str, object]]) -> MagicMock:
"""Return a client whose devices publish the given record-id maps."""
client = MagicMock()
client.list_directory.return_value = list(logs)
files = {
f"diet-guard-sync/devices/{dev}/food_log.json": json.dumps(recs)
for dev, recs in logs.items()
}
client.get_file_text.side_effect = files.get
return client
class TestPlanPrune:
"""Deciding what may go, without touching anything."""
def test_a_covered_peer_is_prunable(self) -> None:
"""Every record already held by a kept device -> nothing is lost."""
client = _client({"live": {"r1": {}, "r2": {}}, "dead": {"r1": {}}})
plan = _prune_peers.plan_prune(client, {"live"})
assert plan.prunes == ["dead"]
assert plan.unique_lost == 0
def test_a_peer_with_a_unique_record_is_kept(self) -> None:
"""One unheld record is enough to disqualify a peer from deletion."""
client = _client({"live": {"r1": {}}, "hoarder": {"r1": {}, "r9": {}}})
plan = _prune_peers.plan_prune(client, {"live"})
assert plan.prunes == []
assert plan.unique_lost == 1
def test_legacy_role_ids_are_never_proposed(self) -> None:
"""Dropping one makes every tick re-merge our own pre-migration log."""
client = _client({"live": {"r1": {}}, "pc": {}, "phone": {}, "desktop": {}})
plan = _prune_peers.plan_prune(client, {"live"})
assert plan.prunes == []
assert set(plan.keeps) >= {"pc", "phone", "desktop"}
def test_this_device_is_never_proposed(self) -> None:
client = _client({device_id(): {"r1": {}}, "live": {"r1": {}}})
plan = _prune_peers.plan_prune(client, {"live"})
assert device_id() not in plan.prunes
def test_an_unreadable_peer_log_counts_as_empty(self) -> None:
"""Unparsable is not "unique" -- it must not block an otherwise safe plan."""
client = _client({"live": {"r1": {}}})
client.list_directory.return_value = ["live", "broken"]
client.get_file_text.side_effect = lambda p: (
"{not json" if "broken" in p else json.dumps({"r1": {}})
)
plan = _prune_peers.plan_prune(client, {"live"})
assert plan.prunes == ["broken"]
def test_a_peer_that_never_pushed_is_prunable(self) -> None:
client = _client({"live": {"r1": {}}})
client.list_directory.return_value = ["live", "empty"]
client.get_file_text.side_effect = lambda p: (
None if "empty" in p else json.dumps({"r1": {}})
)
plan = _prune_peers.plan_prune(client, {"live"})
assert plan.prunes == ["empty"]
class TestApply:
"""The destructive half."""
def test_backup_writes_every_published_path(self, tmp_path: Path) -> None:
client = MagicMock()
client.get_file_text.return_value = "{}"
written = _prune_peers.backup_peers(client, ["dead"], tmp_path)
assert written == 5 # log, food bank, curated bank, budget, rev
assert (tmp_path / "dead" / "food_log.json").read_text() == "{}"
def test_backup_skips_paths_a_device_never_published(self, tmp_path: Path) -> None:
client = MagicMock()
client.get_file_text.return_value = None
assert _prune_peers.backup_peers(client, ["dead"], tmp_path) == 0
def test_apply_deletes_the_revision_marker_too(self) -> None:
"""Leaving it behind makes the narrow pull slower, not faster.
``_sync_refresh._candidate_peers`` enumerates peers from the revision
map, so an orphaned marker resurrects a deleted device as a candidate
and costs a round trip every tick. Observed for real: pruning without
this took the narrow pull from ~90ms to ~15s.
"""
client = MagicMock()
list(_prune_peers.apply_prune(client, ["dead"]))
deleted = [call.args[0] for call in client.delete_file.call_args_list]
assert "diet-guard-sync/revs/dead" in deleted
def test_apply_deletes_all_four_paths(self) -> None:
client = MagicMock()
assert list(_prune_peers.apply_prune(client, ["dead"])) == ["dead"]
assert client.delete_file.call_count == 5
class TestCommand:
"""The CLI wrapper, whose defaults are the real safety rail."""
def _run(
self,
monkeypatch: pytest.MonkeyPatch,
client: MagicMock,
**kwargs: object,
) -> tuple[int, list[str]]:
monkeypatch.setattr(_prune_peers, "_client_for_run", lambda: client)
out: list[str] = []
opts = {"keep": ["live"], "backup_dir": None, "apply": False, **kwargs}
code = _cli_prune.cmd_prune_peers(out.append, **opts)
return code, out
def test_dry_run_deletes_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
client = _client({"live": {"r1": {}}, "dead": {"r1": {}}})
code, out = self._run(monkeypatch, client)
assert code == 0
client.delete_file.assert_not_called()
assert any("dry run" in line for line in out)
def test_apply_without_a_backup_dir_refuses(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
client = _client({"live": {"r1": {}}, "dead": {"r1": {}}})
code, out = self._run(monkeypatch, client, apply=True)
assert code == 1
client.delete_file.assert_not_called()
assert any("refusing to delete" in line for line in out)
def test_an_unsafe_plan_refuses(self, monkeypatch: pytest.MonkeyPatch) -> None:
client = _client({"live": {"r1": {}}, "hoarder": {"r1": {}, "r9": {}}})
code, out = self._run(monkeypatch, client, apply=True)
assert code == 1
client.delete_file.assert_not_called()
assert any("exist only on" in line for line in out)
def test_nothing_to_prune_is_reported(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
client = _client({"live": {"r1": {}}})
code, out = self._run(monkeypatch, client)
assert code == 0
assert any("nothing to prune" in line for line in out)
def test_apply_backs_up_then_deletes(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
client = _client({"live": {"r1": {}}, "dead": {"r1": {}}})
code, out = self._run(monkeypatch, client, apply=True, backup_dir=str(tmp_path))
assert code == 0
assert client.delete_file.call_count == 5
assert any("pruned 1 device" in line for line in out)
def test_main_dispatches_the_subcommand(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The argparse flags reach the handler unpacked."""
seen: dict[str, object] = {}
def _fake(_emit: object, **kwargs: object) -> int:
seen.update(kwargs)
return 0
monkeypatch.setattr(_cli, "cmd_prune_peers", _fake)
assert _cli.main(["prune-peers", "--keep", "abc"]) == 0
assert seen == {"keep": ["abc"], "backup_dir": None, "apply": False}