mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:07:40 +01:00
Four asks, plus the fixes two review passes turned up. Removals (all unused): - "Repeat last meal" button, and the reward/temptation-bundling prompt it was the sole trigger for. Replaced with a post-log progress card showing kcal against budget, macros, and the adherence streak. - The multi-item meal builder, on both the gate and the phone. The stored `components` field stays: nothing writes it now, but historical composite entries and the food bank still read it and it is part of the wire format. - Meal photos entirely: 13 files, `imagePath`, the photo filter, the wrapper's /blobs/ route, image_picker, and the CAMERA permission. Reminder false alarms had three real causes, all fixed: - Logging never reconciled notifications, so a reminder sat on screen up to 15 minutes after the meal was logged -- on the phone itself. - The background check never pulled, so a PC-logged meal nagged forever until the app was opened. Now mirrors _cli_gate._should_lock: cheap local check, network only when a slot looks due, then re-check. - Meals before 08:00 / after 22:00 were attributed to no slot at all. New slot_for_log/slotForLog clamps them to the first/last slot, byte-identical across Python and Dart. Deliberately separate from elapsed_slots, which would otherwise make every slot fall due at 23:00. Budget history, so lowering the budget stops rewriting the past: - Forward-only (effective_from, kcal) entries; each day is judged against the budget in force then. Verified live: setting 2000 left a 36-day adherence streak and the whole calendar pixel-identical. - Rides as hist:<date> fields on the existing budget record. crdt_sync merges per-field over the union of field names and both devices push the merged log, so a device that predates the feature relays them untouched -- no coordinated rollout, no second document. - The seed guard tests emptiness, not file presence: _sync can write an empty history after merging with a pre-feature peer, and a presence check would then never seed again, silently reclassifying every past day. One shared source of truth for every piece of data: - Body weight now travels as its own `weight` CRDT field. Inside `value` it was collateral damage of whole-map LWW -- any phone budget edit deleted it, and the protein target with it. - Both halves of the food bank sync. The curated half (foods added without eating them) was the last device-local state. The derived half merges with count as the clock, so LWW means max-count-wins; the log stays authoritative for membership, because a CRDT union never shrinks and a peer's stale copy would otherwise resurrect a deleted food permanently. - Bank adapters honour tombstones, and Dart parses bank records defensively: a cast failure there is an Error, not an Exception, so one malformed remote record would have broken phone sync permanently. _test_guard.py blocks any pathlib write to real user state while pytest is in the process, whatever conftest is loaded. A throwaway test run outside the package conftest destroyed a real GitHub token during this work; this makes that class of accident impossible rather than merely discouraged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
105 lines
4.0 KiB
Python
105 lines
4.0 KiB
Python
"""Tests for _test_guard.py — the real-user-state write barrier.
|
|
|
|
Meta-tests: they assert that the guard protecting every *other* test is
|
|
itself working. Deliberately exercised against the real protected paths,
|
|
because "does a write to the real path raise?" is the whole question -- the
|
|
guard raises before any I/O happens, so nothing is created.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from diet_guard import _test_guard
|
|
from diet_guard._test_guard import RealUserStateWriteError
|
|
|
|
_REAL_TOKEN = pathlib.Path.home() / ".config" / "diet_guard" / "sync_token"
|
|
_REAL_LOG = pathlib.Path.home() / ".local" / "share" / "diet_guard" / "food_log.json"
|
|
|
|
|
|
class TestBlocksRealWrites:
|
|
"""The exact accident that destroyed the user's PAT must be impossible."""
|
|
|
|
def test_write_text_to_the_real_token_raises(self) -> None:
|
|
with pytest.raises(RealUserStateWriteError):
|
|
_REAL_TOKEN.write_text("tok")
|
|
|
|
def test_write_bytes_to_the_real_log_raises(self) -> None:
|
|
with pytest.raises(RealUserStateWriteError):
|
|
_REAL_LOG.write_bytes(b"{}")
|
|
|
|
def test_opening_the_real_token_for_writing_raises(self) -> None:
|
|
with pytest.raises(RealUserStateWriteError):
|
|
_REAL_TOKEN.open("w")
|
|
|
|
def test_appending_to_the_real_log_raises(self) -> None:
|
|
with pytest.raises(RealUserStateWriteError):
|
|
_REAL_LOG.open("a")
|
|
|
|
def test_unlinking_real_state_raises(self) -> None:
|
|
with pytest.raises(RealUserStateWriteError):
|
|
_REAL_TOKEN.unlink()
|
|
|
|
def test_renaming_real_state_raises(self) -> None:
|
|
with pytest.raises(RealUserStateWriteError):
|
|
_REAL_TOKEN.rename(_REAL_TOKEN.with_suffix(".moved"))
|
|
|
|
def test_the_error_names_the_path_and_the_fix(self) -> None:
|
|
with pytest.raises(RealUserStateWriteError) as caught:
|
|
_REAL_TOKEN.write_text("tok")
|
|
assert "sync_token" in str(caught.value)
|
|
assert "tmp_path" in str(caught.value)
|
|
|
|
|
|
class TestAllowsEverythingElse:
|
|
"""The guard must not get in the way of legitimate test I/O."""
|
|
|
|
def test_tmp_path_writes_are_untouched(self, tmp_path: pathlib.Path) -> None:
|
|
target = tmp_path / "food_log.json"
|
|
target.write_text("{}")
|
|
assert target.read_text() == "{}"
|
|
|
|
def test_reading_real_state_is_allowed(self) -> None:
|
|
# Reads are safe and some checks legitimately want them; only writes
|
|
# are barred. Absent file is fine -- the point is that it does not raise.
|
|
assert _REAL_TOKEN.exists() in (True, False)
|
|
|
|
def test_opening_real_state_for_reading_is_allowed(self) -> None:
|
|
if _REAL_TOKEN.exists():
|
|
with _REAL_TOKEN.open() as handle:
|
|
handle.read()
|
|
|
|
|
|
class TestGuardInternals:
|
|
"""Edge cases in the path check and the install latch."""
|
|
|
|
def test_an_unresolvable_path_is_not_treated_as_protected(self) -> None:
|
|
assert not _test_guard._is_protected(pathlib.Path("\x00bad"))
|
|
|
|
def test_an_unrelated_path_is_not_protected(self, tmp_path: pathlib.Path) -> None:
|
|
assert not _test_guard._is_protected(tmp_path / "anything.json")
|
|
|
|
def test_install_is_idempotent(self) -> None:
|
|
"""A second install must not wrap the already-wrapped methods again."""
|
|
before = pathlib.Path.write_text
|
|
_test_guard.install()
|
|
assert pathlib.Path.write_text is before
|
|
|
|
def test_install_is_a_no_op_outside_pytest(self) -> None:
|
|
"""Production imports diet_guard without pytest; the guard stays off."""
|
|
before = pathlib.Path.write_text
|
|
with patch.dict(sys.modules):
|
|
del sys.modules["pytest"]
|
|
_test_guard.install()
|
|
assert pathlib.Path.write_text is before
|
|
|
|
def test_guarding_an_already_guarded_method_is_a_no_op(self) -> None:
|
|
"""The per-method marker, not a module flag, is what makes it safe."""
|
|
before = pathlib.Path.write_text
|
|
_test_guard._guard("write_text", lambda *_a, **_k: True)
|
|
assert pathlib.Path.write_text is before
|