wake-alarm/wake_alarm/tests/test_sync.py
Krzysztof kuhy Rudnicki 95be57b575 Make the alarm fully automatic and un-weakenable (08:00, earlier-only)
The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to
PC", so it could be defeated by simply not tapping it, or by cancelling it the
night before. It is now automatic and there is no lever a half-asleep user can
pull that buys them sleep.

- AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI
  offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or
  after the baseline, and refuses to relax an active override. Earlier-only is
  the whole design: every available change costs the user sleep.
- The ratchet resets after each fire, so an early start is one-shot. A one-way
  ratchet with no reset is a trap, not a defence — the first 05:00 would
  otherwise be permanent with no way back short of reinstalling.
- load() re-validates the stored value and ignores anything at or after the
  baseline. SharedPreferences is not a trust boundary.
- No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the
  capability genuinely does not exist rather than being one invokeMethod away.
- Arming happens in main()'s init seam and via AlarmWatchdog, a daily
  self-rescheduling check that re-arms if nothing is scheduled — closing the
  gap where a force-stop or a failed re-arm goes unnoticed until the morning
  it does not ring. BootReceiver re-establishes it, since setInexactRepeating
  does not survive a reboot.
- Publishing to the PC is automatic too; it used to need the same tap.

PC half, so the two agree with the network down:
- FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a
  Firebase outage silently moved the alarm five hours LATER — the one
  direction the design refuses to allow.
- _trigger._clamp_to_baseline() refuses any resolved alarm later than the
  baseline, whatever its source. Without it the PC would faithfully reproduce
  a stale or tampered remote value and the forbidden direction stays reachable
  through sync.

Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions
are covered, per the standing rule about monotonic counters. test_sync.py and
test_trigger.py were split (payload, token, CLI, clamp) to stay under the
250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm,
which now have direct tests instead.

`python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and
still print real output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 23:03:00 +02:00

225 lines
8.0 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.SYNC_CONFIG_FILE", config_file),
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
patch("wake_alarm._sync.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):
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."""
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.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)
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)
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)
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)
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)
with patch("wake_alarm._sync.fetch_remote_alarm", return_value=(7, 0)):
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)):
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)
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