mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
The PC push aborted before it ever tried Firebase whenever the GitHub PAT
was missing ("Workouts NOT synced: no sync token"), so once the PAT was lost
this machine published nothing although Firebase was configured and live.
The phone has not written the GitHub repo since 2026-08-21.
- sync_client() / sync_client_or_reason(): firebase_client_for("screen_locker")
only, proven with can_access_remote(), healed once from a sibling app's
session, else a warning naming what will not sync (push AND pull) plus a
recorded degraded source. No config -> warning, no fallback.
- _manual_push and scripts/tombstone_sync_record.py take their client from
sync_client_or_reason(); the push result carries the reason.
- Deleted: read_sync_token, remote_client, the mirror client, _push_outcome
(the half-landed-push case no longer exists), SYNC_TOKEN_FILE and
SYNC_REPO_OWNER/NAME, the sync_token test fixture. Status backend is
"firebase" or "none".
- crdt-sync pinned to v0.9.2 in pyproject, requirements.txt and
install_systemd.sh (the installer still said v0.9.0).
kuhyx/syncs/screen-locker-sync is left untouched as an archive; no code
reads it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
233 lines
9.2 KiB
Python
233 lines
9.2 KiB
Python
"""A degraded workout source must never be reported as "no workout".
|
|
|
|
Regression tests for 2026-06-12 and 2026-08-24, when a completed ~2h workout
|
|
sat in Firebase while the PC locked the screen anyway. The PC's Firebase
|
|
credential no longer authenticated, so the PC degraded to the (since
|
|
retired) GitHub mirror -- which the phone had stopped writing to on 2026-08-15. The
|
|
pull then returned zero records and the lock chain read that as "you did not
|
|
train today" rather than "I could not read the place your workouts live".
|
|
|
|
The distinction is the whole point. "No workout" is a statement about the
|
|
user; "could not check" is a statement about the machine, and only one of
|
|
them justifies taking the screen away.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from crdt_sync import FirebaseAuthError
|
|
|
|
from screen_locker import _sync_client, _workout_sync
|
|
from screen_locker._compliance_state import explain_lock_decision
|
|
from screen_locker._credential_recovery import RecoveryResult
|
|
from screen_locker._sick_tracker import SickHistory
|
|
from screen_locker.tests._workout_sync_fixtures import (
|
|
ReachableClient,
|
|
_firebase_config,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
import pytest
|
|
|
|
|
|
class TestUnreadableSourceIsNotNoWorkout:
|
|
"""The 2026-08-24 lockout, at the layer that actually took the screen.
|
|
|
|
With Firebase unreadable the PC had NO information about today. It
|
|
reported "No workout logged for today yet" and locked. These assertions
|
|
fail on the old code, which is the point: they encode the difference
|
|
between an answer and a failure to ask.
|
|
"""
|
|
|
|
def _files(self, tmp_path: Path) -> dict[str, Path]:
|
|
return {
|
|
"log_file": tmp_path / "log.json",
|
|
"early_bird_pending_file": tmp_path / "early_bird_pending.json",
|
|
}
|
|
|
|
# Local noon: outside the 05:00-08:30 early-bird window on any clock. CI
|
|
# runs around 08:00 UTC, and unpinned that landed inside the window on
|
|
# the runner, so the chain stopped at "early-bird window open" before it
|
|
# ever reached the degraded-source verdict these tests are about.
|
|
_NOON = datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0)
|
|
|
|
def test_lock_reports_unreadable_source_not_absent_workout(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""An empty log plus a dead backend must not read as "you did nothing"."""
|
|
result = explain_lock_decision(
|
|
**self._files(tmp_path),
|
|
now=self._NOON,
|
|
sick_history=SickHistory(),
|
|
weekly_minimum_met=False,
|
|
relaxed_day=False,
|
|
degraded_sources=(_sync_client.DegradedSource("firebase", "HTTP 400"),),
|
|
)
|
|
|
|
assert result.sources_degraded is True
|
|
assert "firebase" in result.reason.lower()
|
|
|
|
def test_healthy_sources_keep_the_plain_verdict(self, tmp_path: Path) -> None:
|
|
"""With every backend answering, an empty log really does mean none."""
|
|
result = explain_lock_decision(
|
|
**self._files(tmp_path),
|
|
now=self._NOON,
|
|
sick_history=SickHistory(),
|
|
weekly_minimum_met=False,
|
|
relaxed_day=False,
|
|
)
|
|
|
|
assert result.sources_degraded is False
|
|
|
|
|
|
class TestFirebaseDegradationIsVisible:
|
|
"""Losing the primary backend must leave a trace the caller can act on."""
|
|
|
|
def test_degradation_is_recorded_not_just_logged(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A dead Firebase must be queryable, not only a log line.
|
|
|
|
The 2026-08-24 run *did* warn. Nothing read the warning, so the lock
|
|
decision was made as if the source were healthy. Recording the failure
|
|
in state is what lets the decision layer tell the two cases apart.
|
|
"""
|
|
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
|
|
|
def _boom(*_args: object, **_kwargs: object) -> None:
|
|
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
|
|
raise FirebaseAuthError(message)
|
|
|
|
monkeypatch.setattr(_sync_client, "firebase_client_for", _boom)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
assert _workout_sync.sync_client() is None
|
|
degraded = _sync_client.degraded_sources()
|
|
assert degraded, "a failed Firebase sign-in must be recorded"
|
|
assert "INVALID_LOGIN_CREDENTIALS" in degraded[0].reason
|
|
|
|
def test_healthy_firebase_records_nothing(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The happy path must stay silent, or the signal means nothing."""
|
|
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
|
monkeypatch.setattr(
|
|
_sync_client,
|
|
"firebase_client_for",
|
|
lambda *_a, **_k: ReachableClient(),
|
|
)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
_workout_sync.sync_client()
|
|
|
|
assert _sync_client.degraded_sources() == []
|
|
|
|
def test_successful_recovery_returns_a_working_client(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Healing must actually hand back Firebase, not a degraded fallback.
|
|
|
|
This is the path that ran for real on 2026-08-24: the cached
|
|
credential was dead, a sibling app's refresh token rebuilt the
|
|
session, and the retry succeeded. Nothing may be recorded as
|
|
degraded -- the source ended up readable.
|
|
"""
|
|
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
|
attempts: list[int] = []
|
|
|
|
def _dead_then_alive(*_args: object, **_kwargs: object) -> object:
|
|
attempts.append(1)
|
|
if len(attempts) == 1:
|
|
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
|
|
raise FirebaseAuthError(message)
|
|
return ReachableClient()
|
|
|
|
monkeypatch.setattr(_sync_client, "firebase_client_for", _dead_then_alive)
|
|
monkeypatch.setattr(
|
|
_sync_client,
|
|
"try_recover_firebase_session",
|
|
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
|
|
)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
recovered = _workout_sync.sync_client()
|
|
assert isinstance(recovered, ReachableClient)
|
|
assert _sync_client.degraded_sources() == [], (
|
|
"a source that recovered is not degraded"
|
|
)
|
|
|
|
def test_recovery_that_still_cannot_sign_in_is_degraded(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A token that mints but still fails must degrade, not pretend.
|
|
|
|
Recovery reporting success is not proof the backend is readable, so
|
|
the retry is what decides. If it raises too, this is the same lockout
|
|
as before and must be recorded -- silently handing back a dead
|
|
client is exactly how 2026-08-24 stayed invisible.
|
|
"""
|
|
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
|
|
|
def _always_dead(*_args: object, **_kwargs: object) -> None:
|
|
msg = "still 400 after refresh"
|
|
raise FirebaseAuthError(msg)
|
|
|
|
monkeypatch.setattr(_sync_client, "firebase_client_for", _always_dead)
|
|
monkeypatch.setattr(
|
|
_sync_client,
|
|
"try_recover_firebase_session",
|
|
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
|
|
)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
assert _workout_sync.sync_client() is None
|
|
degraded = _sync_client.degraded_sources()
|
|
assert degraded, "a retry that still fails must be recorded"
|
|
assert "still 400 after refresh" in degraded[0].reason
|
|
|
|
def test_failed_recovery_records_both_the_cause_and_the_attempt(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""When healing fails, say why it failed as well as why it was needed.
|
|
|
|
This is 2026-08-24 on a machine with no sibling to borrow from. The
|
|
recorded reason has to carry both halves, because "Firebase is dead"
|
|
and "and I could not fix it myself" prompt different actions.
|
|
"""
|
|
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
|
|
|
def _boom(*_args: object, **_kwargs: object) -> None:
|
|
msg = "HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
|
|
raise FirebaseAuthError(msg)
|
|
|
|
monkeypatch.setattr(_sync_client, "firebase_client_for", _boom)
|
|
monkeypatch.setattr(
|
|
_sync_client,
|
|
"try_recover_firebase_session",
|
|
lambda: RecoveryResult(
|
|
recovered=False, reason="no sibling app has a cached refresh token"
|
|
),
|
|
)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
assert _workout_sync.sync_client() is None
|
|
reason = _sync_client.degraded_sources()[0].reason
|
|
assert "INVALID_LOGIN_CREDENTIALS" in reason
|
|
assert "no sibling app" in reason
|
|
|
|
def test_unconfigured_machine_is_not_degraded(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""No Firebase config is a choice, not a failure -- do not cry wolf."""
|
|
monkeypatch.setattr(
|
|
_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json")
|
|
)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
_workout_sync.sync_client()
|
|
|
|
assert _sync_client.degraded_sources() == []
|