Read the phone's alarm from Firebase, keeping GitHub as the mirror
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

The seventh and last app on the GitHub-repo transport. fetch_remote_alarm()
is now `_fetch_firebase_alarm() or _fetch_github_alarm()`, so retiring the
mirror later is deleting one branch and three constants.

Two things made this look harder than it was. The first was the standing
"_sync.py is stdlib-only" rule: crdt_sync pulls in requests, and the service
runs /usr/bin/python rather than a venv. But the package already depends on
gatelock and python-kasa, and _smart_plug.py already has the pattern for
exactly this -- a module-scope import inside try/except ImportError. So the
Firebase import is guarded the same way: a dependency that failed to install
degrades the read to GitHub instead of killing the per-minute trigger. The
GitHub path stays stdlib-only, which is what makes that fallback trustworthy.

The second was the shutdown wrapper, which used to do its own authenticated
GitHub read. It could not have worked against Firebase -- an ID token lasts
about an hour and a shell script at shutdown time has no safe way to refresh
one. It now reads the local alarm cache the trigger already keeps fresh
(deployed to /usr/local/bin/shutdown), so the backend is invisible to it.
install.sh also now refuses to run under sudo, which silently skipped its
later steps.

Verified against the live database, not just in tests: the phone's alarm is
already published at wake-alarm-sync/devices/phone/alarm.json, and Firebase
and GitHub both resolve it to 07:45. `_sync --show --no-cache` reports
"07:45 (remote)" and the trigger dry-run behaves unchanged.

338 tests at 100% branch coverage, no new lint suppressions. The backend
tests moved to test_sync_backends.py to keep both files under the 500-line
cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
This commit is contained in:
Krzysztof Rudnicki 2026-08-05 09:09:22 +02:00
parent 864fea5022
commit 66bc3a9e55
9 changed files with 380 additions and 98 deletions

View File

@ -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

View File

@ -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

View File

@ -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",
]

View File

@ -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

View File

@ -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, "<hlc>"], "alarm_minute": [M, "<hlc>"]}, ...}}. 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": <unix seconds>} — 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

View File

@ -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.

View File

@ -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.

View File

@ -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:

View File

@ -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