diet-guard/diet_guard/tests/test_sync_backend.py
Krzysztof kuhy Rudnicki 458f10515b
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Make the phone publish on every tick, and fail closed on Firebase errors
Meals logged on the phone stopped reaching the PC, so the gate kept locking
for slots that were already logged and the only way through was to spam
"nothing". The PC's fetch was never broken: it was faithfully pulling a
Firebase that did not contain the phone's meals.

The phone had no working publish backstop on either transport:

- `checkAndNotify` gated its sync on `due.isNotEmpty`. Logging every meal
  promptly means nothing is ever due, so the ~15-min periodic task never
  published. It was a notification job that happened to sync, not a sync
  timer. It now syncs on every tick it is asked to, then computes due slots
  from the merged result, so a meal pulled from another device suppresses a
  nag instead of triggering one.
- `backgroundSyncPush` never called `initSyncDeviceId()`. A fresh isolate has
  its own static state, so `currentSyncDeviceId` fell back to the compile-time
  'phone' constant and published to `devices/phone/` rather than the persisted
  uuid -- which then makes the next foreground push see `unchanged` and skip
  writing the uuid directory entirely.

Separately, on the "Fetch from sync" path itself: `FirebaseSyncError` and
`FirebaseAuthError` are siblings of `GitHubSyncError` under `RemoteSyncError`,
not subclasses, so every per-request failure of the *primary* backend escaped
`pull_shared_log`'s catch tuple and raised a traceback out of a helper
documented to fail closed with a reason string. `ConfigError` needed the same
treatment by a different route: it subclasses `Exception` directly, so it is
outside `RemoteSyncError` too, and is now translated at `_client_for_run`.

The PAT is no longer mandatory either. Firebase is primary, but `_read_token`
ran before the client was ever built, so a Firebase-only machine could not
sync at all -- the commits that fixed this touched only the Dart side.

A stalled sync was invisible: the config gates returned silently and
`openFirebase` swallowed every error. `dart:developer`'s `log()` does not
reach logcat on a release build (verified on device), so logging alone would
not have surfaced it. `SyncHealth` records each tick's outcome and the log
screen shows a banner when publishing has actually stopped.

Also make the real-Tk probes bypass the window manager. A tiling WM retiles a
managed toplevel instead of honouring the requested geometry, so `winfo_height`
reported 0/1px and the layout and focus-ring assertions failed on noise rather
than on any regression -- 8 failures on the dev machine that only passed under
Xvfb. `overrideredirect` makes the measurement deterministic with or without a
WM; the probes still detect a genuine overflow at 1366x300.

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

118 lines
4.6 KiB
Python

"""Tests for which backend a sync tick runs against.
Split out of ``test_sync.py`` to keep both files under the repo's 500-line
cap. Covers the Firebase/GitHub cutover seam (``_remote_client``) and which
credentials a run actually requires (``_client_for_run``).
"""
from __future__ import annotations
from pathlib import Path
from crdt_sync import ConfigError
import pytest
from diet_guard import _sync
class TestRemoteClient:
"""Which backend a sync tick runs against during the cutover."""
def test_stays_on_github_without_firebase_config(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unconfigured machine must not reach the network at all."""
monkeypatch.setattr(_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json"))
github = object()
assert _sync._remote_client(github) is github
def test_mirrors_to_github_when_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Configured: Firebase is primary, GitHub keeps receiving writes."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
monkeypatch.setattr(
_sync, "mirror_client_for", lambda _app, client: ("mirror", client)
)
github = object()
assert _sync._remote_client(github) == ("mirror", github)
def test_falls_back_when_firebase_is_unusable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A broken Firebase must degrade to GitHub, never fail the tick."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_sync, "mirror_client_for", _boom)
github = object()
assert _sync._remote_client(github) is github
class TestClientForRun:
"""Which credentials a run actually requires."""
def test_syncs_without_a_pat_when_firebase_is_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Firebase-only machine must sync, not fail for a missing PAT.
Requiring the token before ever constructing Firebase left the Python
half unable to sync at all on a device that had moved to Firebase --
the "Firebase-only device" fix had only ever touched the Dart side.
"""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
monkeypatch.setattr(_sync, "SYNC_TOKEN_FILE", tmp_path / "absent_sync_token")
monkeypatch.setattr(_sync, "firebase_client_for", lambda app: ("firebase", app))
assert _sync._client_for_run() == ("firebase", "diet_guard")
def test_unusable_firebase_config_becomes_a_sync_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unusable Firebase config must fail *closed*, not raise.
``ConfigError`` subclasses ``Exception`` directly, not
``RemoteSyncError``, so letting it out of here escapes every caller's
catch tuple and raises a traceback out of the gate's fail-closed
"Fetch from sync" button -- reintroducing, by a different exception,
exactly the failure the ``RemoteSyncError`` swap was made to stop.
This branch is reached precisely when the config file exists but is
unusable and no PAT is present.
"""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_sync, "CONFIG_FILE", config)
monkeypatch.setattr(_sync, "SYNC_TOKEN_FILE", tmp_path / "absent_token")
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_sync, "firebase_client_for", _boom)
with pytest.raises(_sync.SyncError):
_sync._client_for_run()
def test_still_fails_when_neither_backend_is_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No PAT *and* no Firebase is the genuine "nothing is set up" case."""
monkeypatch.setattr(_sync, "CONFIG_FILE", tmp_path / "absent_firebase.json")
monkeypatch.setattr(_sync, "SYNC_TOKEN_FILE", tmp_path / "absent_sync_token")
with pytest.raises(_sync.SyncError):
_sync._client_for_run()