mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:07:03 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
160 lines
5.2 KiB
Python
160 lines
5.2 KiB
Python
"""Tests for _sync.py — reading the alarm the phone pushed, with fallbacks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from wake_alarm._constants import (
|
|
FALLBACK_ALARM_HOUR,
|
|
PUBLISHED_ALARM_MAX_AGE_SECONDS,
|
|
)
|
|
from wake_alarm._sync import (
|
|
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 TestLapsedPublishedAlarm:
|
|
"""The phone must keep saying it, not just have said it once.
|
|
|
|
A publishing bug on the phone left one record stamped 2026-08-09 and the
|
|
PC honoured it for three weeks: a publish that never happens looks exactly
|
|
like one that has not changed, so the last successful publish is
|
|
authoritative forever. These pin the guard that ends that.
|
|
"""
|
|
|
|
def test_a_lapsed_remote_is_not_used(self, tmp_path: Path) -> None:
|
|
"""A record older than the max age falls through to the fallback."""
|
|
ancient = time.time() - PUBLISHED_ALARM_MAX_AGE_SECONDS - 1
|
|
with (
|
|
patch("wake_alarm._sync.ALARM_CACHE_FILE", tmp_path / "cache.json"),
|
|
patch(
|
|
"wake_alarm._sync.fetch_remote_alarm",
|
|
return_value=(7, 0, ancient),
|
|
),
|
|
):
|
|
alarm = get_alarm()
|
|
assert alarm.source == "fallback"
|
|
assert alarm.hour == FALLBACK_ALARM_HOUR
|
|
|
|
def test_a_recent_remote_is_used(self, tmp_path: Path) -> None:
|
|
"""A freshly stamped record is honoured as normal."""
|
|
with (
|
|
patch("wake_alarm._sync.ALARM_CACHE_FILE", tmp_path / "cache.json"),
|
|
patch(
|
|
"wake_alarm._sync.fetch_remote_alarm",
|
|
return_value=(7, 0, time.time()),
|
|
),
|
|
):
|
|
alarm = get_alarm()
|
|
assert (alarm.hour, alarm.minute, alarm.source) == (7, 0, "remote")
|
|
|
|
def test_an_undated_remote_is_trusted(self, tmp_path: Path) -> None:
|
|
"""No stamp means "cannot tell", which must not mean "expired".
|
|
|
|
The GitHub mirror reads a single fixed path with no per-field HLC, so
|
|
rejecting undated records would break that whole backend.
|
|
"""
|
|
with (
|
|
patch("wake_alarm._sync.ALARM_CACHE_FILE", tmp_path / "cache.json"),
|
|
patch(
|
|
"wake_alarm._sync.fetch_remote_alarm",
|
|
return_value=(7, 0, None),
|
|
),
|
|
):
|
|
alarm = get_alarm()
|
|
assert alarm.source == "remote"
|
|
|
|
def test_a_lapsed_cache_is_not_served(self, tmp_path: Path) -> None:
|
|
"""The check is on when the PHONE said it, not when we fetched.
|
|
|
|
Without this the guard is 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.
|
|
"""
|
|
cache = tmp_path / "cache.json"
|
|
cache.write_text(
|
|
json.dumps(
|
|
{
|
|
"hour": 7,
|
|
"minute": 0,
|
|
"fetched_at": time.time(),
|
|
"published_at": time.time() - PUBLISHED_ALARM_MAX_AGE_SECONDS - 1,
|
|
}
|
|
)
|
|
)
|
|
with (
|
|
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache),
|
|
patch("wake_alarm._sync.fetch_remote_alarm", return_value=None),
|
|
):
|
|
alarm = get_alarm()
|
|
assert alarm.source == "fallback"
|
|
|
|
def test_the_publish_instant_round_trips_through_the_cache(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""A fetched record's age survives being cached."""
|
|
cache = tmp_path / "cache.json"
|
|
stamped = time.time() - 60
|
|
with (
|
|
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache),
|
|
patch(
|
|
"wake_alarm._sync.fetch_remote_alarm",
|
|
return_value=(7, 0, stamped),
|
|
),
|
|
):
|
|
get_alarm()
|
|
assert json.loads(cache.read_text())["published_at"] == stamped
|