mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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
This commit is contained in:
parent
2dac472331
commit
965b9c4ebb
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/<SYNC_PHONE_DEVICE_ID>/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/<device id>/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.
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, <message>)``.
|
||||
- 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/<device>/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.
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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() == []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user