wake-alarm/wake_alarm/tests/test_sync_lapsed.py

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