wake-alarm/wake_alarm/tests/test_sync.py
Krzysztof kuhy Rudnicki 87d2629e88
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
Fire the alarm at the phone's time via a per-minute timer
The PC is no longer shut down at night, which broke the scheduling model
outright. The wake time used to be decided once, at hibernate, and baked
into the RTC by shutdown-wrapper.sh. With no shutdown there is no resume,
so sleep-hook.sh never ran and nothing fired the alarm at all.

Worse, wake-alarm.service was WantedBy=graphical-session.target and
enabled by install.sh, while _should_run_alarm() gates on day / dismissed
/ workout-logged but has no *time* gate. On an always-on machine that
target is reached on every login, so the alarm fired at login time on any
alarm day — which is every day.

Replace both with a systemd user timer ticking once a minute:

- _sync.py reads the alarm the phone pushed to kuhyx/syncs, mirroring
  shutdown-wrapper.sh's jq parse so the two readers cannot drift. Caches
  each read, then degrades fresh cache -> fetch -> stale cache ->
  FALLBACK_ALARM_HOUR, so a network blip can never cost a wake-up.
  stdlib-only: the units run /usr/bin/python, so a dependency here is a
  production install-path hazard.
- _trigger.py fires morning-routine.service (never wake-alarm.service —
  that skips the workout lock) when local time is inside
  [alarm, alarm + TRIGGER_CATCHUP_MINUTES). The catch-up window covers a
  suspended or just-booted machine; starting an already-active unit is a
  no-op, so an alarm on screen needs no extra dedupe state.
- wake-alarm.service loses its [Install]; install.sh disables it.

A timer rather than a resident scheduler: each tick is a fresh process,
so there is no daemon to die silently and no wall-clock jump to handle
across suspend/resume. Ticks are per-minute for exact-minute firing, but
only hit the network when the cache is older than 5 minutes — ~288
GitHub reads/day, and phone changes land within ~5 minutes.

Degraded reads log at INFO, not WARNING: at one tick a minute a
persistent condition would be 1440 identical journal lines a day. The one
WARNING kept is _trigger firing on a non-phone alarm time.

Token resolution gains a fallback to the sibling apps' repo-scoped
sync_token, in both readers, so one PAT covers every app on kuhyx/syncs.

Note this surfaced that wake-alarm-sync/ does not exist in kuhyx/syncs at
all: the phone has never completed a push since the crdt_sync migration,
so the hibernate path had been silently using the +8h fallback. Fixing
that is a phone-side action (reconnect GitHub with repo scope, then one
"Set Alarm & Sync"). Alarms edited directly in the Clock app still do not
reach the sync — that needs an ACTION_NEXT_ALARM_CLOCK_CHANGED receiver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:21:17 +02:00

