diet-guard/diet_guard/tests/test_sync.py
Krzysztof kuhy Rudnicki 3e51e614b1 Split the last seven Python files; extend the redirect guard to all five paths
Ninth batch of the 250-line sweep. Every Python file in the repo is now under
cap (over-cap count 25 -> 18; the 18 remaining are all Dart).

  _foodbank.py             365 -> 210  (+ _foodbank_search, _foodbank_rebuild)
  test_averages.py         351 -> 222  (+ test_averages_report.py)
  _estimator.py            325 -> 137  (+ _estimator_off.py)
  _budget.py               323 -> 227  (+ _budget_biometrics, _budget_derived)
  test_foodbank.py         311 -> 117  (+ test_foodbank_rebuild.py)
  _averages.py             298 -> 174  (+ _averages_periods, _averages_report)
  test_budget_history.py   288 -> 162  (+ test_budget_history_io.py)

The split boundaries are constrained by which module owns each conftest-patched
state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in
_foodbank: the derived/search/rebuild halves reach the file only through their
parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into
their headers as an incidental import, which is exactly the leak the guard is
for -- both cleaned.

test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE,
BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather
than just the food log, in both directions: only the owning module may name
each constant, and each redirect demonstrably moves a real write off the live
path.

Two rewires fail-closed rather than silently: `_budget.budget_weight` and
`_estimator.requests` were module-attribute references no import rewrite
touches, and both raised AttributeError immediately. The latter appeared only
after ruff removed the now-unused `requests` import from _estimator -- the
import was load-bearing solely as a patch target.

Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under
xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard
byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 20:37:05 +02:00

194 lines
7.5 KiB
Python

"""Tests for the cross-device sync orchestration.
The GitHub layer is mocked (no network access); conftest.py's
``_isolate_state``/``_hmac_key`` fixtures provide the rest of the isolation
(sync token path, food log path, a deterministic HMAC key).
"""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from diet_guard import _sync, _sync_client
from diet_guard._device import device_id
from diet_guard._foodbank_search import lookup_food
from diet_guard._state import load_log, log_meal
from diet_guard.tests._sync_fakes import _mock_client, _nutrition, _write_token
class TestReadToken:
def test_missing_token_file_raises_sync_error(self) -> None:
with pytest.raises(_sync.SyncError):
_sync_client._read_token()
def test_empty_token_file_raises_sync_error(self) -> None:
_write_token(" ")
with pytest.raises(_sync.SyncError):
_sync_client._read_token()
def test_present_token_is_read_and_stripped(self) -> None:
_write_token(" abc123 \n")
assert _sync_client._read_token() == "abc123"
class TestRunSync:
def test_raises_before_touching_github_when_no_token(self) -> None:
with (
patch.object(_sync_client, "GitHubSyncClient") as client_cls,
pytest.raises(_sync.SyncError),
):
_sync.run_sync()
client_cls.assert_not_called()
def test_pushes_local_log_when_no_other_devices_have_synced(self) -> None:
_write_token()
log_meal("oatmeal", _nutrition(), slot=8)
client = _mock_client(devices=())
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert sum(len(entries) for entries in merged.values()) == 1
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
# The log push, the derived bank rebuilt from that same log, and this
# device's revision -- published *after* the log, so a peer can never
# cache "seen rev X" against a log it never received.
assert pushed == [
f"diet-guard-sync/devices/{device_id()}/food_bank.json",
f"diet-guard-sync/devices/{device_id()}/food_log.json",
f"diet-guard-sync/revs/{device_id()}",
]
log_call = next(
call
for call in client.put_file_text.call_args_list
if call.args[0].endswith("food_log.json")
)
pushed_json = log_call.args[1]
pushed = json.loads(pushed_json)
(record,) = pushed.values()
assert "fields" in record
assert "id" in record
def test_skips_its_own_device_id_when_listing(self) -> None:
_write_token()
client = _mock_client(
devices=("pc", "phone"),
files={"diet-guard-sync/devices/phone/food_log.json": "{}"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
# Every pull -- food log, budget, curated bank -- skips "pc" (this
# device) and only ever reads "phone"'s files.
requested_paths = [call.args[0] for call in client.get_file_text.call_args_list]
assert requested_paths == [
"diet-guard-sync/devices/phone/food_log.json",
"diet-guard-sync/devices/phone/budget.json",
"diet-guard-sync/devices/phone/food_bank.json",
"diet-guard-sync/devices/phone/food_bank_manual.json",
]
def test_skips_a_device_with_no_pushed_file_yet(self) -> None:
_write_token()
client = _mock_client(devices=("phone",), files={})
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert merged == {}
def test_ignores_a_device_whose_pushed_file_is_not_a_json_object(self) -> None:
_write_token()
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": "[]"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert merged == {}
def test_skips_a_device_whose_pushed_file_is_corrupt_json(self) -> None:
"""An interrupted/truncated push must not crash every other device's
merge -- it is treated the same as a device that hasn't pushed yet.
"""
_write_token()
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": "{not valid json"},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
assert merged == {}
def test_merges_in_a_remote_devices_entries(self) -> None:
_write_token()
remote_log_json = json.dumps(
{
"2026-06-22": [
{
"id": "phone-1",
"time": "2026-06-22T09:00:00+02:00",
"desc": "phone meal",
"kcal": 400.0,
"protein_g": 20.0,
"carbs_g": 40.0,
"fat_g": 10.0,
"grams": 300.0,
"source": "manual",
},
],
},
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
merged = _sync.run_sync()
descs = {entry["desc"] for entries in merged.values() for entry in entries}
assert "phone meal" in descs
def test_resigns_every_entry_so_an_unsigned_remote_entry_survives_reload(
self,
) -> None:
"""The data-loss trap: an unsigned phone-origin entry must not be
silently dropped by load_log() after sync persists it locally --
_entry_is_valid() rejects any unsigned entry once a key exists.
"""
_write_token()
remote_log_json = json.dumps(
{
"2026-06-22": [
{
"id": "phone-1",
"time": "2026-06-22T09:00:00+02:00",
"desc": "phone meal",
"kcal": 400.0,
"protein_g": 20.0,
"carbs_g": 40.0,
"fat_g": 10.0,
"grams": 300.0,
"source": "manual",
# No "hmac" -- the phone never holds the shared key.
},
],
},
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
reloaded = load_log()
descs = {entry["desc"] for entries in reloaded.values() for entry in entries}
assert "phone meal" in descs
def test_rebuilds_the_food_bank_after_merge(self) -> None:
_write_token()
log_meal("oatmeal", _nutrition(), slot=8)
client = _mock_client(devices=())
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert lookup_food("oatmeal") is not None