mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Split _sync.py along the transport/resolution seam item 8 needs
371 -> 168. Token discovery and both backends move to _sync_transport.py; _sync.py keeps resolution (cache, staleness, fallback), the Alarm dataclass and the CLI. The seam is chosen for item 8, not just for line count: that phase adds a dismissal-record reader, which belongs beside the alarm reader in the transport rather than inside the resolution chain. ALARM_CACHE_FILE deliberately stays in _sync. The suite patches `wake_alarm._sync.ALARM_CACHE_FILE` to keep tests off ~/.config/wake_alarm/alarm_cache.json — the file shutdown-wrapper.sh reads at poweroff — and moving that binding would silently reconnect the tests to the real one. That is the failure mode that once wrote 27 fake entries into a live log while the suite still looked green. The split broke 31 tests loudly, all of them patch targets that had to follow the code: the three SYNC_* constants, the two fetchers, the patch.multiple on the firebase names, and the reload test for the crdt_sync import guard. Every one is repointed at _sync_transport; none was left silently patching nothing. Verified after: 465 passed at 100% coverage, `python -m wake_alarm._sync --show` prints, `--no-cache` still resolves the phone's real alarm through Firebase, `_trigger --dry-run` prints, and wake_state.json is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
parent
64ee81f077
commit
801177972b
@ -28,43 +28,40 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from wake_alarm._alarm_payload import in_range, newest_alarm, parse_log
|
||||
from wake_alarm._alarm_payload import in_range
|
||||
from wake_alarm._constants import (
|
||||
ALARM_CACHE_FILE,
|
||||
ALARM_CACHE_MAX_AGE_SECONDS,
|
||||
FALLBACK_ALARM_HOUR,
|
||||
SYNC_ALARM_FILENAME,
|
||||
SYNC_ALARM_PATH,
|
||||
SYNC_API_BASE,
|
||||
SYNC_APP_NAME,
|
||||
SYNC_CONFIG_FILE,
|
||||
SYNC_DEVICES_PREFIX,
|
||||
SYNC_HTTP_TIMEOUT_SECONDS,
|
||||
SYNC_REPO_NAME,
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_TOKEN_FALLBACK_FILES,
|
||||
)
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
from wake_alarm._sync_transport import (
|
||||
_FIREBASE_AVAILABLE,
|
||||
_fetch_firebase_alarm,
|
||||
_fetch_github_alarm,
|
||||
_read_token,
|
||||
_read_token_fallback,
|
||||
fetch_remote_alarm,
|
||||
)
|
||||
|
||||
# Re-imported under their original names: the suite patches
|
||||
# `wake_alarm._sync._fetch_firebase_alarm` and friends, and a moved symbol
|
||||
# makes patch() stop intercepting *silently*. __all__ stops ruff deleting
|
||||
# them as unused.
|
||||
__all__ = [
|
||||
"_FIREBASE_AVAILABLE",
|
||||
"Alarm",
|
||||
"_fetch_firebase_alarm",
|
||||
"_fetch_github_alarm",
|
||||
"_read_token",
|
||||
"_read_token_fallback",
|
||||
"fetch_remote_alarm",
|
||||
"get_alarm",
|
||||
]
|
||||
|
||||
_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")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Alarm:
|
||||
@ -87,173 +84,6 @@ class Alarm:
|
||||
return f"{self.hour:02d}:{self.minute:02d} ({self.source})"
|
||||
|
||||
|
||||
def _read_token_config() -> str | None:
|
||||
"""Read the token from this package's own JSON config.
|
||||
|
||||
Returns:
|
||||
The token, or None if the file is missing, unreadable, malformed, or
|
||||
holds no usable token.
|
||||
"""
|
||||
try:
|
||||
with SYNC_CONFIG_FILE.open() as handle:
|
||||
config = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
token = config.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def _read_token_fallback() -> str | None:
|
||||
"""Read the token from a sibling app's raw single-line token file.
|
||||
|
||||
Returns:
|
||||
The first usable token found, or None if none of the fallback files
|
||||
exists or holds one.
|
||||
"""
|
||||
for path in SYNC_TOKEN_FALLBACK_FILES:
|
||||
try:
|
||||
token = path.read_text().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if token:
|
||||
_logger.debug("Using sync token from %s", path)
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def _read_token() -> str | None:
|
||||
"""Read the GitHub token, preferring this package's own config.
|
||||
|
||||
Falls back to the sibling companion apps' token files so one repo-scoped
|
||||
PAT covers every app syncing through ``kuhyx/syncs``.
|
||||
|
||||
Returns:
|
||||
The token, or None if no configured source holds one.
|
||||
"""
|
||||
# No warning on a miss here: the caller reports "no token" once. Logging
|
||||
# twice for one unconfigured file makes every tick noisy in the journal.
|
||||
return _read_token_config() or _read_token_fallback()
|
||||
|
||||
|
||||
def _fetch_firebase_alarm() -> tuple[int, int] | None:
|
||||
"""Fetch the newest alarm any device has pushed to Firebase.
|
||||
|
||||
Discovers device directories rather than reading one fixed path: the
|
||||
phone's device id is migrating from the constant "phone" to a per-install
|
||||
uuid, and a reader pinned to the old path would stop seeing the alarm the
|
||||
moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e.
|
||||
waking the user at the wrong time. Listing covers both ids, and
|
||||
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
|
||||
|
||||
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)
|
||||
published: list[tuple[str, str]] = []
|
||||
for device_id in client.list_directory(SYNC_DEVICES_PREFIX):
|
||||
raw = client.get_file_text(
|
||||
f"{SYNC_DEVICES_PREFIX}/{device_id}/{SYNC_ALARM_FILENAME}"
|
||||
)
|
||||
if raw is not None:
|
||||
published.append((device_id, raw))
|
||||
if not published:
|
||||
# No directories listed (or none held a file): still try the
|
||||
# legacy fixed path, so a phone that has not migrated is read.
|
||||
raw = client.get_file_text(SYNC_ALARM_PATH)
|
||||
if raw is None:
|
||||
_logger.info("No alarm published to Firebase yet")
|
||||
return None
|
||||
published.append(("phone", raw))
|
||||
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
|
||||
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
|
||||
return None
|
||||
|
||||
return newest_alarm(published)
|
||||
|
||||
|
||||
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
|
||||
rather than base64-wrapped JSON.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if unconfigured, unreachable, or
|
||||
malformed. Never raises — a failed read must degrade to the cache,
|
||||
not crash the trigger.
|
||||
"""
|
||||
token = _read_token()
|
||||
if token is None:
|
||||
# INFO, not WARNING: get_alarm() runs on every one-minute trigger tick,
|
||||
# so a persistent condition logged loudly here is 1440 identical
|
||||
# journal lines a day. The trigger warns once, at the moment it
|
||||
# actually fires on a degraded source — that is the actionable event.
|
||||
_logger.info("No sync token configured at %s", SYNC_CONFIG_FILE)
|
||||
return None
|
||||
|
||||
url = (
|
||||
f"{SYNC_API_BASE}/repos/{SYNC_REPO_OWNER}/{SYNC_REPO_NAME}"
|
||||
f"/contents/{SYNC_ALARM_PATH}"
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github.raw",
|
||||
},
|
||||
)
|
||||
# The URL is built from module constants, never from remote input, but
|
||||
# assert the scheme anyway: this call carries a credential, and an http://
|
||||
# or file:// target would leak it. Belt and braces for a bad edit above.
|
||||
if request.type != "https":
|
||||
_logger.error("Refusing to send the sync token over %s", request.type)
|
||||
return None
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=SYNC_HTTP_TIMEOUT_SECONDS
|
||||
) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
except (urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
|
||||
# INFO for the same reason as the no-token path: a persistent failure
|
||||
# (404 because the phone never pushed, an expired token, no network)
|
||||
# would otherwise be one WARNING per cache expiry, forever.
|
||||
_logger.info("Cannot fetch synced alarm: %s", exc)
|
||||
return 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.
|
||||
|
||||
|
||||
218
wake_alarm/_sync_transport.py
Normal file
218
wake_alarm/_sync_transport.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""Fetching the alarm the phone pushed — Firebase first, GitHub as mirror.
|
||||
|
||||
Split out of ``_sync.py`` for the 250-line cap, along the seam item 8 needs:
|
||||
this module is the **transport** (token discovery and the two backends), while
|
||||
``_sync.py`` keeps **resolution** (cache, staleness, fallback) and the CLI.
|
||||
Item 8 adds a dismissal-record reader, which belongs here beside the alarm
|
||||
reader rather than inside the resolution chain.
|
||||
|
||||
``ALARM_CACHE_FILE`` deliberately does NOT live here: the suite patches
|
||||
``wake_alarm._sync.ALARM_CACHE_FILE`` to keep tests off
|
||||
``~/.config/wake_alarm/alarm_cache.json``, which ``shutdown-wrapper.sh`` reads
|
||||
at poweroff, and moving that binding would silently reconnect the tests to the
|
||||
real file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from wake_alarm._alarm_payload import newest_alarm, parse_log
|
||||
from wake_alarm._constants import (
|
||||
SYNC_ALARM_FILENAME,
|
||||
SYNC_ALARM_PATH,
|
||||
SYNC_API_BASE,
|
||||
SYNC_APP_NAME,
|
||||
SYNC_CONFIG_FILE,
|
||||
SYNC_DEVICES_PREFIX,
|
||||
SYNC_HTTP_TIMEOUT_SECONDS,
|
||||
SYNC_REPO_NAME,
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_TOKEN_FALLBACK_FILES,
|
||||
)
|
||||
|
||||
_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")
|
||||
|
||||
|
||||
def _read_token_config() -> str | None:
|
||||
"""Read the token from this package's own JSON config.
|
||||
|
||||
Returns:
|
||||
The token, or None if the file is missing, unreadable, malformed, or
|
||||
holds no usable token.
|
||||
"""
|
||||
try:
|
||||
with SYNC_CONFIG_FILE.open() as handle:
|
||||
config = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
token = config.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def _read_token_fallback() -> str | None:
|
||||
"""Read the token from a sibling app's raw single-line token file.
|
||||
|
||||
Returns:
|
||||
The first usable token found, or None if none of the fallback files
|
||||
exists or holds one.
|
||||
"""
|
||||
for path in SYNC_TOKEN_FALLBACK_FILES:
|
||||
try:
|
||||
token = path.read_text().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if token:
|
||||
_logger.debug("Using sync token from %s", path)
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def _read_token() -> str | None:
|
||||
"""Read the GitHub token, preferring this package's own config.
|
||||
|
||||
Falls back to the sibling companion apps' token files so one repo-scoped
|
||||
PAT covers every app syncing through ``kuhyx/syncs``.
|
||||
|
||||
Returns:
|
||||
The token, or None if no configured source holds one.
|
||||
"""
|
||||
# No warning on a miss here: the caller reports "no token" once. Logging
|
||||
# twice for one unconfigured file makes every tick noisy in the journal.
|
||||
return _read_token_config() or _read_token_fallback()
|
||||
|
||||
|
||||
def _fetch_firebase_alarm() -> tuple[int, int] | None:
|
||||
"""Fetch the newest alarm any device has pushed to Firebase.
|
||||
|
||||
Discovers device directories rather than reading one fixed path: the
|
||||
phone's device id is migrating from the constant "phone" to a per-install
|
||||
uuid, and a reader pinned to the old path would stop seeing the alarm the
|
||||
moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e.
|
||||
waking the user at the wrong time. Listing covers both ids, and
|
||||
SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.
|
||||
|
||||
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)
|
||||
published: list[tuple[str, str]] = []
|
||||
for device_id in client.list_directory(SYNC_DEVICES_PREFIX):
|
||||
raw = client.get_file_text(
|
||||
f"{SYNC_DEVICES_PREFIX}/{device_id}/{SYNC_ALARM_FILENAME}"
|
||||
)
|
||||
if raw is not None:
|
||||
published.append((device_id, raw))
|
||||
if not published:
|
||||
# No directories listed (or none held a file): still try the
|
||||
# legacy fixed path, so a phone that has not migrated is read.
|
||||
raw = client.get_file_text(SYNC_ALARM_PATH)
|
||||
if raw is None:
|
||||
_logger.info("No alarm published to Firebase yet")
|
||||
return None
|
||||
published.append(("phone", raw))
|
||||
except (ConfigError, FirebaseAuthError, RemoteSyncError, OSError) as exc:
|
||||
_logger.info("Cannot fetch the alarm from Firebase: %s", exc)
|
||||
return None
|
||||
|
||||
return newest_alarm(published)
|
||||
|
||||
|
||||
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
|
||||
rather than base64-wrapped JSON.
|
||||
|
||||
Returns:
|
||||
The (hour, minute) pair, or None if unconfigured, unreachable, or
|
||||
malformed. Never raises — a failed read must degrade to the cache,
|
||||
not crash the trigger.
|
||||
"""
|
||||
token = _read_token()
|
||||
if token is None:
|
||||
# INFO, not WARNING: get_alarm() runs on every one-minute trigger tick,
|
||||
# so a persistent condition logged loudly here is 1440 identical
|
||||
# journal lines a day. The trigger warns once, at the moment it
|
||||
# actually fires on a degraded source — that is the actionable event.
|
||||
_logger.info("No sync token configured at %s", SYNC_CONFIG_FILE)
|
||||
return None
|
||||
|
||||
url = (
|
||||
f"{SYNC_API_BASE}/repos/{SYNC_REPO_OWNER}/{SYNC_REPO_NAME}"
|
||||
f"/contents/{SYNC_ALARM_PATH}"
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github.raw",
|
||||
},
|
||||
)
|
||||
# The URL is built from module constants, never from remote input, but
|
||||
# assert the scheme anyway: this call carries a credential, and an http://
|
||||
# or file:// target would leak it. Belt and braces for a bad edit above.
|
||||
if request.type != "https":
|
||||
_logger.error("Refusing to send the sync token over %s", request.type)
|
||||
return None
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=SYNC_HTTP_TIMEOUT_SECONDS
|
||||
) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
except (urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
|
||||
# INFO for the same reason as the no-token path: a persistent failure
|
||||
# (404 because the phone never pushed, an expired token, no network)
|
||||
# would otherwise be one WARNING per cache expiry, forever.
|
||||
_logger.info("Cannot fetch synced alarm: %s", exc)
|
||||
return 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()
|
||||
@ -61,9 +61,9 @@ def _patch_paths(
|
||||
) -> None:
|
||||
"""Redirect every on-disk path into tmp_path for every test."""
|
||||
with (
|
||||
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
|
||||
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@ -60,9 +60,9 @@ def _patch_paths(
|
||||
) -> None:
|
||||
"""Redirect every on-disk path into tmp_path for every test."""
|
||||
with (
|
||||
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
|
||||
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
):
|
||||
yield
|
||||
|
||||
@ -111,7 +111,7 @@ class TestFetchRemoteAlarm:
|
||||
"""The token is never sent over a plaintext or file:// URL."""
|
||||
self._with_token(config_file)
|
||||
with (
|
||||
patch("wake_alarm._sync.SYNC_API_BASE", "http://api.github.com"),
|
||||
patch("wake_alarm._sync_transport.SYNC_API_BASE", "http://api.github.com"),
|
||||
patch("urllib.request.urlopen") as urlopen,
|
||||
):
|
||||
assert _fetch_github_alarm() is None
|
||||
|
||||
@ -64,7 +64,7 @@ class TestFetchFirebaseAlarm:
|
||||
config = MagicMock()
|
||||
config.is_file.return_value = configured
|
||||
return patch.multiple(
|
||||
"wake_alarm._sync",
|
||||
"wake_alarm._sync_transport",
|
||||
_FIREBASE_AVAILABLE=True,
|
||||
FIREBASE_CONFIG_FILE=config,
|
||||
firebase_client_for=MagicMock(
|
||||
@ -77,7 +77,7 @@ class TestFetchFirebaseAlarm:
|
||||
|
||||
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):
|
||||
with patch("wake_alarm._sync_transport._FIREBASE_AVAILABLE", new=False):
|
||||
assert _fetch_firebase_alarm() is None
|
||||
|
||||
def test_unconfigured_never_touches_the_network(self) -> None:
|
||||
@ -183,7 +183,7 @@ class TestOptionalCrdtSyncImport:
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
from wake_alarm import _sync
|
||||
from wake_alarm import _sync_transport
|
||||
|
||||
for name in (
|
||||
"crdt_sync",
|
||||
@ -193,11 +193,14 @@ class TestOptionalCrdtSyncImport:
|
||||
):
|
||||
monkeypatch.setitem(sys.modules, name, None)
|
||||
try:
|
||||
reloaded = importlib.reload(_sync)
|
||||
reloaded = importlib.reload(_sync_transport)
|
||||
assert reloaded._FIREBASE_AVAILABLE is False
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
importlib.reload(_sync)
|
||||
# Restore the real module for every later test: a reload left in
|
||||
# the import-failed state would silently disable Firebase for the
|
||||
# rest of the session.
|
||||
importlib.reload(_sync_transport)
|
||||
|
||||
|
||||
class TestFetchRemoteAlarmDispatch:
|
||||
@ -206,8 +209,10 @@ class TestFetchRemoteAlarmDispatch:
|
||||
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,
|
||||
patch(
|
||||
"wake_alarm._sync_transport._fetch_firebase_alarm", return_value=(6, 15)
|
||||
),
|
||||
patch("wake_alarm._sync_transport._fetch_github_alarm") as github,
|
||||
):
|
||||
assert fetch_remote_alarm() == (6, 15)
|
||||
github.assert_not_called()
|
||||
@ -215,15 +220,21 @@ class TestFetchRemoteAlarmDispatch:
|
||||
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)),
|
||||
patch(
|
||||
"wake_alarm._sync_transport._fetch_firebase_alarm", return_value=None
|
||||
),
|
||||
patch(
|
||||
"wake_alarm._sync_transport._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),
|
||||
patch(
|
||||
"wake_alarm._sync_transport._fetch_firebase_alarm", return_value=None
|
||||
),
|
||||
patch("wake_alarm._sync_transport._fetch_github_alarm", return_value=None),
|
||||
):
|
||||
assert fetch_remote_alarm() is None
|
||||
|
||||
@ -61,9 +61,9 @@ def _patch_paths(
|
||||
) -> None:
|
||||
"""Redirect every on-disk path into tmp_path for every test."""
|
||||
with (
|
||||
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
|
||||
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@ -58,9 +58,9 @@ def _patch_paths(
|
||||
) -> None:
|
||||
"""Redirect every on-disk path into tmp_path for every test."""
|
||||
with (
|
||||
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
|
||||
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
|
||||
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user