diff --git a/CLAUDE.md b/CLAUDE.md index 2adf522..29ccd6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,9 +44,18 @@ the same synced alarm, and sets `rtcwake`. `sleep-hook.sh` (in If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are not wired together, by design (the shell wrapper has no Python runtime -available at the point it intercepts `shutdown`). The same duplication applies -to the token search order: `SYNC_CONFIG_FILE` / `SYNC_TOKEN_FALLBACK_FILES` in -`_constants.py` are mirrored by `read_sync_token()` in the wrapper. +available at the point it intercepts `shutdown`). + +**The wrapper no longer talks to the network or handles a token.** It reads +`~/.config/wake_alarm/alarm_cache.json` — the cache `_sync.py` already +refreshes every minute — and falls back to `WAKE_AFTER_HOURS` if that file is +absent, malformed, or more than 2 days old. This removed the duplicated token +search order (`read_sync_token()` used to mirror `SYNC_CONFIG_FILE` / +`SYNC_TOKEN_FALLBACK_FILES`), and it is what makes the wrapper survive the +move to Firebase unchanged: an ID token expires in about an hour, and a shell +script at shutdown time has no safe way to refresh one. The only coupling left +is the cache's `{"hour","minute","fetched_at"}` shape — keep the reader in the +wrapper in step with `_write_cache()` in `_sync.py`. ## `wake-alarm.service` must never auto-start @@ -63,20 +72,36 @@ auto-start. ## Reading the phone's alarm -`_sync.py` is the Python reader; `shutdown-wrapper.sh` is a second, jq-based -reader of the **same file**. Keep their parse and token order in step. +`_sync.py` is the only reader of the remote alarm. `shutdown-wrapper.sh` reads +`_sync.py`'s local cache instead of the remote file, so there is no second +remote reader to keep in step. -- Source of truth: `wake-alarm-sync/devices/phone/alarm.json` in the private - `kuhyx/syncs` repo, written by `phone_app/` as a `crdt_sync` log. The value - lives at `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. -- Token: `~/.config/wake_alarm/sync.json` key `token`, else a sibling app's - raw `~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope — - the repo is private, and a token without access returns **404, not 403**. -- `_sync` is **stdlib-only** (`urllib.request`) on purpose: the service runs - `/usr/bin/python`, so every dependency is a production install-path hazard. +- Source of truth: `wake-alarm-sync/devices/phone/alarm.json`, written by + `phone_app/` as a `crdt_sync` log. The value lives at + `.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path + on both backends** — Firebase's namespace mirrors the repo layout — so only + the transport differs, never the parse. +- **Firebase first, GitHub as the cutover mirror** (`fetch_remote_alarm()` is + just `_fetch_firebase_alarm() or _fetch_github_alarm()`). Retiring the + mirror is deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`. +- Firebase config: the shared `~/.config/crdt-sync/firebase.json`; the + refresh token is cached per app under `SYNC_APP_NAME` (`wake_alarm`), so + this app's credential is separate from its siblings'. The config file is + checked **before** anything is constructed, so an unconfigured machine + never touches the network. +- GitHub token (fallback path only): `~/.config/wake_alarm/sync.json` key + `token`, else a sibling app's raw + `~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope — the + repo is private, and a token without access returns **404, not 403**. +- `crdt_sync` is imported at module scope but **guarded by + `try/except ImportError`**, exactly like `python-kasa` in `_smart_plug.py`: + the service runs `/usr/bin/python`, not a venv, so a dependency that failed + to install must degrade the read to the GitHub path rather than crash the + per-minute trigger. The GitHub path stays stdlib-only for that reason. + Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the dev venv. - Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale cache → `FALLBACK_ALARM_HOUR`. The cache - (`~/.config/wake_alarm/alarm_cache.json`) is what bounds GitHub reads to + (`~/.config/wake_alarm/alarm_cache.json`) is what bounds remote reads to ~288/day despite the per-minute tick, and what keeps a network blip from costing a wake-up. - **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute, @@ -177,8 +202,10 @@ Note the dev venv lacks `gatelock`, so anything importing `_state` (including ## Do NOT - Don't add a dependency without doing the production install-path check - above. `_sync.py` is stdlib-only for exactly this reason — don't "improve" - it to `requests`. + above. `_sync.py`'s **GitHub** path is stdlib-only for exactly this reason — + don't "improve" it to `requests`, and don't make the `crdt_sync` import + unconditional: the `try/except ImportError` guard is what keeps a failed + install from killing the trigger. - Don't forget the orchestrator when changing this package's module path or invocation — see "The morning_routine orchestrator lives elsewhere" above. - Don't give `wake-alarm.service` an `[Install]` section or enable it. It diff --git a/install.sh b/install.sh index 9fc10a3..7dba087 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,20 @@ set -euo pipefail +# Refuse to run under sudo. This script must run as the logged-in user: it +# installs *user* systemd units and calls `sudo` itself for the few root-owned +# pieces. Under sudo, XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are stripped, +# so `systemctl --user` fails at step 3 and every later step -- including the +# shutdown wrapper at step 6 -- is silently skipped while the earlier pip output +# still looks like success. It also runs pip as root, which leaves root-owned +# files in ~/.cache/pip. Failing loudly here beats a half-finished install. +if [[ -n "${SUDO_USER:-}" || $EUID -eq 0 ]]; then + echo "Error: do not run this script with sudo." >&2 + echo "Run it as your normal user: bash $0" >&2 + echo "It will prompt for sudo itself where root is actually needed." >&2 + exit 1 +fi + # Split declare/assign so the command-substitution exit code is not masked (SC2155). SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_DIR diff --git a/pyproject.toml b/pyproject.toml index 2354ce2..f562a2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,10 @@ description = "Phone-driven wake alarm with fan ramp and smart-plug control" requires-python = ">=3.10" dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock", + # Firebase transport for the alarm read. _sync.py imports it lazily and + # falls back to its stdlib GitHub path if the import fails, so a broken + # install degrades the read instead of killing the per-minute trigger. + "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync", "python-kasa>=0.7", ] diff --git a/requirements.txt b/requirements.txt index 2c49b98..af25619 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,13 @@ # Wake Alarm — runtime + development dependencies -# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock and -# python-kasa below (python-kasa is optional at runtime; the module degrades -# gracefully if it's missing, but is required here for mypy/pylint/tests). +# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock, +# crdt-sync and python-kasa below. crdt-sync and python-kasa are both +# optional at runtime — their modules degrade gracefully if missing (the +# alarm read falls back to GitHub, smart-plug control disables itself) — but +# both are required here for mypy/pylint/tests. bandit>=1.7.0 codespell>=2.2.0 coverage>=7.4.0 +crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock mypy>=1.8.0 pre-commit>=3.6.0 diff --git a/shutdown-wrapper.sh b/shutdown-wrapper.sh index 093975f..bf61263 100755 --- a/shutdown-wrapper.sh +++ b/shutdown-wrapper.sh @@ -12,12 +12,25 @@ # minute on the always-on machine and fires at the phone's alarm time without # any rtcwake involvement (see wake_alarm/_trigger.py). # -# Wake time is taken from the alarm the phone app pushes to the private -# kuhyx/syncs repo via the shared crdt_sync transport (see phone_app/). Falls -# back to WAKE_AFTER_HOURS if sync is not configured or unreachable. Token: -# ~/.config/wake_alarm/sync.json key "token", else a sibling app's -# ~/.config/{diet_guard,screen_locker}/sync_token. Needs "repo" scope, since -# the Contents API must read a private repo. +# Wake time is read from ~/.config/wake_alarm/alarm_cache.json, the on-disk +# cache that wake-alarm-trigger.timer refreshes every minute from the alarm +# the phone app pushes. Falls back to WAKE_AFTER_HOURS if that cache is +# missing or unusable. +# +# Deliberately a *local file read*, with no network call and no credentials. +# This script runs as /usr/local/bin/shutdown, at the one moment the machine +# is going down; a remote fetch there can block on a dying network, and under +# Firebase it could not work at all, because an ID token lasts about an hour +# and refreshing one is beyond what a shell script should be doing with a +# secret. The Python side already holds the credentials and already keeps this +# cache fresh, so the backend it syncs against is now invisible here: this +# wrapper needs no changes when GitHub is retired. +# +# The trade-off is bounded and acceptable: a cache older than the trigger's +# refresh interval still reflects the last alarm the phone published, and the +# alarm changes far more rarely than once a minute. MAX_CACHE_AGE_SECONDS +# below only rejects a cache so old it suggests the trigger has been dead for +# days, in which case the fixed fallback is the safer answer. set -euo pipefail @@ -36,66 +49,42 @@ for arg in "$@"; do esac done -# Resolve the GitHub token, preferring wake_alarm's own JSON config and falling -# back to the sibling companion apps' raw single-line token files (one -# repo-scoped PAT covers every app syncing through kuhyx/syncs). -# Mirrors SYNC_CONFIG_FILE / SYNC_TOKEN_FALLBACK_FILES in -# wake_alarm/_constants.py — keep the two readers in step. -read_sync_token() { - local cfg="$HOME/.config/wake_alarm/sync.json" - local token fallback +ALARM_CACHE="$HOME/.config/wake_alarm/alarm_cache.json" +# Reject a cache this old as evidence the trigger itself has stopped running. +# Generous on purpose (2 days): the alarm rarely changes, so a merely stale +# cache is still a far better answer than the fixed +8h fallback. Mirrors +# ALARM_CACHE_FILE in wake_alarm/_constants.py. +MAX_CACHE_AGE_SECONDS=$(( 2 * 24 * 3600 )) - if [[ -f "$cfg" ]]; then - token=$(jq -r '.token // empty' "$cfg" 2>/dev/null) || token="" - if [[ -n "$token" ]]; then - printf '%s' "$token" - return 0 - fi - fi - - for fallback in \ - "$HOME/.config/diet_guard/sync_token" \ - "$HOME/.config/screen_locker/sync_token"; do - [[ -f "$fallback" ]] || continue - token=$(tr -d '[:space:]' < "$fallback") || continue - if [[ -n "$token" ]]; then - printf '%s' "$token" - return 0 - fi - done - - return 1 -} - -# Read the alarm time the phone pushed to kuhyx/syncs via crdt_sync. -# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M. -# Exits non-zero if sync is not configured, unreachable, or malformed. +# Read the alarm the phone published, from the local cache the trigger keeps +# fresh. Returns the Unix epoch (seconds) of the next occurrence of that H:M. +# Exits non-zero if the cache is absent, malformed, or older than +# MAX_CACHE_AGE_SECONDS, so the caller can fall back. # -# The phone is the sole writer, under the fixed device id "phone", so a single -# Contents-API read of its alarm.json is enough (no device discovery / HLC -# ordering needed here — that would only matter with multiple writers). The -# stored payload is a crdt_sync Log: {"alarm": {"fields": {"alarm_hour": -# [H, ""], "alarm_minute": [M, ""]}, ...}}. The raw media type makes -# the Contents API return that JSON directly, so no base64 decode is needed. +# The cache is written by wake_alarm/_sync.py as +# {"hour": H, "minute": M, "fetched_at": } — keep this reader in +# step with _write_cache() there. Storing H/M rather than the raw crdt_sync log +# means this script no longer needs to understand the sync payload format at +# all, which is what makes it backend-agnostic. get_synced_alarm_epoch() { - local token log_json hour minute target_epoch now_epoch - token=$(read_sync_token) || return 1 + local hour minute fetched_at now_epoch age target_epoch - local url="https://api.github.com/repos/kuhyx/syncs/contents" - url="$url/wake-alarm-sync/devices/phone/alarm.json" - log_json=$(curl -sf --max-time 10 \ - -H "Authorization: token $token" \ - -H "Accept: application/vnd.github.raw" \ - "$url") || return 1 - [[ -n "$log_json" ]] || return 1 + [[ -f "$ALARM_CACHE" ]] || return 1 + + # One jq call for all three fields: -e fails on null, and the fixed field + # order lets a single read destructure them. + read -r hour minute fetched_at < <( + jq -er '"\(.hour) \(.minute) \(.fetched_at)"' "$ALARM_CACHE" 2>/dev/null + ) || return 1 - hour=$(printf '%s' "$log_json" \ - | jq -r '.alarm.fields.alarm_hour[0]' 2>/dev/null) || return 1 - minute=$(printf '%s' "$log_json" \ - | jq -r '.alarm.fields.alarm_minute[0]' 2>/dev/null) || return 1 [[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1 + (( hour <= 23 && minute <= 59 )) || return 1 printf -v now_epoch '%(%s)T' -1 + # fetched_at is a float; strip the fraction rather than pulling in bc. + age=$(( now_epoch - ${fetched_at%%.*} )) + (( age >= 0 && age <= MAX_CACHE_AGE_SECONDS )) || return 1 + target_epoch=$(date -d "today $hour:$minute" +%s) if (( target_epoch <= now_epoch )); then target_epoch=$(date -d "tomorrow $hour:$minute" +%s) @@ -109,11 +98,11 @@ case "$tomorrow_dow" in 1|2|3|4|5|6|7) if wake_epoch=$(get_synced_alarm_epoch 2>/dev/null); then logger -t shutdown-wrapper \ - "crdt_sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)" + "alarm cache: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)" else wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 )) logger -t shutdown-wrapper \ - "sync unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)" + "alarm cache unusable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)" fi sudo "$RTCWAKE" -m no -t "$wake_epoch" exec /usr/bin/systemctl hibernate diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index 77cb06f..f038554 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -92,13 +92,22 @@ SYNC_TOKEN_FALLBACK_FILES: tuple[Path, ...] = ( # Where a successful read is cached, so a network blip can't cost a wake-up. ALARM_CACHE_FILE: Path = Path.home() / ".config" / "wake_alarm" / "alarm_cache.json" -# The shared private repo every companion app syncs through, and the phone's -# path within it. The phone is the sole writer under a fixed device id, so one -# read of its alarm.json is enough — no device discovery or HLC ordering here. +# The phone's path to its published alarm. Deliberately the *same* logical +# path on both backends — Firebase's namespace mirrors the repo layout — so +# only the transport changes, never the parse. The phone is the sole writer +# under a fixed device id, so one read of its alarm.json is enough: no device +# discovery or HLC ordering here. +SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json" + +# Names this app's credential cache under ~/.local/share/crdt-sync/, keeping +# its Firebase refresh token separate from the sibling apps'. +SYNC_APP_NAME: str = "wake_alarm" + +# The shared private repo, used only by the GitHub fallback path. Retiring the +# mirror means deleting these three and _fetch_github_alarm() together. SYNC_API_BASE: str = "https://api.github.com" SYNC_REPO_OWNER: str = "kuhyx" SYNC_REPO_NAME: str = "syncs" -SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json" # Valid ranges for a wall-clock alarm, used to reject a malformed sync payload # or a corrupted cache before it can become a fire time. diff --git a/wake_alarm/_sync.py b/wake_alarm/_sync.py index d7dfe15..80659e3 100644 --- a/wake_alarm/_sync.py +++ b/wake_alarm/_sync.py @@ -5,14 +5,19 @@ in a ``crdt_sync`` ``LogStore`` and pushes that log to a fixed path in the private ``kuhyx/syncs`` repo. This module is the PC-side reader used by the per-minute trigger (see ``_trigger.py``). -``shutdown-wrapper.sh`` reads the **same file** with ``jq`` for the hibernate -fallback path. The parse here mirrors it exactly (``.alarm.fields.alarm_hour`` -and ``.alarm.fields.alarm_minute``, first tuple element) so the two readers -cannot drift. +``shutdown-wrapper.sh`` no longer reads the remote file at all — it reads the +local cache this module writes, so there is only one remote reader to keep in +step (see ``CLAUDE.md``). -Deliberately stdlib-only (``urllib.request`` + ``json``): the alarm runs under -``/usr/bin/python``, not a venv, so every added dependency is a production -install-path hazard (see ``CLAUDE.md``). A read is not worth that risk. +**Backends.** Firebase is preferred and GitHub is the cutover fallback, the +same order every other app in the fleet uses. Firebase needs ``crdt_sync`` +(hence ``requests``), which is a real dependency declared in +``pyproject.toml``; the GitHub path stays stdlib-only (``urllib.request`` + +``json``) so that if the import is somehow missing on the production +interpreter, the alarm still resolves rather than crashing the trigger. That +is why the import below is guarded rather than top-level: this module runs +under ``/usr/bin/python``, not a venv, and a wake-up is not worth an +ImportError. """ from __future__ import annotations @@ -37,6 +42,7 @@ from wake_alarm._constants import ( MIN_MINUTE, SYNC_ALARM_PATH, SYNC_API_BASE, + SYNC_APP_NAME, SYNC_CONFIG_FILE, SYNC_HTTP_TIMEOUT_SECONDS, SYNC_REPO_NAME, @@ -47,6 +53,20 @@ from wake_alarm._logging_setup import configure_logging _logger = logging.getLogger(__name__) +# Optional at runtime, exactly like python-kasa in _smart_plug.py: the trigger +# runs every minute under /usr/bin/python, so a dependency that failed to +# install must degrade this read to the GitHub path, not raise. +try: + from crdt_sync import ConfigError, firebase_client_for + from crdt_sync._config import CONFIG_FILE as FIREBASE_CONFIG_FILE + from crdt_sync._firebase_auth import FirebaseAuthError + from crdt_sync._remote import RemoteSyncError + + _FIREBASE_AVAILABLE = True +except ImportError: + _FIREBASE_AVAILABLE = False + _logger.info("crdt_sync is not installed; reading the alarm from GitHub") + # Keys inside the crdt_sync log the phone writes. _RECORD_ID = "alarm" _HOUR_FIELD = "alarm_hour" @@ -199,7 +219,39 @@ def _parse_log(raw: str) -> tuple[int, int] | None: return hour, minute -def fetch_remote_alarm() -> tuple[int, int] | None: +def _fetch_firebase_alarm() -> tuple[int, int] | None: + """Fetch the alarm the phone last pushed to Firebase. + + Returns None — never raises — for every failure mode, including + ``crdt_sync`` not being importable on this interpreter, so the caller can + fall back to GitHub and then to the cache. + + Returns: + The (hour, minute) pair, or None if unconfigured or unreadable. + """ + if not _FIREBASE_AVAILABLE: + return None + + # Checked before constructing anything, so an unconfigured machine never + # touches the network (and a socket-blocking test suite stays quiet). + if not FIREBASE_CONFIG_FILE.is_file(): + _logger.info("Firebase not configured at %s", FIREBASE_CONFIG_FILE) + return None + + try: + client = firebase_client_for(SYNC_APP_NAME) + raw = client.get_file_text(SYNC_ALARM_PATH) + except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc: + _logger.info("Cannot fetch the alarm from Firebase: %s", exc) + return None + + if raw is None: + _logger.info("No alarm published to Firebase yet") + return None + return _parse_log(raw) + + +def _fetch_github_alarm() -> tuple[int, int] | None: """Fetch the alarm the phone last pushed to GitHub. Uses the raw media type so the Contents API returns the file body directly @@ -251,6 +303,19 @@ def fetch_remote_alarm() -> tuple[int, int] | None: return _parse_log(raw) +def fetch_remote_alarm() -> tuple[int, int] | None: + """Fetch the alarm the phone last pushed, Firebase first. + + GitHub is tried only when Firebase yields nothing, which covers both the + not-yet-migrated machine and a Firebase outage. Once every device has + moved, deleting the GitHub branch is the whole retirement. + + Returns: + The (hour, minute) pair, or None if no backend has a usable answer. + """ + return _fetch_firebase_alarm() or _fetch_github_alarm() + + def _read_cache() -> tuple[int, int, float] | None: """Read the cached alarm. diff --git a/wake_alarm/tests/test_sync.py b/wake_alarm/tests/test_sync.py index 1b8f525..b538ef1 100644 --- a/wake_alarm/tests/test_sync.py +++ b/wake_alarm/tests/test_sync.py @@ -12,13 +12,13 @@ import pytest from wake_alarm._sync import ( Alarm, _extract_field, + _fetch_github_alarm, _parse_args, _parse_log, _read_cache, _read_token, _read_token_fallback, _write_cache, - fetch_remote_alarm, get_alarm, main, ) @@ -208,11 +208,11 @@ class TestParseLog: class TestFetchRemoteAlarm: - """Tests for fetch_remote_alarm.""" + """Tests for the GitHub backend behind fetch_remote_alarm.""" def test_no_token(self) -> None: """Without a token there is nothing to fetch.""" - assert fetch_remote_alarm() is None + assert _fetch_github_alarm() is None def _with_token(self, config_file: Path) -> None: config_file.write_text(json.dumps({"token": "ghp_x"})) @@ -224,7 +224,7 @@ class TestFetchRemoteAlarm: response.read.return_value = _log(6, 15).encode() response.__enter__.return_value = response with patch("urllib.request.urlopen", return_value=response): - assert fetch_remote_alarm() == (6, 15) + assert _fetch_github_alarm() == (6, 15) def test_sends_raw_media_type_and_auth(self, config_file: Path) -> None: """The request carries the token and the raw Accept header.""" @@ -233,7 +233,7 @@ class TestFetchRemoteAlarm: response.read.return_value = _log().encode() response.__enter__.return_value = response with patch("urllib.request.urlopen", return_value=response) as urlopen: - fetch_remote_alarm() + _fetch_github_alarm() request = urlopen.call_args[0][0] assert request.get_header("Authorization") == "token ghp_x" assert request.get_header("Accept") == "application/vnd.github.raw" @@ -246,7 +246,7 @@ class TestFetchRemoteAlarm: patch("wake_alarm._sync.SYNC_API_BASE", "http://api.github.com"), patch("urllib.request.urlopen") as urlopen, ): - assert fetch_remote_alarm() is None + assert _fetch_github_alarm() is None urlopen.assert_not_called() @pytest.mark.parametrize( @@ -263,7 +263,7 @@ class TestFetchRemoteAlarm: """Every transport failure degrades to None, never raises.""" self._with_token(config_file) with patch("urllib.request.urlopen", side_effect=exc): - assert fetch_remote_alarm() is None + assert _fetch_github_alarm() is None class TestCache: diff --git a/wake_alarm/tests/test_sync_backends.py b/wake_alarm/tests/test_sync_backends.py new file mode 100644 index 0000000..171919d --- /dev/null +++ b/wake_alarm/tests/test_sync_backends.py @@ -0,0 +1,171 @@ +"""Tests for the Firebase/GitHub backend selection in _sync.py. + +Split out of ``test_sync.py`` to keep both files under the repo's 500-line +cap; the parse, cache and CLI tests stay there. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from wake_alarm._constants import SYNC_ALARM_PATH +from wake_alarm._sync import _fetch_firebase_alarm, fetch_remote_alarm + +if TYPE_CHECKING: + from contextlib import AbstractContextManager + + +class _FakeConfigError(Exception): + """Stand-in for crdt_sync.ConfigError, which is optional at runtime.""" + + +class _FakeAuthError(Exception): + """Stand-in for crdt_sync's FirebaseAuthError.""" + + +class _FakeRemoteError(Exception): + """Stand-in for crdt_sync's RemoteSyncError.""" + + +def _log(hour: object = 7, minute: object = 30) -> str: + """Build a crdt_sync log payload shaped like the phone's.""" + return json.dumps( + { + "alarm": { + "fields": { + "alarm_hour": [hour, "hlc-1"], + "alarm_minute": [minute, "hlc-1"], + } + } + } + ) + + +class TestFetchFirebaseAlarm: + """Tests for the Firebase backend behind fetch_remote_alarm.""" + + def _patched( + self, *, client: MagicMock | None = None, configured: bool = True + ) -> AbstractContextManager[None]: + """Patch the module-level crdt_sync bindings for one test. + + crdt_sync is an optional dependency imported at module scope, so the + tests drive its bound names directly rather than requiring it to be + installed -- the same contract production degrades against. + """ + config = MagicMock() + config.is_file.return_value = configured + return patch.multiple( + "wake_alarm._sync", + _FIREBASE_AVAILABLE=True, + FIREBASE_CONFIG_FILE=config, + firebase_client_for=MagicMock( + return_value=client if client is not None else MagicMock() + ), + ConfigError=_FakeConfigError, + FirebaseAuthError=_FakeAuthError, + RemoteSyncError=_FakeRemoteError, + ) + + def test_missing_dependency_yields_none(self) -> None: + """A machine without crdt_sync falls back instead of raising.""" + with patch("wake_alarm._sync._FIREBASE_AVAILABLE", new=False): + assert _fetch_firebase_alarm() is None + + def test_unconfigured_never_touches_the_network(self) -> None: + """With no config file, no client is ever constructed.""" + client = MagicMock() + with self._patched(client=client, configured=False): + assert _fetch_firebase_alarm() is None + client.get_file_text.assert_not_called() + + def test_success(self) -> None: + """A published alarm parses to its (hour, minute) pair.""" + client = MagicMock() + client.get_file_text.return_value = _log(6, 15) + with self._patched(client=client): + assert _fetch_firebase_alarm() == (6, 15) + client.get_file_text.assert_called_once_with(SYNC_ALARM_PATH) + + def test_absent_value_yields_none(self) -> None: + """Nothing published yet is a miss, not a crash.""" + client = MagicMock() + client.get_file_text.return_value = None + with self._patched(client=client): + assert _fetch_firebase_alarm() is None + + @pytest.mark.parametrize( + "exc", + [ + _FakeConfigError("bad config"), + _FakeAuthError("rejected"), + _FakeRemoteError("database down"), + OSError("no route to host"), + ], + ) + def test_failures_yield_none(self, exc: Exception) -> None: + """Every Firebase failure degrades to None, never raises.""" + client = MagicMock() + client.get_file_text.side_effect = exc + with self._patched(client=client): + assert _fetch_firebase_alarm() is None + + +class TestOptionalCrdtSyncImport: + """Cover the ImportError branch of the optional ``crdt_sync`` import.""" + + def test_module_sets_firebase_unavailable_when_import_fails( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Reloading with crdt_sync blocked sets _FIREBASE_AVAILABLE=False.""" + import importlib + import sys + + from wake_alarm import _sync + + for name in ( + "crdt_sync", + "crdt_sync._config", + "crdt_sync._firebase_auth", + "crdt_sync._remote", + ): + monkeypatch.setitem(sys.modules, name, None) + try: + reloaded = importlib.reload(_sync) + assert reloaded._FIREBASE_AVAILABLE is False + finally: + monkeypatch.undo() + importlib.reload(_sync) + + +class TestFetchRemoteAlarmDispatch: + """Tests for the Firebase-first, GitHub-fallback ordering.""" + + def test_prefers_firebase(self) -> None: + """When Firebase answers, GitHub is never asked.""" + with ( + patch("wake_alarm._sync._fetch_firebase_alarm", return_value=(6, 15)), + patch("wake_alarm._sync._fetch_github_alarm") as github, + ): + assert fetch_remote_alarm() == (6, 15) + github.assert_not_called() + + def test_falls_back_to_github(self) -> None: + """A Firebase miss still resolves through the mirror.""" + with ( + patch("wake_alarm._sync._fetch_firebase_alarm", return_value=None), + patch("wake_alarm._sync._fetch_github_alarm", return_value=(7, 30)), + ): + assert fetch_remote_alarm() == (7, 30) + + def test_both_missing_yields_none(self) -> None: + """With neither backend usable the caller degrades to the cache.""" + with ( + patch("wake_alarm._sync._fetch_firebase_alarm", return_value=None), + patch("wake_alarm._sync._fetch_github_alarm", return_value=None), + ): + assert fetch_remote_alarm() is None