mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:27:32 +01:00
Belt-and-braces for the publishing race fixed in the previous commit. That bug is gone, but the shape of the failure is worth closing on this side too: a publish that never happens is indistinguishable from one that has not changed, so the last successful publish stayed authoritative forever. One record stamped 2026-08-09 had the PC starting the morning routine at 07:00 for three weeks. get_alarm now skips any record the phone published longer ago than PUBLISHED_ALARM_MAX_AGE_SECONDS (14 days) and falls back. That is safe precisely because FALLBACK_ALARM_HOUR is the phone's own baseline, so a lapsed record degrades to the right answer rather than to a guess -- and an override is one-shot anyway, so reverting to the baseline is what the phone would say. The check is on when the PHONE stamped it, never on when we fetched, and that distinction is the whole design. Checking the fetch would be trivially defeated: the cache is rewritten from the same stale record every few minutes, so `fetched_at` is always fresh no matter how old the record behind it is. So the winning HLC's instant is now carried out of _alarm_payload, through _sync_transport, and persisted in the cache as `published_at`. Nothing else reads that cache any more -- the shutdown wrapper that used to went with the PC's waking role -- so widening its shape is free. An undated record is trusted rather than expired. The GitHub mirror reads a single fixed path with no per-field HLC, so treating "cannot tell how old this is" as "too old" would break that whole backend, and refusing an alarm we merely failed to date is a worse failure than honouring it. test_sync.py and test_sync_backends.py crossed the 250-line cap, so the new lapse cases and the backend-ordering cases move to test_sync_lapsed.py and test_sync_dispatch.py. 168 tests at 100% branch coverage. Verified against the real backend: the cache now carries published_at, and `_sync --show` still resolves 08:00 from the record the phone stamped minutes earlier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
238 lines
8.6 KiB
Python
238 lines
8.6 KiB
Python
"""Tests for _sync.py — reading the alarm the phone pushed, with fallbacks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
import urllib.error
|
|
|
|
import pytest
|
|
|
|
from wake_alarm._constants import (
|
|
FALLBACK_ALARM_HOUR,
|
|
)
|
|
from wake_alarm._sync import (
|
|
Alarm,
|
|
_fetch_github_alarm,
|
|
_read_cache,
|
|
_write_cache,
|
|
get_alarm,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
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"],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def config_file(tmp_path: Path) -> Path:
|
|
"""Provide a temporary sync config path."""
|
|
return tmp_path / "sync.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def cache_file(tmp_path: Path) -> Path:
|
|
"""Provide a temporary alarm cache path."""
|
|
return tmp_path / "cache" / "alarm_cache.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
|
|
"""Provide two temporary sibling-app token paths."""
|
|
return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_paths(
|
|
config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
|
|
) -> None:
|
|
"""Redirect every on-disk path into tmp_path for every test."""
|
|
with (
|
|
patch("wake_alarm._sync_transport.SYNC_CONFIG_FILE", config_file),
|
|
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
|
|
patch("wake_alarm._sync_transport.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
|
):
|
|
yield
|
|
|
|
|
|
class TestAlarm:
|
|
"""Tests for the Alarm value object."""
|
|
|
|
def test_str_pads_and_names_source(self) -> None:
|
|
"""Render zero-padded HH:MM with the source in parentheses."""
|
|
assert str(Alarm(7, 5, "remote")) == "07:05 (remote)"
|
|
|
|
|
|
class TestFetchRemoteAlarm:
|
|
"""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_github_alarm() is None
|
|
|
|
def _with_token(self, config_file: Path) -> None:
|
|
config_file.write_text(json.dumps({"token": "ghp_x"}))
|
|
|
|
def test_success(self, config_file: Path) -> None:
|
|
"""A 200 with a valid body yields the pair."""
|
|
self._with_token(config_file)
|
|
response = MagicMock()
|
|
response.read.return_value = _log(6, 15).encode()
|
|
response.__enter__.return_value = response
|
|
with patch("urllib.request.urlopen", return_value=response):
|
|
fetched = _fetch_github_alarm()
|
|
assert fetched is not None
|
|
assert fetched[:2] == (6, 15)
|
|
|
|
def test_unparseable_body_is_not_an_alarm(self, config_file: Path) -> None:
|
|
"""A 200 whose body is not a usable log yields nothing, not a crash."""
|
|
self._with_token(config_file)
|
|
response = MagicMock()
|
|
response.read.return_value = b"{}"
|
|
response.__enter__.return_value = response
|
|
with patch("urllib.request.urlopen", return_value=response):
|
|
assert _fetch_github_alarm() is None
|
|
|
|
def test_sends_raw_media_type_and_auth(self, config_file: Path) -> None:
|
|
"""The request carries the token and the raw Accept header."""
|
|
self._with_token(config_file)
|
|
response = MagicMock()
|
|
response.read.return_value = _log().encode()
|
|
response.__enter__.return_value = response
|
|
with patch("urllib.request.urlopen", return_value=response) as urlopen:
|
|
_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"
|
|
assert "kuhyx/syncs" in request.full_url
|
|
|
|
def test_refuses_a_non_https_scheme(self, config_file: Path) -> None:
|
|
"""The token is never sent over a plaintext or file:// URL."""
|
|
self._with_token(config_file)
|
|
with (
|
|
patch("wake_alarm._sync_transport.SYNC_API_BASE", "http://api.github.com"),
|
|
patch("urllib.request.urlopen") as urlopen,
|
|
):
|
|
assert _fetch_github_alarm() is None
|
|
urlopen.assert_not_called()
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
urllib.error.URLError("down"),
|
|
OSError("timeout"),
|
|
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "bad"),
|
|
],
|
|
)
|
|
def test_network_failures_yield_none(
|
|
self, config_file: Path, exc: Exception
|
|
) -> None:
|
|
"""Every transport failure degrades to None, never raises."""
|
|
self._with_token(config_file)
|
|
with patch("urllib.request.urlopen", side_effect=exc):
|
|
assert _fetch_github_alarm() is None
|
|
|
|
|
|
class TestCache:
|
|
"""Tests for the on-disk cache helpers."""
|
|
|
|
def test_roundtrip(self, cache_file: Path) -> None:
|
|
"""A written cache reads back with the same values."""
|
|
_write_cache(6, 45, None)
|
|
cached = _read_cache()
|
|
assert cached is not None
|
|
assert cached[0] == 6
|
|
assert cached[1] == 45
|
|
|
|
def test_write_creates_parent_dir(self, cache_file: Path) -> None:
|
|
"""The cache directory is created on demand."""
|
|
_write_cache(6, 45, None)
|
|
assert cache_file.exists()
|
|
|
|
def test_write_failure_is_swallowed(self) -> None:
|
|
"""An unwritable cache logs but does not raise."""
|
|
with patch("pathlib.Path.mkdir", side_effect=OSError("read-only")):
|
|
_write_cache(6, 45, None)
|
|
|
|
def test_missing_file(self) -> None:
|
|
"""No cache file yields None."""
|
|
assert _read_cache() is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[
|
|
"{not json",
|
|
"[]",
|
|
json.dumps({"hour": "6", "minute": 45, "fetched_at": 1.0}),
|
|
json.dumps({"hour": 6, "minute": "45", "fetched_at": 1.0}),
|
|
json.dumps({"hour": 6, "minute": 45, "fetched_at": "soon"}),
|
|
json.dumps({"hour": 24, "minute": 45, "fetched_at": 1.0}),
|
|
json.dumps({"hour": 6, "minute": 60, "fetched_at": 1.0}),
|
|
],
|
|
)
|
|
def test_malformed_cache_yields_none(self, cache_file: Path, content: str) -> None:
|
|
"""Any unexpected cache shape is treated as no cache."""
|
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
cache_file.write_text(content)
|
|
assert _read_cache() is None
|
|
|
|
|
|
class TestGetAlarm:
|
|
"""Tests for the get_alarm resolution chain."""
|
|
|
|
def test_fresh_cache_skips_network(self, cache_file: Path) -> None:
|
|
"""A young cache entry is served without any fetch."""
|
|
_write_cache(6, 45, None)
|
|
with patch("wake_alarm._sync.fetch_remote_alarm") as fetch:
|
|
alarm = get_alarm()
|
|
fetch.assert_not_called()
|
|
assert (alarm.hour, alarm.minute, alarm.source) == (6, 45, "cache")
|
|
|
|
def test_stale_cache_triggers_fetch(self, cache_file: Path) -> None:
|
|
"""An old cache entry is refreshed from the network."""
|
|
_write_cache(6, 45, None)
|
|
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=(7, 0, None)):
|
|
alarm = get_alarm(max_cache_age_seconds=0)
|
|
assert (alarm.hour, alarm.minute, alarm.source) == (7, 0, "remote")
|
|
|
|
def test_fetch_result_is_cached(self, cache_file: Path) -> None:
|
|
"""A successful fetch is written to the cache for next time."""
|
|
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=(7, 0, None)):
|
|
get_alarm()
|
|
assert json.loads(cache_file.read_text())["hour"] == 7
|
|
|
|
def test_falls_back_to_stale_cache(self, cache_file: Path) -> None:
|
|
"""When the fetch fails, an old cache entry is still used."""
|
|
_write_cache(6, 45, None)
|
|
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
|
|
alarm = get_alarm(max_cache_age_seconds=0)
|
|
assert (alarm.hour, alarm.minute, alarm.source) == (
|
|
6,
|
|
45,
|
|
"stale-cache",
|
|
)
|
|
|
|
def test_falls_back_to_constant(self) -> None:
|
|
"""With no cache and no network, the fixed fallback hour is used."""
|
|
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
|
|
alarm = get_alarm()
|
|
assert (alarm.minute, alarm.source) == (0, "fallback")
|
|
# Bound to the constant, not a literal: the fallback deliberately
|
|
# matches the phone's baked-in baseline, so hardcoding the number here
|
|
# would fail every time that shared value moves.
|
|
assert alarm.hour == FALLBACK_ALARM_HOUR
|