mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
|