388 lines
14 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, Any
from unittest.mock import MagicMock, patch
import urllib.error
import pytest
from wake_alarm._sync import (
Alarm,
_extract_field,
_parse_args,
_parse_log,
_read_cache,
_read_token,
_read_token_fallback,
_write_cache,
fetch_remote_alarm,
get_alarm,
main,
)
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 TestReadToken:
"""Tests for _read_token."""
def test_missing_file(self) -> None:
"""A missing config file yields no token."""
assert _read_token() is None
def test_malformed_json(self, config_file: Path) -> None:
"""Unparsable JSON yields no token."""
config_file.write_text("{not json")
assert _read_token() is None
def test_not_a_dict(self, config_file: Path) -> None:
"""A JSON array is not a config object."""
config_file.write_text("[]")
assert _read_token() is None
@pytest.mark.parametrize("value", [None, "", 42])
def test_unusable_token_values(self, config_file: Path, value: object) -> None:
"""Absent, empty and non-string tokens are all rejected."""
config_file.write_text(json.dumps({"token": value}))
assert _read_token() is None
def test_valid_token(self, config_file: Path) -> None:
"""A non-empty string token is returned."""
config_file.write_text(json.dumps({"token": "ghp_x"}))
assert _read_token() == "ghp_x"
class TestTokenFallback:
"""Tests for reusing a sibling companion app's token file."""
def test_no_fallback_files(self) -> None:
"""With nothing on disk anywhere there is no token."""
assert _read_token_fallback() is None
assert _read_token() is None
def test_reads_raw_single_line_token(
self, fallback_files: tuple[Path, Path]
) -> None:
"""A sibling token file is a raw PAT, not JSON — and is stripped."""
fallback_files[0].write_text(" ghp_sibling\n")
assert _read_token_fallback() == "ghp_sibling"
def test_skips_empty_file(self, fallback_files: tuple[Path, Path]) -> None:
"""An empty first file falls through to the second."""
fallback_files[0].write_text(" \n")
fallback_files[1].write_text("ghp_second")
assert _read_token_fallback() == "ghp_second"
def test_skips_unreadable_file(self, fallback_files: tuple[Path, Path]) -> None:
"""An unreadable file is skipped rather than fatal."""
fallback_files[1].write_text("ghp_second")
with patch(
"pathlib.Path.read_text",
side_effect=[OSError("denied"), "ghp_second"],
):
assert _read_token_fallback() == "ghp_second"
def test_own_config_wins(
self, config_file: Path, fallback_files: tuple[Path, Path]
) -> None:
"""wake_alarm's own config takes precedence over a sibling's."""
config_file.write_text(json.dumps({"token": "ghp_own"}))
fallback_files[0].write_text("ghp_sibling")
assert _read_token() == "ghp_own"
def test_falls_back_when_config_absent(
self, fallback_files: tuple[Path, Path]
) -> None:
"""With no own config, the sibling token is used."""
fallback_files[0].write_text("ghp_sibling")
assert _read_token() == "ghp_sibling"
class TestExtractField:
"""Tests for _extract_field's tolerance of malformed logs."""
@pytest.mark.parametrize(
"log",
[
{},
{"alarm": "not a dict"},
{"alarm": {}},
{"alarm": {"fields": "not a dict"}},
{"alarm": {"fields": {}}},
{"alarm": {"fields": {"alarm_hour": "not a list"}}},
{"alarm": {"fields": {"alarm_hour": []}}},
{"alarm": {"fields": {"alarm_hour": ["7", "hlc"]}}},
{"alarm": {"fields": {"alarm_hour": [True, "hlc"]}}},
],
)
def test_malformed_shapes_yield_none(self, log: dict[str, Any]) -> None:
"""Every unexpected shape degrades to None rather than raising."""
assert _extract_field(log, "alarm_hour") is None
def test_extracts_value(self) -> None:
"""The value is element 0 of the (value, HLC) pair."""
log = {"alarm": {"fields": {"alarm_hour": [6, "hlc"]}}}
assert _extract_field(log, "alarm_hour") == 6
class TestParseLog:
"""Tests for _parse_log."""
def test_valid(self) -> None:
"""A well-formed log parses to an (hour, minute) pair."""
assert _parse_log(_log(7, 30)) == (7, 30)
def test_invalid_json(self) -> None:
"""Unparsable JSON yields None."""
assert _parse_log("{nope") is None
def test_not_a_dict(self) -> None:
"""A JSON array is not a log."""
assert _parse_log("[]") is None
def test_missing_fields(self) -> None:
"""A log without the alarm fields yields None."""
assert _parse_log(json.dumps({"alarm": {"fields": {}}})) is None
@pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)])
def test_out_of_range(self, hour: int, minute: int) -> None:
"""Values outside 0-23 / 0-59 are rejected."""
assert _parse_log(_log(hour, minute)) is None
class TestFetchRemoteAlarm:
"""Tests for fetch_remote_alarm."""
def test_no_token(self) -> None:
"""Without a token there is nothing to fetch."""
assert fetch_remote_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_remote_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_remote_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_remote_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_remote_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")
assert alarm.hour == 13
class TestCli:
"""Tests for the --show entry point."""
def test_parse_args_defaults(self) -> None:
"""Both flags default to off."""
args = _parse_args([])
assert not args.show
assert not args.no_cache
def test_main_prints_alarm(self, capsys: pytest.CaptureFixture[str]) -> None:
"""main prints the resolved alarm."""
with (
patch("sys.argv", ["_sync", "--show"]),
patch(
"wake_alarm._sync.get_alarm",
return_value=Alarm(7, 30, "remote"),
),
):
main()
assert "07:30 (remote)" in capsys.readouterr().out
def test_no_cache_forces_live_read(self) -> None:
"""--no-cache asks get_alarm for a zero-age cache."""
with (
patch("sys.argv", ["_sync", "--no-cache"]),
patch(
"wake_alarm._sync.get_alarm",
return_value=Alarm(7, 30, "remote"),
) as get,
):
main()
assert get.call_args.kwargs["max_cache_age_seconds"] == 0.0