wake-alarm/wake_alarm/tests/test_sync_token.py
Krzysztof kuhy Rudnicki 801177972b Split _sync.py along the transport/resolution seam item 8 needs
371 -> 168. Token discovery and both backends move to _sync_transport.py;
_sync.py keeps resolution (cache, staleness, fallback), the Alarm dataclass
and the CLI.

The seam is chosen for item 8, not just for line count: that phase adds a
dismissal-record reader, which belongs beside the alarm reader in the
transport rather than inside the resolution chain.

ALARM_CACHE_FILE deliberately stays in _sync. The suite patches
`wake_alarm._sync.ALARM_CACHE_FILE` to keep tests off
~/.config/wake_alarm/alarm_cache.json — the file shutdown-wrapper.sh reads at
poweroff — and moving that binding would silently reconnect the tests to the
real one. That is the failure mode that once wrote 27 fake entries into a
live log while the suite still looked green.

The split broke 31 tests loudly, all of them patch targets that had to follow
the code: the three SYNC_* constants, the two fetchers, the patch.multiple on
the firebase names, and the reload test for the crdt_sync import guard. Every
one is repointed at _sync_transport; none was left silently patching nothing.

Verified after: 465 passed at 100% coverage, `python -m wake_alarm._sync
--show` prints, `--no-cache` still resolves the phone's real alarm through
Firebase, `_trigger --dry-run` prints, and wake_state.json is byte-identical.

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

141 lines
4.6 KiB
Python

"""Tests for _sync.py's GitHub token discovery and its sibling fallbacks.
Split out of test_sync.py to hold both files under the 250-line gate.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from wake_alarm._sync import (
_read_token,
_read_token_fallback,
)
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 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"