From 965b9c4ebb2103cb976620a158332b4dc286a8fd Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 9 Oct 2026 19:18:56 +0200 Subject: [PATCH] =?UTF-8?q?Sync:=20Firebase=20only=20=E2=80=94=20drop=20th?= =?UTF-8?q?e=20GitHub=20transport=20(kuhyx/syncs=20stays=20a=20frozen=20ar?= =?UTF-8?q?chive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku --- docs/todo/DOCS-workstream-c-github-sync.md | 8 + install_systemd.sh | 2 +- pyproject.toml | 6 +- requirements.txt | 2 +- screen_locker/_constants.py | 19 +- screen_locker/_manual_push.py | 84 +++------ screen_locker/_mcp.py | 4 +- screen_locker/_phone_verification.py | 8 +- screen_locker/_push_outcome.py | 54 ------ screen_locker/_source_health.py | 2 +- screen_locker/_sync_client.py | 171 ++++++------------ screen_locker/_sync_status.py | 18 +- screen_locker/_workout_sync.py | 42 ++--- screen_locker/tests/_workout_sync_fixtures.py | 2 +- screen_locker/tests/conftest.py | 15 +- screen_locker/tests/test_home_isolation.py | 2 +- screen_locker/tests/test_manual_push_part2.py | 124 +++++-------- screen_locker/tests/test_sync_degradation.py | 44 ++--- .../tests/test_sync_degradation_part2.py | 19 +- screen_locker/tests/test_sync_fixtures.py | 37 +--- screen_locker/tests/test_sync_status.py | 13 +- screen_locker/tests/test_workout_sync.py | 34 +--- .../tests/test_workout_sync_part2.py | 56 +++--- .../tests/test_workout_sync_part3.py | 65 ------- .../tests/test_workout_sync_part4.py | 6 +- .../tests/test_workout_sync_part5.py | 39 ++-- .../tests/test_workout_sync_part6.py | 29 ++- scripts/tombstone_sync_record.py | 21 +-- 28 files changed, 276 insertions(+), 650 deletions(-) delete mode 100644 screen_locker/_push_outcome.py delete mode 100644 screen_locker/tests/test_workout_sync_part3.py diff --git a/docs/todo/DOCS-workstream-c-github-sync.md b/docs/todo/DOCS-workstream-c-github-sync.md index 54c06a2..40b2414 100644 --- a/docs/todo/DOCS-workstream-c-github-sync.md +++ b/docs/todo/DOCS-workstream-c-github-sync.md @@ -2,6 +2,14 @@ ## Status +**Superseded (2026-10-09): screen-locker sync is Firebase RTDB only.** The +phone stopped writing the GitHub repo on 2026-08-21, and from 2026-10-09 the +PC does not use it either: the push, `sync_client()` and +`scripts/tombstone_sync_record.py` all use `firebase_client_for("screen_locker")`, +and the sync_token / GitHub client / mirror code paths are deleted. +`kuhyx/syncs/screen-locker-sync` stays as a frozen archive that no code reads +and nothing deletes. The rest of this document is the historical design record. + **Blocked on Workstream D** (2026-07-04 user decision — see "Sequencing decision" below). No code has been written for this workstream. The design questions below (sync pattern, repo name, scope) have been pre-decided so the diff --git a/install_systemd.sh b/install_systemd.sh index 8facada..971b6f1 100755 --- a/install_systemd.sh +++ b/install_systemd.sh @@ -73,7 +73,7 @@ ensure_runtime_deps() { _pip_runtime_dep gatelock \ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock" _pip_runtime_dep crdt_sync \ - "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync" + "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync" } ensure_runtime_deps diff --git a/pyproject.toml b/pyproject.toml index 6185fa3..2eafdd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ version = "1.0.0" description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration" requires-python = ">=3.14" # gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm -# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport, -# also used by diet_guard; here for phone-workout GitHub sync (Workstream C) +# crdt-sync: shared CRDT merge scheme + sync transports, also used by +# diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09 dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock", - "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", + "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync", "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays", "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time", ] diff --git a/requirements.txt b/requirements.txt index 3d072b4..66ec089 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ bandit==1.9.4 codespell==2.4.3 coverage==7.16.2 -crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync +crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 4134ac4..5ff27bd 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -186,15 +186,12 @@ RUNNERUP_WEBDAV_DIRS: tuple[Path, ...] = ( ) # --------------------------------------------------------------------------- -# Sync (phone workout data) — Workstream C, built on the shared crdt-sync -# library. GitHub is used purely as dumb file storage via the REST Contents -# API (not a git clone), same pattern as diet_guard's cross-device sync. The -# phone app pushes to devices//log.json; the PC reads -# every device's log and pushes its OWN manual workouts to devices/pc/log.json -# (see _manual_push) — which needs a token with contents:write, not read-only. +# Sync (phone workout data) — built on the shared crdt-sync library, Firebase +# RTDB only since 2026-10-09 (the shared ~/.config/crdt-sync/ credential). The +# old GitHub repo kuhyx/syncs/screen-locker-sync is a frozen archive: nothing +# reads or writes it. Every device pushes to devices//log.json; the +# PC reads every device's log and pushes its OWN workouts (see _manual_push). # --------------------------------------------------------------------------- -SYNC_REPO_OWNER: str = "kuhyx" -SYNC_REPO_NAME: str = "syncs" # The phone's pre-uuid device id. Still read (the phone may not have migrated # yet), but no longer the only path the reader knows about -- it discovers # every device directory. @@ -209,12 +206,6 @@ SYNC_LEGACY_DEVICE_ID: str | None = "pc" SYNC_DEVICE_ID_FILE: Path = ( Path.home() / ".local" / "share" / "screen_locker" / ".device_id" ) -# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The -# user creates this once via github.com and saves it here, mode 600. Never -# committed — this path is outside the repo entirely. Unlike diet_guard, -# this file being absent is a normal, expected state (sync is optional here). -SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token" - # Revision cache for cross-device sync. Without it every push re-uploads and # every pull re-downloads regardless of change, which is the traffic the # Firebase free tier's monthly budget depends on not happening. diff --git a/screen_locker/_manual_push.py b/screen_locker/_manual_push.py index 7ddf1cd..f937ff4 100644 --- a/screen_locker/_manual_push.py +++ b/screen_locker/_manual_push.py @@ -1,7 +1,7 @@ -"""Push the PC's workouts to the shared sync repo. +"""Push the PC's workouts to the shared Firebase sync store. ``log.json`` is the single source of truth: this module derives the -crdt-sync log directly from it and pushes to ``devices/pc/log.json``, so the +crdt-sync log directly from it and pushes to this device's log, so the phone converges on the SAME history the PC has — manual workouts *and* machine-verified ones (StrongLifts sessions, RunnerUp runs). @@ -10,11 +10,11 @@ workout is published automatically no matter when or how it was recorded, including entries logged before this module existed. Idempotence comes from two properties: the record id is the workout's own stable ``workout_id``, and its HLC is derived deterministically from the entry's own timestamp — so re-pushing -an unchanged log produces a byte-identical record set and no repo churn. +an unchanged log produces a byte-identical record set and no store churn. This reverses the old "the PC only ever reads, never pushes" invariant (it had -no data of its own to contribute). Pushing needs a sync token with -**contents:write** on the repo — a read-only token 403s. Nothing here fails +no data of its own to contribute). Firebase is the only transport since +2026-10-09; the old GitHub mirror is a frozen archive. Nothing here fails silently: every path returns a :class:`PushResult` whose ``reason`` says exactly what happened, and logs it. """ @@ -29,33 +29,23 @@ from typing import TYPE_CHECKING from crdt_sync import ( FileSyncStateStore, - GitHubSyncClient, - GitHubSyncError, Hlc, LogCodec, Record, RemoteSyncError, - RepoNotFoundError, RevisionTracking, SyncTarget, sync_log, ) -from screen_locker._constants import ( - SYNC_REPO_NAME, - SYNC_REPO_OWNER, - SYNC_STATE_FILE, - SYNC_TIMEOUT_SECONDS, - SYNC_TOKEN_FILE, -) +from screen_locker._constants import SYNC_STATE_FILE from screen_locker._device import device_identity from screen_locker._log_io import load_workout_log from screen_locker._log_mixin import _derive_workout_id -from screen_locker._push_outcome import describe_push from screen_locker._sync_retry import with_sync_retry from screen_locker._sync_tombstones import tombstone_records from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES -from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client +from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason if TYPE_CHECKING: from pathlib import Path @@ -172,32 +162,20 @@ def push_pc_workouts(log_file: Path) -> PushResult: the returned :class:`PushResult` and a WARNING say why a push did not happen. """ identity = device_identity() - token = read_sync_token() - if token is None: - reason = f"no sync token at {SYNC_TOKEN_FILE}" - _logger.warning( - "Workouts NOT synced: %s — create a fine-grained GitHub PAT with " - "contents:write on %s/%s and save it there (chmod 600)", - reason, - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - ) - return PushResult(pushed=False, record_count=0, reason=reason) - log = records_from_workout_log(log_file) if not log: reason = f"no counted workouts in {log_file}" _logger.warning("Workouts NOT synced: %s", reason) return PushResult(pushed=False, record_count=0, reason=reason) - client = remote_client( - GitHubSyncClient( - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - token, - timeout_seconds=SYNC_TIMEOUT_SECONDS, + client, reason = sync_client_or_reason() + if client is None: + # sync_client_or_reason has already said why, in full; this line + # ties that cause to the records it just stranded. + _logger.warning( + "Workouts NOT synced: %d workout(s) stay local — %s", len(log), reason ) - ) + return PushResult(pushed=False, record_count=len(log), reason=reason) try: with_sync_retry( lambda: sync_log( @@ -218,32 +196,14 @@ def push_pc_workouts(log_file: Path) -> PushResult: ), description="push PC workouts", ) - except (GitHubSyncError, RemoteSyncError) as exc: + except RemoteSyncError as exc: + # Report what actually happened, nothing more: this used to assert + # "a 403 means the token lacks contents:write" for every failure, + # including a plain network error, which sent a 2026-07-20 + # investigation chasing a permissions bug that did not exist. reason = f"sync error: {exc}" - # Report what actually happened. This used to assert "a 403 here means - # the token lacks contents:write" for *every* failure, including a - # plain network error — which sent a 2026-07-20 investigation chasing a - # permissions bug that did not exist (the token could write fine; the - # network was simply not up yet). Only RepoNotFoundError actually - # implicates the repo or the token. - if isinstance(exc, RepoNotFoundError): - _logger.warning( - "Workout sync push FAILED for %d workout(s): %s — check the " - "token has contents:write on %s/%s", - len(log), - exc, - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - ) - else: - _logger.warning( - "Workout sync push FAILED for %d workout(s): %s", - len(log), - exc, - ) + _logger.warning("Workout sync push FAILED for %d workout(s): %s", len(log), exc) return PushResult(pushed=False, record_count=len(log), reason=reason) - # Delegated so a half-landed push can never be logged as a clean one; - # see _push_outcome for why that distinction is load-bearing. - _, reason = describe_push(len(log)) - return PushResult(pushed=True, record_count=len(log), reason=reason) + _logger.info("Synced %d workout(s) to Firebase", len(log)) + return PushResult(pushed=True, record_count=len(log), reason="pushed") diff --git a/screen_locker/_mcp.py b/screen_locker/_mcp.py index 4ed2532..4f67877 100644 --- a/screen_locker/_mcp.py +++ b/screen_locker/_mcp.py @@ -23,8 +23,8 @@ Safety invariants (do not break when adding tools): calls must never write to stdout. All logging is routed to STDERR below, and no tool calls ``screen_lock.py`` / ``status_view.main`` (which ``print`` / open Tk windows / ``sys.exit``). - * **No secret ever leaves.** No tool returns the sync token - (``~/.config/screen_locker/sync_token``) or any HMAC key; the predicates + * **No secret ever leaves.** No tool returns the Firebase session + (``~/.config/screen_locker/firebase_auth.json``) or any HMAC key; the predicates load only status/state and return booleans, and the status snapshot carries no secret material. """ diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py index 44af237..5679bbf 100644 --- a/screen_locker/_phone_verification.py +++ b/screen_locker/_phone_verification.py @@ -1,4 +1,4 @@ -"""Phone workout verification: GitHub sync first, ADB/HTTP as fallback.""" +"""Phone workout verification: Firebase sync first, ADB/HTTP as fallback.""" from __future__ import annotations @@ -144,7 +144,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin): # ── HTTP fallback (no ADB / developer options required) ─────────────────── def _verify_phone_workout(self) -> tuple[str, str]: - """Verify today's workout: GitHub sync first, ADB/HTTP as fallback. + """Verify today's workout: Firebase sync first, ADB/HTTP as fallback. Returns (status, message). Status values: verified / too_short / not_verified / no_phone / sync_failed / @@ -154,7 +154,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin): if not clock_ok: return "clock_tampered", clock_msg - # GitHub sync is the primary channel — it works without the phone + # Firebase sync is the primary channel — it works without the phone # being on the same network as the PC. Only a *verified* sync result # short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the # phone's last push predates today, or a later push failed offline) @@ -174,7 +174,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin): ) elif sync_error is not None: _logger.info( - "GitHub sync unavailable (%s) — trying ADB/HTTP...", sync_error + "Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error ) # Prefer ADB when a device is visible, but if the pull yields no usable diff --git a/screen_locker/_push_outcome.py b/screen_locker/_push_outcome.py deleted file mode 100644 index 719e1e2..0000000 --- a/screen_locker/_push_outcome.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Turning a completed push into an honest result. - -Split out of :mod:`screen_locker._manual_push` to keep every file under the -250-line cap. - -A push that the primary backend refused still reaches the GitHub mirror, and -the records are not lost -- the phone reads the union of both. What must not -happen is that half-landed push logging the same line as a whole one, which is -how weeks of broken Firebase pushes looked healthy in the journal. -""" - -from __future__ import annotations - -import logging - -from screen_locker._constants import SYNC_REPO_NAME, SYNC_REPO_OWNER -from screen_locker._degraded_sources import degraded_sources - -_logger = logging.getLogger(__name__) - - -def describe_push(record_count: int) -> tuple[bool, str]: - """Report whether every backend received the records, and say so. - - Args: - record_count: How many records the push carried. - - Returns: - ``(complete, reason)`` -- ``reason`` reads like a sentence either way, - because it is what ``PushResult`` carries back to the caller. - """ - missing = [source.name for source in degraded_sources()] - if not missing: - _logger.info( - "Synced %d workout(s) to %s/%s", - record_count, - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - ) - return True, "pushed" - names = ", ".join(missing) - reason = ( - f"pushed to {SYNC_REPO_OWNER}/{SYNC_REPO_NAME} only; " - f"{names} did NOT receive these records" - ) - _logger.warning( - "Workout sync push INCOMPLETE for %d workout(s): %s — the phone reads " - "both backends, so this still syncs, but %s is broken and must be " - "fixed before it is the only path left", - record_count, - reason, - names, - ) - return False, reason diff --git a/screen_locker/_source_health.py b/screen_locker/_source_health.py index c07d1b9..3b56176 100644 --- a/screen_locker/_source_health.py +++ b/screen_locker/_source_health.py @@ -44,7 +44,7 @@ def describe_staleness( """Return a finding when *name*'s newest record is too old, else None. Args: - name: The backend, as the user would name it ("GitHub mirror"). + name: The backend, as the user would name it ("Firebase"). newest: Timestamp of the most recent record it returned, or None when it returned nothing at all. now: Injected for tests. diff --git a/screen_locker/_sync_client.py b/screen_locker/_sync_client.py index 8c6a073..cb6d295 100644 --- a/screen_locker/_sync_client.py +++ b/screen_locker/_sync_client.py @@ -1,4 +1,4 @@ -"""Building the sync client: read the token, pick Firebase or the GitHub mirror. +"""Building the sync client: Firebase only, proven live before it is returned. Split out of :mod:`screen_locker._workout_sync` to keep every file under the 250-line cap. Re-exported from there, so callers and their patch targets are @@ -6,6 +6,12 @@ unchanged. ``sync_client`` returning None means sync is OFF -- and says why in the log, because a silent None here is exactly how the PC stopped syncing for weeks. + +Firebase is the only transport since 2026-10-09. The GitHub mirror +(``kuhyx/syncs/screen-locker-sync``) is a frozen archive: nothing here reads +or writes it, and nothing deletes it. The PC used to refuse to push at all +without a GitHub PAT, even with Firebase configured -- so when the PAT was +lost, this machine silently published nothing. """ from __future__ import annotations @@ -17,24 +23,16 @@ from pathlib import Path from crdt_sync import ( CONFIG_FILE, ConfigError, - FirebaseAuthError, FirebaseConfig, FirebaseCredentials, - GitHubSyncClient, RemoteStore, RemoteSyncError, credential_store_for, firebase_client_for, - mirror_client_for, ) import requests -from screen_locker._constants import ( - SYNC_REPO_NAME, - SYNC_REPO_OWNER, - SYNC_TIMEOUT_SECONDS, - SYNC_TOKEN_FILE, -) +from screen_locker._constants import SYNC_TIMEOUT_SECONDS from screen_locker._credential_recovery import RecoveryResult, recover_session from screen_locker._degraded_sources import ( DegradedSource, @@ -53,9 +51,8 @@ __all__ = [ "DegradedSource", "clear_degraded_sources", "degraded_sources", - "read_sync_token", - "remote_client", "sync_client", + "sync_client_or_reason", "try_recover_firebase_session", ] @@ -96,27 +93,22 @@ def try_recover_firebase_session() -> RecoveryResult: ) -def read_sync_token() -> str | None: - """Return the saved sync PAT, or None if sync isn't configured. - - Unlike diet_guard's equivalent, an absent or empty token file is a - normal state here -- sync is an optional primary channel, not something - the app requires to function. - """ - if not SYNC_TOKEN_FILE.exists(): - return None - token = SYNC_TOKEN_FILE.read_text().strip() - return token or None +# What a missing or broken Firebase costs, in both directions -- named in every +# warning so the journal says what is lost, not just that something failed. +_CONSEQUENCE = ( + "this PC's workouts will NOT be pushed and phone-logged workouts will " + "NOT be pulled, so only ADB/HTTP can verify a phone workout here" +) -def _live_mirror_client(github: RemoteStore) -> tuple[RemoteStore | None, str]: - """Build the mirrored client, but only return one that actually answers. +def _live_firebase_client() -> tuple[RemoteStore | None, str]: + """Build the Firebase client, but only return one that actually answers. - Constructing is not proving. ``mirror_client_for`` only asks + Constructing is not proving. ``firebase_client_for`` only asks ``has_session()``, which reads the cached JSON off disk and never touches the network, so a credential that is *present but rejected* builds perfectly and then 401s on every single read and write. That is exactly - what happened on 2026-08-27: the recovery below was wired to construction + what happened on 2026-08-27: the recovery was wired to construction failures, the construction succeeded, and so the self-heal never fired while every operation was being refused. @@ -129,117 +121,66 @@ def _live_mirror_client(github: RemoteStore) -> tuple[RemoteStore | None, str]: ``(client, "")`` when Firebase answered, else ``(None, reason)``. """ try: - client = mirror_client_for("screen_locker", github) - except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc: + client = firebase_client_for( + "screen_locker", timeout_seconds=SYNC_TIMEOUT_SECONDS + ) + except (ConfigError, RemoteSyncError) as exc: # The reason travels back to the caller, which decides whether to heal - # or degrade -- but it is logged here too, so the originating failure + # or give up -- but it is logged here too, so the originating failure # is on the record even when a later recovery masks it. _logger.warning("Could not build the Firebase client: %s", exc) return None, str(exc) if not client.can_access_remote(): return None, ( - "the cached Firebase credential was rejected by the server — it " - "exists on disk, so nothing failed while connecting, but every " - "read and write is being refused" + "the cached Firebase credential was rejected by the server, or " + "the database is unreachable — every read and write is refused" ) return client, "" -def remote_client(github: RemoteStore) -> RemoteStore: - """Return the backend to read the phone's workout log from. - - Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a - mirror so a phone that has not moved yet is still seen; GitHub alone - otherwise. :class:`MirrorSyncClient` reads the union of both, so a workout - logged against either backend still counts. - - Not read-only: ``_manual_push.push_pc_workouts`` writes this machine's log - through the client this returns. That matters for the degraded path -- - when Firebase is dropped here the push still lands on GitHub, which the - phone also reads, so records are not lost; ``_manual_push`` reports the - push as INCOMPLETE rather than letting a half-landed push look clean. +def sync_client_or_reason() -> tuple[RemoteStore | None, str]: + """Return a live Firebase client, or None and the reason there is none. The config file is checked before constructing anything, so an - unconfigured machine never reaches the network. + unconfigured machine never reaches the network. A configured but failing + Firebase is healed from a sibling app's session when possible (waiting + for a human to copy a JSON file is what cost 2026-06-12 and 2026-08-24 -- + two workouts done, two lockouts anyway); if that fails too, the failure is + recorded as a degraded source so an empty pull is never reported as + "no workouts". - Rolling back is deleting this function and passing ``github`` straight - through: no data moves either way. + Returns: + ``(client, "")`` on success, else ``(None, reason)`` -- ``reason`` is a + sentence a caller can put straight into its own result. """ if not CONFIG_FILE.is_file(): - return github - usable, reason = _live_mirror_client(github) - if usable is not None: - return usable - # Before degrading, try to heal: a sibling app on this machine almost - # certainly holds a live refresh token for the same account. Waiting - # for a human to copy a JSON file is what cost 2026-06-12 and - # 2026-08-24 -- two workouts done, two lockouts anyway. + reason = f"no Firebase config at {CONFIG_FILE}" + _logger.warning("Sync is OFF: %s — %s", reason, _CONSEQUENCE) + return None, reason + client, reason = _live_firebase_client() + if client is not None: + return client, "" recovery = try_recover_firebase_session() if recovery.recovered: - retried, retry_reason = _live_mirror_client(github) + retried, retry_reason = _live_firebase_client() if retried is not None: _logger.info("Firebase recovered automatically: %s", recovery.reason) - return retried - _logger.warning( - "Firebase still unusable after %s: %s", recovery.reason, retry_reason - ) - _record_degraded("firebase", retry_reason) - return github + return retried, "" + reason = f"still unusable after {recovery.reason}: {retry_reason}" + else: + reason = f"{reason}; automatic recovery failed: {recovery.reason}" _logger.warning( - "Firebase unavailable, reading workouts via GitHub only: %s — the " - "phone syncs to Firebase, so a workout logged there is INVISIBLE " - "on this machine until this is fixed. Automatic recovery also " - "failed: %s", - reason, - recovery.reason, + "Firebase is configured but unusable: %s — %s", reason, _CONSEQUENCE ) - _record_degraded("firebase", f"{reason}; recovery: {recovery.reason}") - return github + _record_degraded("firebase", reason) + return None, f"Firebase unusable: {reason}" def sync_client() -> RemoteStore | None: - """Return the configured read client, or None if sync is set up nowhere. + """Return the live Firebase client, or None when sync cannot run. - A GitHub token is no longer required: Firebase has been the primary backend - since eb4ff01, so a Firebase-only machine must still sync. Previously this - module returned early whenever the PAT was missing, reporting "sync is OFF" - on a machine whose sync was working perfectly -- a false negative that hid - a live backend behind a legacy credential check. - - GitHub is used alone when only the PAT exists, Firebase alone when only - ``~/.config/crdt-sync/`` exists, and the mirrored union when both do. - ``None`` means neither is configured, which stays a benign, expected state. + ``None`` is never silent: :func:`sync_client_or_reason` has already + logged a warning naming the cause and what will not sync because of it. """ - token = read_sync_token() - github = ( - GitHubSyncClient( - SYNC_REPO_OWNER, - SYNC_REPO_NAME, - token, - timeout_seconds=SYNC_TIMEOUT_SECONDS, - ) - if token is not None - else None - ) - if github is not None: - return remote_client(github) - if not CONFIG_FILE.is_file(): - _logger.warning( - "Cannot pull synced workouts: no sync token at %s and no Firebase " - "config at %s — sync is OFF, so only ADB/HTTP can verify a phone " - "workout and phone-logged workouts will NOT count here", - SYNC_TOKEN_FILE, - CONFIG_FILE, - ) - return None - try: - return firebase_client_for("screen_locker") - except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc: - _logger.warning( - "Firebase is configured at %s but unusable, and there is no GitHub " - "token at %s to fall back to: %s — pulling NO synced workouts", - CONFIG_FILE, - SYNC_TOKEN_FILE, - exc, - ) - return None + client, _reason = sync_client_or_reason() + return client diff --git a/screen_locker/_sync_status.py b/screen_locker/_sync_status.py index a84740f..fad44b9 100644 --- a/screen_locker/_sync_status.py +++ b/screen_locker/_sync_status.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING from crdt_sync import CONFIG_FILE as FIREBASE_CONFIG_FILE -from screen_locker._constants import SYNC_STATE_FILE, SYNC_TOKEN_FILE +from screen_locker._constants import SYNC_STATE_FILE from screen_locker._device import device_identity _logger = logging.getLogger(__name__) @@ -36,9 +36,9 @@ class SyncStatus: Attributes: device_id: The uuid this machine publishes under. - backend: ``"firebase"``, ``"github"``, or ``"none"`` -- the highest - backend this machine is configured for. Firebase is primary and - GitHub is the cutover mirror, so Firebase wins when both exist. + backend: ``"firebase"`` or ``"none"`` -- whether this machine is + configured for sync at all. Firebase is the only transport since + 2026-10-09; the old GitHub mirror is a frozen archive. pushed: Whether this machine has ever completed a push. peer_count: How many other devices' logs it has merged. last_push: ISO-ish local timestamp of the last push, or None. @@ -86,26 +86,22 @@ def _read_state(path: Path) -> dict[str, object]: return parsed if isinstance(parsed, dict) else {} -def _backend(*, firebase_config: Path, token_file: Path) -> str: - """Return the highest backend this machine is configured for.""" +def _backend(*, firebase_config: Path) -> str: + """Return the backend this machine is configured for, or ``"none"``.""" if firebase_config.is_file(): return "firebase" - if token_file.is_file(): - return "github" return "none" def gather_sync_status( *, state_file: Path = SYNC_STATE_FILE, - token_file: Path = SYNC_TOKEN_FILE, firebase_config: Path = FIREBASE_CONFIG_FILE, ) -> SyncStatus: """Read this machine's sync posture from local state only. Args: state_file: The revision cache written after each push. - token_file: The GitHub PAT, present only on the mirror path. firebase_config: The shared Firebase credential. Returns: @@ -138,7 +134,7 @@ def gather_sync_status( return SyncStatus( device_id=device_identity().device_id, - backend=_backend(firebase_config=firebase_config, token_file=token_file), + backend=_backend(firebase_config=firebase_config), pushed=bool(state.get("pushed_rev")), peer_count=peer_count, last_push=last_push, diff --git a/screen_locker/_workout_sync.py b/screen_locker/_workout_sync.py index 709d35b..d05b173 100644 --- a/screen_locker/_workout_sync.py +++ b/screen_locker/_workout_sync.py @@ -1,11 +1,12 @@ -"""Pulls phone/other-device workouts via crdt-sync's GitHub transport. +"""Pulls phone/other-device workouts via crdt-sync's Firebase transport. -GitHub is used purely as dumb file storage (see ``crdt_sync``'s own docs) -- -the phone app pushes its completed-workout log to a private repo; this module +Firebase RTDB is the only backend since 2026-10-09 (the old GitHub repo is a +frozen archive nobody reads) -- the phone app pushes its completed-workout log +there; this module reads the session log and every device's manual-workout records. (The PC does push its OWN manual workouts, but via :mod:`screen_locker._manual_push`, not -here.) Sync is optional: an unconfigured token is a normal, expected state, -not an error -- unlike diet_guard, where sync is core to the app. +here.) Sync is optional: a machine without a Firebase config still locks +and verifies over ADB/HTTP -- but it says so at ``warning`` every time. """ from __future__ import annotations @@ -20,16 +21,7 @@ from crdt_sync import ( RemoteSyncError, ) -from screen_locker._constants import ( - SYNC_REPO_NAME, - SYNC_REPO_OWNER, - SYNC_TOKEN_FILE, -) -from screen_locker._sync_client import ( - read_sync_token, - remote_client, - sync_client, -) +from screen_locker._sync_client import sync_client, sync_client_or_reason from screen_locker._sync_records import ( _is_manual_payload, _is_session_payload, @@ -40,12 +32,9 @@ from screen_locker._sync_records import ( ) from screen_locker._sync_retry import with_sync_retry -# Re-exported for callers (_manual_push) and for the autouse isolate_sync_token -# fixture, which redirects SYNC_TOKEN_FILE on this module as well as on -# _sync_client -- without both, a real token on the host leaks into the tests. +# Re-exported for callers (_manual_push, scripts/tombstone_sync_record.py). __all__ = [ "CONFIG_FILE", - "SYNC_TOKEN_FILE", "_is_manual_payload", "_is_session_payload", "_manual_records", @@ -54,9 +43,8 @@ __all__ = [ "pull_all_manual_records", "pull_all_session_records", "pull_synced_workout", - "read_sync_token", - "remote_client", "sync_client", + "sync_client_or_reason", ] if TYPE_CHECKING: @@ -104,11 +92,9 @@ def _merge_device_records( ) except RemoteSyncError as exc: _logger.warning( - "Could not list device logs under %s in %s/%s: %s — pulling NO " + "Could not list device logs under %s in Firebase: %s — pulling NO " "synced %s, so phone-logged workouts will not count", _DEVICES_PREFIX, - SYNC_REPO_OWNER, - SYNC_REPO_NAME, exc, what, ) @@ -146,7 +132,7 @@ def pull_synced_workout() -> tuple[dict | None, str | None]: - Nothing configured anywhere: ``(None, None)`` -- benign, sync is optional. - Nothing pushed yet: ``(None, None)``. - - A real sync error (network, bad token, backend unreachable): + - A real sync error (network, rejected credential, backend unreachable): ``(None, )``. - Success: ``(payload_dict, None)``. """ @@ -182,13 +168,13 @@ def pull_all_session_records() -> list[tuple[str, dict]]: being open: ``pull_synced_workout`` returns only the single newest session for the live unlock check, which cannot backfill a week. - Best-effort in the same way -- an unconfigured token or a corrupt device + Best-effort in the same way -- no Firebase config or a corrupt device log yields fewer records rather than raising. """ client = sync_client() if client is None: _logger.warning( - "No sync client (neither a GitHub token nor a Firebase config) — " + "No usable Firebase sync client (see the warning above) — " "synced StrongLifts sessions cannot be pulled, so a workout done " "on another device will NOT count toward the weekly minimum.", ) @@ -204,7 +190,7 @@ def pull_all_manual_records() -> list[tuple[str, dict]]: Merges every ``devices//log.json`` under the sync prefix (phone, pc, …), keeping the highest-HLC copy of each record id — records are id-stable, so the same workout mirrored into two device logs dedups to one. - Best-effort: an unconfigured token, an unreachable repo, or a corrupt device + Best-effort: no Firebase config, an unreachable store, or a corrupt device log yields fewer/no records rather than raising — manual sync, like session sync, is optional. """ diff --git a/screen_locker/tests/_workout_sync_fixtures.py b/screen_locker/tests/_workout_sync_fixtures.py index 9c83ff6..deecec9 100644 --- a/screen_locker/tests/_workout_sync_fixtures.py +++ b/screen_locker/tests/_workout_sync_fixtures.py @@ -100,7 +100,7 @@ def _firebase_config( class ReachableClient: """A built client whose authenticated probe succeeds. - ``remote_client`` no longer trusts a client just because it constructed: + ``sync_client`` no longer trusts a client just because it constructed: it probes with ``can_access_remote`` first, because a credential that is present but server-rejected builds fine and then 401s on every operation. Fakes therefore have to answer that probe. ``identity`` carries whatever diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index c7268ec..7d8c23f 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -236,15 +236,14 @@ def _mock_weekly_logic() -> Iterator[None]: def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None: """Point the Firebase config at a path that does not exist. - ``remote_client`` reads ``crdt_sync.CONFIG_FILE`` to decide whether to - build a Firebase-primary mirror. On a developer machine that file *does* - exist, so without this the sync tests would reach the real database and - assert against live data instead of their own fakes. Tests that want the - Firebase path point it back at a file they control. + ``sync_client`` (the ONLY way push, pull and the tombstone script get a + client) reads ``_sync_client.CONFIG_FILE`` to decide whether to build the + Firebase client. On a developer machine that file *does* exist, so + without this the sync tests would reach the real database and assert + against live data. Tests wanting Firebase point it at a file they own. """ - # CONFIG_FILE is read by remote_client, which lives in _sync_client since - # _workout_sync was split. Patching the old module would leave the real - # config in play and the sync tests would assert against live data. + # Patch _sync_client's binding: crdt_sync's own CONFIG_FILE is bound to + # the real home at import, which _isolate_home cannot redirect. from screen_locker import _sync_client monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json")) diff --git a/screen_locker/tests/test_home_isolation.py b/screen_locker/tests/test_home_isolation.py index 6a1a55b..df5e562 100644 --- a/screen_locker/tests/test_home_isolation.py +++ b/screen_locker/tests/test_home_isolation.py @@ -85,7 +85,7 @@ def test_no_module_binds_a_path_inside_the_real_app_config() -> None: """Import-time bindings of ``~/.config/screen_locker`` must all be redirected. ``_isolate_home`` only reaches paths built from ``Path.home()`` at *call* - time. A module constant such as ``SYNC_TOKEN_FILE`` was bound at import, + time. A module constant such as the old ``SYNC_TOKEN_FILE`` was bound at import, before any fixture ran, and keeps the real path unless every module that holds it is patched. This scans every runtime module for such a binding, so a new one fails here instead of silently writing to the live dir. diff --git a/screen_locker/tests/test_manual_push_part2.py b/screen_locker/tests/test_manual_push_part2.py index b475751..baa7efa 100644 --- a/screen_locker/tests/test_manual_push_part2.py +++ b/screen_locker/tests/test_manual_push_part2.py @@ -10,9 +10,9 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from crdt_sync import GitHubSyncError, RepoNotFoundError +from crdt_sync import FirebaseSyncError -from screen_locker import _manual_push, _sync_client +from screen_locker import _manual_push from screen_locker._manual_push import ( PushResult, push_pc_workouts, @@ -40,23 +40,32 @@ _RUN = { class TestPushPcWorkouts: """Every outcome is reported — never a silent no-op.""" - def test_no_token_reports_why(self, tmp_path: Path) -> None: - """No token reports why.""" + def test_no_firebase_config_reports_why( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """No Firebase config: nothing pushed, the reason and count say so. + + The autouse ``_no_real_firebase_config`` fixture is exactly this + machine state, so the real ``sync_client_or_reason`` runs here. + """ log_file = tmp_path / "log.json" _write_log( log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} ) - with patch.object(_manual_push, "read_sync_token", return_value=None): + with caplog.at_level("WARNING"): result = push_pc_workouts(log_file) - assert result == PushResult(pushed=False, record_count=0, reason=result.reason) - assert "no sync token" in result.reason + assert result.pushed is False + assert result.record_count == 1 + assert result.reason.startswith("no Firebase config at ") + assert "1 workout(s) stay local" in caplog.text def test_empty_log_reports_why(self, tmp_path: Path) -> None: """Empty log reports why.""" log_file = tmp_path / "log.json" _write_log(log_file, {}) - with patch.object(_manual_push, "read_sync_token", return_value="t"): + with patch.object(_manual_push, "sync_client_or_reason") as build: result = push_pc_workouts(log_file) + build.assert_not_called() assert result.pushed is False assert result.record_count == 0 assert "no counted workouts" in result.reason @@ -68,10 +77,11 @@ class TestPushPcWorkouts: log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} ) with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), patch.object( - _manual_push, "sync_log", side_effect=GitHubSyncError("403 forbidden") + _manual_push, "sync_client_or_reason", return_value=(MagicMock(), "") + ), + patch.object( + _manual_push, "sync_log", side_effect=FirebaseSyncError("403 forbidden") ), ): result = push_pc_workouts(log_file) @@ -94,12 +104,13 @@ class TestPushPcWorkouts: log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} ) with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object( + _manual_push, "sync_client_or_reason", return_value=(MagicMock(), "") + ), patch.object( _manual_push, "sync_log", - side_effect=GitHubSyncError("network error reading x"), + side_effect=FirebaseSyncError("network error reading x"), ), caplog.at_level("WARNING"), ): @@ -108,28 +119,6 @@ class TestPushPcWorkouts: assert "network error reading x" in caplog.text assert "contents:write" not in caplog.text - def test_repo_not_found_still_points_at_the_token( - self, tmp_path: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """The permission hint survives for the error that actually implies it.""" - log_file = tmp_path / "log.json" - _write_log( - log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} - ) - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), - patch.object( - _manual_push, - "sync_log", - side_effect=RepoNotFoundError("no access"), - ), - caplog.at_level("WARNING"), - ): - result = push_pc_workouts(log_file) - assert result.pushed is False - assert "contents:write" in caplog.text - def test_successful_push_reports_count(self, tmp_path: Path) -> None: """Successful push reports count.""" log_file = tmp_path / "log.json" @@ -144,8 +133,9 @@ class TestPushPcWorkouts: ) fake_sync = MagicMock() with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object( + _manual_push, "sync_client_or_reason", return_value=(MagicMock(), "") + ), patch.object(_manual_push, "sync_log", fake_sync), ): result = push_pc_workouts(log_file) @@ -159,8 +149,9 @@ class TestPushPcWorkouts: log_file, {"2026-07-12": [_entry(_RUN, "2026-07-12T10:00:00+00:00")]} ) with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "GitHubSyncClient", MagicMock()), + patch.object( + _manual_push, "sync_client_or_reason", return_value=(MagicMock(), "") + ), patch.object(_manual_push, "sync_log", MagicMock()) as fake_sync, ): push_pc_workouts(log_file) @@ -169,57 +160,26 @@ class TestPushPcWorkouts: assert "runnerup_verified:2026-07-12" in pushed -class TestIncompletePushIsNotReportedAsClean: - """A push that reached only some backends must not look like a full one.""" +class TestPushLogsItsOutcome: + """The journal line is what says a push landed; it must name Firebase.""" - def test_a_degraded_backend_makes_the_push_incomplete( + def test_a_healthy_push_logs_the_count_and_no_warning( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Firebase missing the records must be said out loud, at warning. - - Before this, a dead Firebase produced the identical - ``INFO Synced N workout(s)`` line as a fully mirrored push, so a - half-landed push was indistinguishable from a healthy one for weeks. - """ + """A clean push says how many records went to Firebase, at INFO.""" log_file = tmp_path / "log.json" _write_log( log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} ) - _sync_client.clear_degraded_sources() - _sync_client._record_degraded("firebase", "HTTP 401") - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "remote_client", MagicMock()), + patch.object( + _manual_push, "sync_client_or_reason", return_value=(MagicMock(), "") + ), patch.object(_manual_push, "with_sync_retry", MagicMock()), - caplog.at_level("WARNING"), + caplog.at_level("INFO"), ): result = push_pc_workouts(log_file) - _sync_client.clear_degraded_sources() - assert result.pushed is True, "the records DID land on GitHub" - assert "firebase" in result.reason - assert "did NOT receive" in result.reason - assert "INCOMPLETE" in caplog.text - - def test_a_healthy_push_stays_quiet( - self, tmp_path: Path, caplog: pytest.LogCaptureFixture - ) -> None: - """The happy path must stay clean, or the warning means nothing.""" - log_file = tmp_path / "log.json" - _write_log( - log_file, {"2026-07-13": [_entry(_RUN, "2026-07-13T10:00:00+00:00")]} - ) - _sync_client.clear_degraded_sources() - - with ( - patch.object(_manual_push, "read_sync_token", return_value="t"), - patch.object(_manual_push, "remote_client", MagicMock()), - patch.object(_manual_push, "with_sync_retry", MagicMock()), - caplog.at_level("WARNING"), - ): - result = push_pc_workouts(log_file) - - assert result.pushed is True - assert result.reason == "pushed" - assert "INCOMPLETE" not in caplog.text + assert result == PushResult(pushed=True, record_count=1, reason="pushed") + assert "Synced 1 workout(s) to Firebase" in caplog.text + assert not [r for r in caplog.records if r.levelname == "WARNING"] diff --git a/screen_locker/tests/test_sync_degradation.py b/screen_locker/tests/test_sync_degradation.py index 04e69a3..116d8a9 100644 --- a/screen_locker/tests/test_sync_degradation.py +++ b/screen_locker/tests/test_sync_degradation.py @@ -2,8 +2,8 @@ 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 ``remote_client`` degraded to the -GitHub mirror -- which the phone had stopped writing to on 2026-08-15. The +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". @@ -101,11 +101,10 @@ class TestFirebaseDegradationIsVisible: message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)" raise FirebaseAuthError(message) - monkeypatch.setattr(_sync_client, "mirror_client_for", _boom) + monkeypatch.setattr(_sync_client, "firebase_client_for", _boom) _sync_client.clear_degraded_sources() - github = object() - assert _workout_sync.remote_client(github) is github + 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 @@ -117,12 +116,12 @@ class TestFirebaseDegradationIsVisible: _firebase_config(_sync_client, tmp_path, monkeypatch) monkeypatch.setattr( _sync_client, - "mirror_client_for", - lambda _app, client: ReachableClient(("mirror", client)), + "firebase_client_for", + lambda *_a, **_k: ReachableClient(), ) _sync_client.clear_degraded_sources() - _workout_sync.remote_client(object()) + _workout_sync.sync_client() assert _sync_client.degraded_sources() == [] @@ -139,24 +138,23 @@ class TestFirebaseDegradationIsVisible: _firebase_config(_sync_client, tmp_path, monkeypatch) attempts: list[int] = [] - def _dead_then_alive(_app: object, client: object) -> object: + 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(("mirror", client)) + return ReachableClient() - monkeypatch.setattr(_sync_client, "mirror_client_for", _dead_then_alive) + 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() - github = object() - recovered = _workout_sync.remote_client(github) - assert recovered.identity == ("mirror", github) + recovered = _workout_sync.sync_client() + assert isinstance(recovered, ReachableClient) assert _sync_client.degraded_sources() == [], ( "a source that recovered is not degraded" ) @@ -168,8 +166,8 @@ class TestFirebaseDegradationIsVisible: 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 returning the GitHub - mirror is exactly how 2026-08-24 stayed invisible. + 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) @@ -177,16 +175,15 @@ class TestFirebaseDegradationIsVisible: msg = "still 400 after refresh" raise FirebaseAuthError(msg) - monkeypatch.setattr(_sync_client, "mirror_client_for", _always_dead) + 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() - github = object() - assert _workout_sync.remote_client(github) is github + 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 @@ -206,7 +203,7 @@ class TestFirebaseDegradationIsVisible: msg = "HTTP 400 (INVALID_LOGIN_CREDENTIALS)" raise FirebaseAuthError(msg) - monkeypatch.setattr(_sync_client, "mirror_client_for", _boom) + monkeypatch.setattr(_sync_client, "firebase_client_for", _boom) monkeypatch.setattr( _sync_client, "try_recover_firebase_session", @@ -215,9 +212,8 @@ class TestFirebaseDegradationIsVisible: ), ) _sync_client.clear_degraded_sources() - github = object() - assert _workout_sync.remote_client(github) is github + 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 @@ -227,10 +223,10 @@ class TestFirebaseDegradationIsVisible: ) -> None: """No Firebase config is a choice, not a failure -- do not cry wolf.""" monkeypatch.setattr( - _workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json") + _sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json") ) _sync_client.clear_degraded_sources() - _workout_sync.remote_client(object()) + _workout_sync.sync_client() assert _sync_client.degraded_sources() == [] diff --git a/screen_locker/tests/test_sync_degradation_part2.py b/screen_locker/tests/test_sync_degradation_part2.py index 17f19c6..8c78543 100644 --- a/screen_locker/tests/test_sync_degradation_part2.py +++ b/screen_locker/tests/test_sync_degradation_part2.py @@ -2,7 +2,7 @@ Split from ``test_sync_degradation.py`` (250-line cap). -``mirror_client_for`` only checks ``has_session()``, which reads the cached +``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 @@ -38,7 +38,7 @@ class TestRejectedCredentialTriggersRecovery: ) -> None: """This is 2026-08-27: present on disk, rejected by the server. - ``mirror_client_for`` only checks ``has_session()``, which reads the + ``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. @@ -46,11 +46,11 @@ class TestRejectedCredentialTriggersRecovery: _firebase_config(_sync_client, tmp_path, monkeypatch) built: list[int] = [] - def _rejected_then_live(_app: object, _client: object) -> object: + def _rejected_then_live(*_args: object, **_kwargs: object) -> object: built.append(1) return RejectedClient() if len(built) == 1 else ReachableClient() - monkeypatch.setattr(_sync_client, "mirror_client_for", _rejected_then_live) + monkeypatch.setattr(_sync_client, "firebase_client_for", _rejected_then_live) monkeypatch.setattr( _sync_client, "try_recover_firebase_session", @@ -58,7 +58,7 @@ class TestRejectedCredentialTriggersRecovery: ) _sync_client.clear_degraded_sources() - client = _workout_sync.remote_client(object()) + client = _workout_sync.sync_client() assert isinstance(client, ReachableClient), ( "a rejected credential must trigger the same self-heal as a " @@ -71,14 +71,14 @@ class TestRejectedCredentialTriggersRecovery: ) -> None: """If it is still refused after healing, say so on the lock screen. - Falling back to GitHub silently is what let weeks of 401s scroll past + 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, - "mirror_client_for", - lambda _app, _client: RejectedClient(), + "firebase_client_for", + lambda *_a, **_k: RejectedClient(), ) monkeypatch.setattr( _sync_client, @@ -86,9 +86,8 @@ class TestRejectedCredentialTriggersRecovery: lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"), ) _sync_client.clear_degraded_sources() - github = object() - assert _workout_sync.remote_client(github) is github + 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 diff --git a/screen_locker/tests/test_sync_fixtures.py b/screen_locker/tests/test_sync_fixtures.py index a69080a..363a553 100644 --- a/screen_locker/tests/test_sync_fixtures.py +++ b/screen_locker/tests/test_sync_fixtures.py @@ -1,4 +1,4 @@ -"""Isolation fixture for the phone-workout GitHub sync token. +"""Autouse isolation fixtures for the phone-workout sync layer. Split out of ``conftest.py`` (already at the repo's 400-line-per-file cap) and re-exported there so pytest still picks this up as an autouse fixture @@ -7,7 +7,6 @@ for every test in this directory. from __future__ import annotations -from contextlib import ExitStack from typing import TYPE_CHECKING from unittest.mock import patch @@ -18,40 +17,6 @@ if TYPE_CHECKING: from pathlib import Path -# Every module that holds SYNC_TOKEN_FILE (~/.config/screen_locker/sync_token) -# by value, bound at import before any fixture could redirect Path.home(). -_SYNC_TOKEN_BINDINGS = ( - "_constants", - "_manual_push", - "_sync_client", - "_sync_status", - "_workout_sync", -) - - -@pytest.fixture(autouse=True) -def isolate_sync_token(tmp_path: Path) -> Iterator[None]: - """Redirect SYNC_TOKEN_FILE to tmp_path so tests never see a real token. - - Without this, any test calling ``_verify_phone_workout`` would fall - through to ``pull_synced_workout()``, which -- if a real - ``~/.config/screen_locker/sync_token`` happens to exist on the host -- - would make a real GitHub API call. Defaulting to a nonexistent tmp_path - file makes ``read_sync_token()`` return None, the same benign "sync not - configured" state every existing test already assumes. - """ - # read_sync_token moved to _sync_client when _workout_sync was split; - # _workout_sync still re-exports the constant, so both names must be - # redirected or a real token on the host leaks into the tests. The rest - # bind it by value too (only for messages today); patching every binding - # is what test_home_isolation's module scan checks. - token = tmp_path / "sync_token" - with ExitStack() as stack: - for module in _SYNC_TOKEN_BINDINGS: - stack.enter_context(patch(f"screen_locker.{module}.SYNC_TOKEN_FILE", token)) - yield - - @pytest.fixture(autouse=True) def isolate_sync_device_id(tmp_path: Path) -> Iterator[None]: """Redirect the persisted device id so tests never mint one for real. diff --git a/screen_locker/tests/test_sync_status.py b/screen_locker/tests/test_sync_status.py index d8c1e51..aef8402 100644 --- a/screen_locker/tests/test_sync_status.py +++ b/screen_locker/tests/test_sync_status.py @@ -27,13 +27,11 @@ def _gather( tmp_path: Path, *, state_file: Path | None = None, - token_file: Path | None = None, firebase_config: Path | None = None, ) -> SyncStatus: """Gather status against files under ``tmp_path`` unless overridden.""" return gather_sync_status( state_file=state_file or tmp_path / "sync_state.json", - token_file=token_file or tmp_path / "sync_token", firebase_config=firebase_config or tmp_path / "firebase.json", ) @@ -45,15 +43,14 @@ class TestBackendDetection: """No credentials is not configured.""" assert _gather(tmp_path).backend == "none" - def test_a_github_token_alone_is_the_mirror_path(self, tmp_path: Path) -> None: - """A github token alone is the mirror path.""" + def test_a_leftover_github_token_does_not_count(self, tmp_path: Path) -> None: + """A stale GitHub PAT on disk is not a backend: Firebase is the only one.""" (tmp_path / "sync_token").write_text("tok") - assert _gather(tmp_path).backend == "github" + assert _gather(tmp_path).backend == "none" - def test_firebase_wins_when_both_exist(self, tmp_path: Path) -> None: - """Firebase is primary; GitHub is only the cutover mirror.""" - (tmp_path / "sync_token").write_text("tok") + def test_firebase_config_is_the_backend(self, tmp_path: Path) -> None: + """The shared Firebase config is what makes this machine configured.""" (tmp_path / "firebase.json").write_text("{}") assert _gather(tmp_path).backend == "firebase" diff --git a/screen_locker/tests/test_workout_sync.py b/screen_locker/tests/test_workout_sync.py index dac7b15..903fc22 100644 --- a/screen_locker/tests/test_workout_sync.py +++ b/screen_locker/tests/test_workout_sync.py @@ -1,8 +1,8 @@ """Tests for the phone-workout sync pull (crdt-sync transport). -Covers the token read, the payload predicates, the per-log record extractors -and the cross-device merge. The backend selection (``sync_client`` / -``remote_client``) and the two public pulls live in ``_part2``. +Covers the payload predicates, the per-log record extractors +and the cross-device merge. The backend selection (``sync_client``) +and the two public pulls live in ``_part2``. """ from __future__ import annotations @@ -21,31 +21,9 @@ from screen_locker.tests._workout_sync_fixtures import ( _session_record_dict, ) -# The autouse `isolate_sync_token` fixture (registered repo-wide via the -# `pytest_plugins` entry for `screen_locker.tests.test_sync_fixtures` in -# pyproject.toml) already redirects `_workout_sync.SYNC_TOKEN_FILE` into a -# per-test tmp_path, so tests here can write to it directly without needing -# their own isolation fixture. The autouse `_no_real_firebase_config` fixture -# in conftest.py likewise points `CONFIG_FILE` at a nonexistent path, so tests -# that want the Firebase branch must monkeypatch it back at a file they own. - - -class TestReadSyncToken: - """ReadSyncToken.""" - - def test_returns_none_when_file_is_missing(self) -> None: - """Returns none when file is missing.""" - assert _workout_sync.read_sync_token() is None - - def test_returns_none_when_file_is_empty(self) -> None: - """Returns none when file is empty.""" - _workout_sync.SYNC_TOKEN_FILE.write_text(" \n") - assert _workout_sync.read_sync_token() is None - - def test_returns_the_stripped_token(self) -> None: - """Returns the stripped token.""" - _workout_sync.SYNC_TOKEN_FILE.write_text(" abc123 \n") - assert _workout_sync.read_sync_token() == "abc123" +# The autouse `_no_real_firebase_config` fixture in conftest.py points +# `_sync_client.CONFIG_FILE` at a nonexistent path, so tests that want the +# Firebase branch must monkeypatch it back at a file they own. class TestIsSessionPayload: diff --git a/screen_locker/tests/test_workout_sync_part2.py b/screen_locker/tests/test_workout_sync_part2.py index 5490850..b327b7f 100644 --- a/screen_locker/tests/test_workout_sync_part2.py +++ b/screen_locker/tests/test_workout_sync_part2.py @@ -18,7 +18,6 @@ import pytest from screen_locker import _sync_client, _workout_sync from screen_locker.tests._workout_sync_fixtures import ( - ReachableClient, _firebase_config, ) @@ -27,7 +26,7 @@ if TYPE_CHECKING: class TestSyncClient: - """Every configuration of (GitHub token, Firebase config), plus failure.""" + """Firebase is the only backend: configured, missing, or unusable.""" def test_returns_none_when_nothing_is_configured(self) -> None: """Returns none when nothing is configured.""" @@ -36,41 +35,36 @@ class TestSyncClient: def test_warns_when_nothing_is_configured( self, caplog: pytest.LogCaptureFixture ) -> None: - """Warns when nothing is configured.""" + """The warning names BOTH directions that stop syncing.""" with caplog.at_level("WARNING"): _workout_sync.sync_client() - assert "sync is OFF" in caplog.text + assert "Sync is OFF" in caplog.text + assert "will NOT be pushed" in caplog.text + assert "NOT be pulled" in caplog.text - def test_uses_github_alone_when_only_the_token_exists(self) -> None: - """Uses github alone when only the token exists.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - github = MagicMock() - with patch.object(_sync_client, "GitHubSyncClient", return_value=github): - assert _workout_sync.sync_client() is github + def test_reason_names_the_missing_config(self) -> None: + """The reason is a sentence a caller can put into its own result.""" + client, reason = _workout_sync.sync_client_or_reason() + assert client is None + assert reason.startswith("no Firebase config at ") - def test_mirrors_when_both_the_token_and_firebase_exist( + def test_never_builds_a_client_without_config(self) -> None: + """An unconfigured machine must not reach the network at all.""" + with patch.object(_sync_client, "firebase_client_for") as build: + _workout_sync.sync_client() + build.assert_not_called() + + def test_uses_firebase_when_the_config_exists( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Mirrors when both the token and firebase exist.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") - _firebase_config(_sync_client, tmp_path, monkeypatch) - github = MagicMock() - monkeypatch.setattr( - _sync_client, - "mirror_client_for", - lambda _app, client: ReachableClient(("mirror", client)), - ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=github): - assert _workout_sync.sync_client().identity == ("mirror", github) - - def test_uses_firebase_alone_when_only_the_config_exists( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A Firebase-only machine must still sync -- no PAT required.""" + """A configured machine syncs over Firebase -- no PAT anywhere.""" _firebase_config(_sync_client, tmp_path, monkeypatch) firebase = MagicMock() - monkeypatch.setattr(_sync_client, "firebase_client_for", lambda _app: firebase) + monkeypatch.setattr( + _sync_client, "firebase_client_for", lambda *_a, **_k: firebase + ) assert _workout_sync.sync_client() is firebase + assert _workout_sync.sync_client_or_reason() == (firebase, "") @pytest.mark.parametrize( "error", @@ -83,14 +77,16 @@ class TestSyncClient: def test_returns_none_when_firebase_only_is_unusable( self, error: Exception, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """No PAT to fall back to: report it loudly, do not raise.""" + """No fallback exists: report it loudly, do not raise.""" _firebase_config(_sync_client, tmp_path, monkeypatch) def _boom(*_args: object, **_kwargs: object) -> None: raise error monkeypatch.setattr(_sync_client, "firebase_client_for", _boom) - assert _workout_sync.sync_client() is None + client, reason = _workout_sync.sync_client_or_reason() + assert client is None + assert reason.startswith("Firebase unusable: ") def test_warns_when_firebase_only_is_unusable( self, diff --git a/screen_locker/tests/test_workout_sync_part3.py b/screen_locker/tests/test_workout_sync_part3.py deleted file mode 100644 index f68dc59..0000000 --- a/screen_locker/tests/test_workout_sync_part3.py +++ /dev/null @@ -1,65 +0,0 @@ -"""``remote_client`` mirror selection, split from ``test_workout_sync_part2``. - -Kept separate from the pulls so no workout-sync test file exceeds the repo's -400-line limit. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from crdt_sync import ConfigError - -from screen_locker import _sync_client, _workout_sync -from screen_locker.tests._workout_sync_fixtures import ( - ReachableClient, - _firebase_config, -) - -if TYPE_CHECKING: - import pytest - - -class TestRemoteClient: - """Which backend the workout pull reads from 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( - _workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json") - ) - github = object() - - assert _workout_sync.remote_client(github) is github - - def test_mirrors_to_github_when_configured( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Configured: reads union both, so either backend's workout counts.""" - _firebase_config(_sync_client, tmp_path, monkeypatch) - monkeypatch.setattr( - _sync_client, - "mirror_client_for", - lambda _app, client: ReachableClient(("mirror", client)), - ) - github = object() - - assert _workout_sync.remote_client(github).identity == ("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 pull.""" - _firebase_config(_sync_client, tmp_path, monkeypatch) - - def _boom(*_args: object, **_kwargs: object) -> None: - message = "no password" - raise ConfigError(message) - - monkeypatch.setattr(_sync_client, "mirror_client_for", _boom) - github = object() - - assert _workout_sync.remote_client(github) is github diff --git a/screen_locker/tests/test_workout_sync_part4.py b/screen_locker/tests/test_workout_sync_part4.py index 729428b..e55639c 100644 --- a/screen_locker/tests/test_workout_sync_part4.py +++ b/screen_locker/tests/test_workout_sync_part4.py @@ -1,8 +1,8 @@ """Tests for the phone-workout sync pull (crdt-sync transport). -Covers the token read, the payload predicates, the per-log record extractors -and the cross-device merge. The backend selection (``sync_client`` / -``remote_client``) and the two public pulls live in ``_part2``. +Covers the payload predicates, the per-log record extractors and the +cross-device merge. The backend selection (``sync_client``) and the two +public pulls live in ``_part2``. """ from __future__ import annotations diff --git a/screen_locker/tests/test_workout_sync_part5.py b/screen_locker/tests/test_workout_sync_part5.py index 74681b9..bb9e4d0 100644 --- a/screen_locker/tests/test_workout_sync_part5.py +++ b/screen_locker/tests/test_workout_sync_part5.py @@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch from crdt_sync import ( FirebaseSyncError, - GitHubSyncError, ) from screen_locker import _sync_client, _workout_sync @@ -36,16 +35,15 @@ class TestPullSyncedWorkout: def test_returns_none_none_when_no_token_is_configured(self) -> None: """Returns none none when no token is configured.""" - with patch.object(_sync_client, "GitHubSyncClient") as client_cls: + with patch.object(_sync_client, "firebase_client_for") as client_cls: assert _workout_sync.pull_synced_workout() == (None, None) client_cls.assert_not_called() def test_returns_the_error_message_on_a_sync_error(self) -> None: """A failed device LISTING is a real error, not "nothing to sync".""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = MagicMock() - client.list_directory.side_effect = GitHubSyncError("offline") - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + client.list_directory.side_effect = FirebaseSyncError("offline") + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_synced_workout() == (None, "offline") def test_does_not_propagate_a_firebase_sync_error(self) -> None: @@ -55,10 +53,9 @@ class TestPullSyncedWorkout: crash the locker with status=1/FAILURE. It must come back as an error tuple like any other backend failure. """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = MagicMock() client.list_directory.side_effect = FirebaseSyncError("firebase exploded") - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): data, error = _workout_sync.pull_synced_workout() assert data is None assert error == "firebase exploded" @@ -74,33 +71,32 @@ class TestPullSyncedWorkout: _firebase_config(_sync_client, tmp_path, monkeypatch) client = MagicMock() client.list_directory.side_effect = FirebaseSyncError("firebase exploded") - monkeypatch.setattr(_sync_client, "firebase_client_for", lambda _app: client) + monkeypatch.setattr( + _sync_client, "firebase_client_for", lambda *_a, **_k: client + ) data, error = _workout_sync.pull_synced_workout() assert data is None assert error == "firebase exploded" def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None: """Returns none none when nothing has been pushed yet.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = _multi_device_client({"phone": None}) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_synced_workout() == (None, None) def test_returns_none_none_when_there_are_no_devices_at_all(self) -> None: """Returns none none when there are no devices at all.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = _multi_device_client({}) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_synced_workout() == (None, None) def test_returns_the_payload_on_success(self) -> None: """Returns the payload on success.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") payload = _session_payload() client = _multi_device_client( {"phone": json.dumps({"a": _session_record_dict("session:a", payload)})} ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_synced_workout() == (payload, None) def test_finds_a_session_in_a_uuid_named_device_directory(self) -> None: @@ -109,7 +105,6 @@ class TestPullSyncedWorkout: Reading a single hardcoded ``devices/phone/log.json`` returned a stale session forever while newer ones sat unread one directory over. """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") fresh = _session_payload(note="FROM-UUID-DIR") client = _multi_device_client( { @@ -131,12 +126,11 @@ class TestPullSyncedWorkout: ), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_synced_workout() == (fresh, None) def test_returns_the_highest_clock_session_across_devices(self) -> None: """Returns the highest clock session across devices.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") newest = _session_payload(note="NEWEST") client = _multi_device_client( { @@ -163,7 +157,7 @@ class TestPullSyncedWorkout: ), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): data, error = _workout_sync.pull_synced_workout() assert error is None assert data == newest @@ -175,7 +169,6 @@ class TestPullSyncedWorkout: would hand back a record with none of the fields a caller expects -- even though here they carry the HIGHEST clocks in the log. """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") session = _session_payload(note="REAL-SESSION") client = _multi_device_client( { @@ -201,14 +194,13 @@ class TestPullSyncedWorkout: ) } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): data, error = _workout_sync.pull_synced_workout() assert error is None assert data == session def test_skips_a_corrupt_device_log_instead_of_failing(self) -> None: """A corrupt log costs that device's sessions, not the whole pull.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") payload = _session_payload() client = _multi_device_client( { @@ -216,12 +208,11 @@ class TestPullSyncedWorkout: "phone": json.dumps({"a": _session_record_dict("session:a", payload)}), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_synced_workout() == (payload, None) def test_returns_none_none_when_every_device_log_is_corrupt(self) -> None: """Returns none none when every device log is corrupt.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = _multi_device_client({"corrupt": "{not valid json"}) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_synced_workout() == (None, None) diff --git a/screen_locker/tests/test_workout_sync_part6.py b/screen_locker/tests/test_workout_sync_part6.py index b70e16b..4e6b2d9 100644 --- a/screen_locker/tests/test_workout_sync_part6.py +++ b/screen_locker/tests/test_workout_sync_part6.py @@ -10,12 +10,12 @@ import json from unittest.mock import MagicMock, patch from crdt_sync import ( - GitHubSyncError, + FirebaseSyncError, Hlc, Record, ) -from screen_locker import _sync_client, _workout_sync +from screen_locker import _workout_sync from screen_locker.tests._workout_sync_fixtures import ( _manual_payload, _manual_record_dict, @@ -32,15 +32,13 @@ class TestPullAllManualRecords: def test_returns_empty_when_listing_fails(self) -> None: """Returns empty when listing fails.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = MagicMock() - client.list_directory.side_effect = GitHubSyncError("offline") - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + client.list_directory.side_effect = FirebaseSyncError("offline") + with patch.object(_workout_sync, "sync_client", return_value=client): assert _workout_sync.pull_all_manual_records() == [] def test_merges_manual_records_across_devices(self) -> None: """Merges manual records across devices.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = _multi_device_client( { "phone": json.dumps( @@ -51,7 +49,7 @@ class TestPullAllManualRecords: ), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): result = _workout_sync.pull_all_manual_records() assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"] @@ -63,7 +61,6 @@ class TestPullAllManualRecords: the phone app look like it worked and then silently come back on the next 15-minute sync. """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") hlc = Hlc(wall_time_ms=1000, counter=0, node_id="phone") tombstoned = Record( id="manual:gone", @@ -81,7 +78,7 @@ class TestPullAllManualRecords: ), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): result = _workout_sync.pull_all_manual_records() assert [rid for rid, _ in result] == ["manual:kept"] @@ -92,7 +89,6 @@ class TestPullAllManualRecords: absent from one device's dict while the live copy won the merge from the other, so the record was ingested exactly as before. """ - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") hlc = Hlc(wall_time_ms=1000, counter=0, node_id="phone") tombstoned = Record( id="manual:dup", @@ -111,30 +107,28 @@ class TestPullAllManualRecords: ), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): result = _workout_sync.pull_all_manual_records() assert [rid for rid, _ in result] == ["manual:keep"] def test_skips_missing_and_corrupt_device_logs(self) -> None: """Skips missing and corrupt device logs.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = _multi_device_client( { "phone": json.dumps( {"a": _manual_record_dict("manual:a", _manual_payload())} ), - "gone": GitHubSyncError("404"), + "gone": FirebaseSyncError("404"), "empty": None, "corrupt": "{not json", } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): result = _workout_sync.pull_all_manual_records() assert [rid for rid, _ in result] == ["manual:a"] def test_dedups_same_id_keeping_highest_clock(self) -> None: """Dedups same ID keeping highest clock.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = _multi_device_client( { "phone": json.dumps( @@ -157,14 +151,13 @@ class TestPullAllManualRecords: ), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): result = _workout_sync.pull_all_manual_records() assert len(result) == 1 assert result[0][1]["cost"] == "NEW" def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None: """Ignores a lower clock duplicate seen later.""" - _workout_sync.SYNC_TOKEN_FILE.write_text("tok") client = _multi_device_client( { "phone": json.dumps( @@ -187,7 +180,7 @@ class TestPullAllManualRecords: ), } ) - with patch.object(_sync_client, "GitHubSyncClient", return_value=client): + with patch.object(_workout_sync, "sync_client", return_value=client): result = _workout_sync.pull_all_manual_records() assert len(result) == 1 assert result[0][1]["cost"] == "NEW" diff --git a/scripts/tombstone_sync_record.py b/scripts/tombstone_sync_record.py index b3e166a..13bb879 100755 --- a/scripts/tombstone_sync_record.py +++ b/scripts/tombstone_sync_record.py @@ -34,7 +34,6 @@ import sys from crdt_sync import ( FileSyncStateStore, - GitHubSyncClient, LogCodec, Record, RevisionTracking, @@ -42,12 +41,7 @@ from crdt_sync import ( sync_log, ) -from screen_locker._constants import ( - SYNC_REPO_NAME, - SYNC_REPO_OWNER, - SYNC_STATE_FILE, - SYNC_TIMEOUT_SECONDS, -) +from screen_locker._constants import SYNC_STATE_FILE from screen_locker._device import device_identity from screen_locker._manual_push import ( _decode_log, @@ -55,7 +49,7 @@ from screen_locker._manual_push import ( records_from_workout_log, ) from screen_locker._sync_tombstones import add_tombstone -from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client +from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason _logger = logging.getLogger(__name__) @@ -72,15 +66,10 @@ def _report(message: str) -> None: def _sync(local_log: dict[str, Record]) -> dict[str, Record]: """Run one sync tick with *local_log* as this device's view; return the merge.""" identity = device_identity() - token = read_sync_token() - if token is None: - msg = "no sync token — cannot reach the store" + client, reason = sync_client_or_reason() + if client is None: + msg = f"cannot reach the Firebase sync store: {reason}" raise SystemExit(msg) - client = remote_client( - GitHubSyncClient( - SYNC_REPO_OWNER, SYNC_REPO_NAME, token, timeout_seconds=SYNC_TIMEOUT_SECONDS - ) - ) return sync_log( SyncTarget( client=client,