wake-alarm/wake_alarm/tests/test_sync_backends.py
Krzysztof kuhy Rudnicki 801177972b 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
2026-08-27 23:44:24 +02:00

241 lines
8.9 KiB
Python

"""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_FILENAME,
SYNC_ALARM_PATH,
SYNC_DEVICES_PREFIX,
)
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, hlc: str = "hlc-1") -> str:
"""Build a crdt_sync log payload shaped like the phone's."""
return json.dumps(
{
"alarm": {
"fields": {
"alarm_hour": [hour, hlc],
"alarm_minute": [minute, hlc],
}
}
}
)
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_transport",
_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_transport._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.list_directory.return_value = []
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.list_directory.return_value = []
client.get_file_text.return_value = None
with self._patched(client=client):
assert _fetch_firebase_alarm() is None
def test_reads_a_discovered_device_directory(self) -> None:
"""A phone publishing under a uuid is found by listing, not by path."""
client = MagicMock()
client.list_directory.return_value = ["7f3c-uuid"]
client.get_file_text.return_value = _log(6, 45)
with self._patched(client=client):
assert _fetch_firebase_alarm() == (6, 45)
client.get_file_text.assert_called_once_with(
f"{SYNC_DEVICES_PREFIX}/7f3c-uuid/{SYNC_ALARM_FILENAME}"
)
def test_newest_device_wins_across_several(self) -> None:
"""The alarm set most recently wins, whatever order devices list in.
This is the migration case: the phone's old "phone" directory still
holds a stale alarm while its new uuid directory holds the current
one. Ranking by HLC rather than listing order is what stops the PC
waking at the abandoned time.
"""
client = MagicMock()
client.list_directory.return_value = ["new-uuid", "phone"]
client.get_file_text.side_effect = lambda path: {
f"{SYNC_DEVICES_PREFIX}/new-uuid/{SYNC_ALARM_FILENAME}": _log(
5, 30, hlc="2026-08-09T10:00:00.000Z-0000-new-uuid"
),
f"{SYNC_DEVICES_PREFIX}/phone/{SYNC_ALARM_FILENAME}": _log(
9, 0, hlc="2026-07-01T10:00:00.000Z-0000-phone"
),
}[path]
with self._patched(client=client):
assert _fetch_firebase_alarm() == (5, 30)
def test_falls_back_to_the_legacy_path_when_nothing_lists(self) -> None:
"""A backend that lists nothing still reads the un-migrated phone."""
client = MagicMock()
client.list_directory.return_value = ["phone"]
client.get_file_text.side_effect = [None, _log(8, 5)]
with self._patched(client=client):
assert _fetch_firebase_alarm() == (8, 5)
assert client.get_file_text.call_args_list[-1].args[0] == SYNC_ALARM_PATH
def test_a_device_publishing_garbage_does_not_win(self) -> None:
"""One unparsable device must not shadow a healthy one."""
client = MagicMock()
client.list_directory.return_value = ["broken", "good"]
client.get_file_text.side_effect = lambda path: {
f"{SYNC_DEVICES_PREFIX}/broken/{SYNC_ALARM_FILENAME}": "{not json",
f"{SYNC_DEVICES_PREFIX}/good/{SYNC_ALARM_FILENAME}": _log(7, 20),
}[path]
with self._patched(client=client):
assert _fetch_firebase_alarm() == (7, 20)
@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_transport
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_transport)
assert reloaded._FIREBASE_AVAILABLE is False
finally:
monkeypatch.undo()
# 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:
"""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_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()
def test_falls_back_to_github(self) -> None:
"""A Firebase miss still resolves through the mirror."""
with (
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_transport._fetch_firebase_alarm", return_value=None
),
patch("wake_alarm._sync_transport._fetch_github_alarm", return_value=None),
):
assert fetch_remote_alarm() is None