screen-locker/screen_locker/tests/test_sync_degradation_part2.py
Krzysztof kuhy Rudnicki 965b9c4ebb Sync: Firebase only — drop the GitHub transport (kuhyx/syncs stays a frozen archive)
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
2026-10-09 19:18:56 +02:00

127 lines
5.0 KiB
Python

"""A Firebase credential that BUILDS but is refused must still self-heal.
Split from ``test_sync_degradation.py`` (250-line cap).
``firebase_client_for`` only checks ``has_session()``, which reads the cached
JSON off disk and never reaches the network. So on 2026-08-27 a credential the
server had stopped accepting constructed perfectly, the recovery -- wired only
to construction failures -- never ran, and every read and write was refused
with HTTP 401 for weeks behind a warning nobody read.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker import _startup_checks, _sync_client, _workout_sync
from screen_locker._credential_recovery import RecoveryResult
from screen_locker._degraded_sources import _record_degraded
from screen_locker.tests._workout_sync_fixtures import (
ReachableClient,
RejectedClient,
_firebase_config,
)
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
import pytest
class TestRejectedCredentialTriggersRecovery:
"""A credential that builds but is refused must heal, not 401 forever."""
def test_a_rejected_credential_is_recovered(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""This is 2026-08-27: present on disk, rejected by the server.
``firebase_client_for`` only checks ``has_session()``, which reads the
cached JSON and never reaches the network, so construction SUCCEEDED
and the recovery -- wired only to construction failures -- never ran
while every read and write was being refused with HTTP 401.
"""
_firebase_config(_sync_client, tmp_path, monkeypatch)
built: list[int] = []
def _rejected_then_live(*_args: object, **_kwargs: object) -> object:
built.append(1)
return RejectedClient() if len(built) == 1 else ReachableClient()
monkeypatch.setattr(_sync_client, "firebase_client_for", _rejected_then_live)
monkeypatch.setattr(
_sync_client,
"try_recover_firebase_session",
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
)
_sync_client.clear_degraded_sources()
client = _workout_sync.sync_client()
assert isinstance(client, ReachableClient), (
"a rejected credential must trigger the same self-heal as a "
"failed construction"
)
assert _sync_client.degraded_sources() == []
def test_a_still_rejected_credential_is_degraded_loudly(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If it is still refused after healing, say so on the lock screen.
Returning a dead client silently is what let weeks of 401s scroll past
in a journal nobody reads.
"""
_firebase_config(_sync_client, tmp_path, monkeypatch)
monkeypatch.setattr(
_sync_client,
"firebase_client_for",
lambda *_a, **_k: RejectedClient(),
)
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 credential the server refuses must be recorded"
assert "rejected" in degraded[0].reason
class TestDecisionNamesUnreadableSources:
"""A weekly count taken over a dead backend is a floor, not a count.
On 2026-08-24 the only trace of a dead Firebase was a warning 90 seconds
earlier, and the DECISION line read ``weekly=0/5`` as though that zero
were a measurement. The decision itself has to say which source it could
not read.
"""
def test_a_degraded_source_is_named_on_the_decision(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The annotation exists precisely so the zero cannot mislead."""
locker = create_locker(mock_tk, tmp_path, has_logged=True)
_record_degraded("firebase", "credential refused")
with patch.object(_startup_checks, "record_decision") as recorded:
locker._record_decision(
locked=True, reason="enforced", detail="No exemption applied."
)
assert recorded.call_args.args[0].extra["unreadable_sources"] == "firebase"
def test_a_healthy_run_adds_no_such_note(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Every decision must not carry an empty caveat."""
locker = create_locker(mock_tk, tmp_path, has_logged=True)
with patch.object(_startup_checks, "record_decision") as recorded:
locker._record_decision(
locked=True, reason="enforced", detail="No exemption applied."
)
assert "unreadable_sources" not in recorded.call_args.args[0].extra