diet-guard/diet_guard/tests/test_sync_merge.py

110 lines
4.1 KiB
Python
Raw Normal View History

Add cross-device log sync (Python half of Milestone 3) Pulls every other device's pushed log from GitHub-backed dumb storage, merges it with the local log, and pushes this device's own merged copy back -- the PC half of the diet-guard-app sync plan. - _sync_merge.py: pure union-by-id merge, tombstone always wins, legacy (time, desc) dedup for pre-id entries. Commutative and idempotent. - _sync_github.py: minimal GitHub Contents API client (list/get/put), distinguishing a 404 on an unused path from the repo itself being unreachable. - _sync.py: orchestration -- pull, merge, re-sign every persisted entry regardless of origin, write, rebuild the food bank, push. Re-signing unconditionally is load-bearing: an unsigned phone-origin entry would otherwise be silently dropped on the very next read once a machine holds the shared HMAC key. - _foodbank.rebuild_food_bank(): the "replay a full log into a fresh bank" entrypoint the Python side was missing (the Dart port already had its equivalent). Backs sync's bank-rebuild step. - New diet-guard-sync.service/.timer (15-minute cadence, headless, a separate unit from the gate so a held lock can't stall sync) and a new install.sh step to install them. - Created the private kuhyx/diet-guard-sync GitHub repo for storage. Incidental to this feature: adding the `sync` subcommand pushed _cli.py past the repo's 500-line cap, so `gate`'s CLI glue moved out alongside sync's into _cli_gate.py/_cli_sync.py -- same split pattern already used for the gate window logic itself, not a sync-specific design choice. 338 tests, 100% branch coverage. Verified importing and running cleanly under /usr/bin/python (the production interpreter), not just the dev venv -- the gap that caused the earlier 3-day outage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
2026-06-22 19:36:27 +02:00
"""Tests for the pure cross-device log-merge logic."""
from __future__ import annotations
from diet_guard._sync_merge import merge_logs
def _entry(**overrides: object) -> dict[str, object]:
"""Build a minimal valid entry, overriding only what a test cares about."""
entry: dict[str, object] = {
"id": "id-1",
"time": "2026-06-22T08:00:00+02:00",
"desc": "oatmeal",
"kcal": 300.0,
"protein_g": 10.0,
"carbs_g": 50.0,
"fat_g": 5.0,
"grams": 200.0,
"source": "manual",
}
entry.update(overrides)
return entry
class TestUnionById:
def test_disjoint_logs_union_into_one(self) -> None:
a = {"2026-06-22": [_entry(id="a", time="2026-06-22T08:00:00+02:00")]}
b = {"2026-06-22": [_entry(id="b", time="2026-06-22T12:00:00+02:00")]}
merged = merge_logs(a, b)
assert {e["id"] for e in merged["2026-06-22"]} == {"a", "b"}
def test_same_id_in_both_logs_is_not_duplicated(self) -> None:
shared = _entry(id="shared")
merged = merge_logs({"2026-06-22": [shared]}, {"2026-06-22": [shared]})
assert len(merged["2026-06-22"]) == 1
def test_legacy_entries_without_id_dedup_by_time_and_desc(self) -> None:
legacy_a = _entry(id=None, time="2026-06-20T08:00:00+02:00", desc="toast")
legacy_a.pop("id")
legacy_b = dict(legacy_a)
merged = merge_logs({"2026-06-20": [legacy_a]}, {"2026-06-20": [legacy_b]})
assert len(merged["2026-06-20"]) == 1
def test_legacy_and_id_entries_with_different_keys_both_survive(self) -> None:
legacy = _entry(time="2026-06-20T08:00:00+02:00", desc="toast")
legacy.pop("id")
with_id = _entry(id="x", time="2026-06-20T09:00:00+02:00", desc="eggs")
merged = merge_logs({"2026-06-20": [legacy]}, {"2026-06-20": [with_id]})
assert len(merged["2026-06-20"]) == 2
class TestTombstoneWins:
def test_tombstone_beats_a_non_deleted_copy_either_order(self) -> None:
normal = _entry(id="x", deleted=False)
tombstoned = _entry(id="x", deleted=True)
forward = merge_logs(
{"2026-06-22": [normal]},
{"2026-06-22": [tombstoned]},
)
backward = merge_logs(
{"2026-06-22": [tombstoned]},
{"2026-06-22": [normal]},
)
assert forward["2026-06-22"][0]["deleted"] is True
assert backward["2026-06-22"][0]["deleted"] is True
def test_two_tombstoned_copies_stay_tombstoned(self) -> None:
tombstoned = _entry(id="x", deleted=True)
merged = merge_logs(
{"2026-06-22": [tombstoned]},
{"2026-06-22": [dict(tombstoned)]},
)
assert merged["2026-06-22"][0]["deleted"] is True
class TestRebucketingAndOrdering:
def test_entry_is_filed_under_its_own_times_date_not_the_arrival_bucket(
self,
) -> None:
misfiled = _entry(id="x", time="2026-06-21T23:00:00+02:00")
merged = merge_logs({"2026-06-22": [misfiled]}, {})
assert merged == {"2026-06-21": [misfiled]}
def test_a_days_entries_are_sorted_oldest_first(self) -> None:
late = _entry(id="late", time="2026-06-22T20:00:00+02:00")
early = _entry(id="early", time="2026-06-22T08:00:00+02:00")
merged = merge_logs({"2026-06-22": [late]}, {"2026-06-22": [early]})
assert [e["id"] for e in merged["2026-06-22"]] == ["early", "late"]
class TestAlgebraicProperties:
def test_merge_is_commutative(self) -> None:
a = {"2026-06-22": [_entry(id="a")]}
b = {"2026-06-22": [_entry(id="b", time="2026-06-22T09:00:00+02:00")]}
assert merge_logs(a, b) == merge_logs(b, a)
def test_merge_is_idempotent(self) -> None:
canonical = {"2026-06-22": [_entry(id="a")]}
assert merge_logs(canonical, canonical) == canonical
def test_merging_with_an_empty_log_is_a_no_op(self) -> None:
log = {"2026-06-22": [_entry(id="a")]}
assert merge_logs(log, {}) == log
assert merge_logs({}, log) == log
def test_merging_two_empty_logs_is_empty(self) -> None:
assert merge_logs({}, {}) == {}