Task d25b875f: Last-good snapshot cache and staleness handling (#170)
This commit was merged in pull request #170.
This commit is contained in:
+196
-9
@@ -12,6 +12,7 @@ cycle day, and the unconfigured / failed-fetch behaviour.
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,7 +24,9 @@ from custom_components.nestquest.api_client import (
|
||||
NestQuestApiError,
|
||||
)
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -67,6 +70,23 @@ class StubSnapshotClient:
|
||||
return outcome
|
||||
|
||||
|
||||
class FakeMonotonic:
|
||||
"""A deterministic stand-in for the staleness clock.
|
||||
|
||||
The coordinator reads elapsed time through
|
||||
:func:`custom_components.nestquest.coordinator._monotonic`; tests
|
||||
patch that seam with an instance of this class and advance ``now``
|
||||
by whole seconds, so the staleness window is exercised without any
|
||||
real waiting.
|
||||
"""
|
||||
|
||||
def __init__(self, start: float = 1000.0) -> None:
|
||||
self.now = start
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
#: A fixture panel snapshot (the documented Feature 16 route shape):
|
||||
#: two children — Ada owes one, owes none of it yet, is away today with
|
||||
#: a returns date; Bo completed his only quest and is present.
|
||||
@@ -340,13 +360,20 @@ async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> N
|
||||
await async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> None:
|
||||
"""A failed fetch (transport failure or non-2xx — both typed) is a
|
||||
FAILED refresh pass: the update pass converts the typed error to
|
||||
UpdateFailed (the exception HA's coordinator machinery records as a
|
||||
failed pass, chaining the typed error as ``__cause__``), the refresh
|
||||
records the failure instead of raising, and data keeps its last
|
||||
good value."""
|
||||
async def test_coordinator_failed_fetch_marks_unavailable(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""A failed fetch (transport failure or non-2xx — both typed) PAST
|
||||
the staleness threshold is a FAILED refresh pass: the update pass
|
||||
converts the typed error to UpdateFailed (the exception HA's
|
||||
coordinator machinery records as a failed pass, chaining the typed
|
||||
error as ``__cause__``), the refresh records the failure instead of
|
||||
raising, and data keeps its last good value. (Within the threshold
|
||||
the last-good cache serves instead — the staleness tests below.)"""
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
clock = FakeMonotonic()
|
||||
monkeypatch.setattr(coordinator_module, "_monotonic", clock)
|
||||
error = NestQuestApiError(
|
||||
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
|
||||
status=503,
|
||||
@@ -359,8 +386,11 @@ async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> N
|
||||
assert coordinator.data is not None
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
# The next pass hits the scripted failure: the update pass converts
|
||||
# the typed error to UpdateFailed, with the typed error chained.
|
||||
# The next pass hits the scripted failure AFTER the cache went
|
||||
# stale — one second past the shipped 900 s default threshold: the
|
||||
# update pass converts the typed error to UpdateFailed, with the
|
||||
# typed error chained.
|
||||
clock.now += DEFAULT_SNAPSHOT_STALENESS + 1
|
||||
with pytest.raises(UpdateFailed, match="HTTP 503") as excinfo:
|
||||
await coordinator._async_update_data()
|
||||
assert isinstance(excinfo.value.__cause__, NestQuestApiError)
|
||||
@@ -376,6 +406,163 @@ async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> N
|
||||
assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError)
|
||||
|
||||
|
||||
async def test_coordinator_serves_cached_snapshot_within_staleness(
|
||||
hass, make_entry, monkeypatch, caplog
|
||||
) -> None:
|
||||
"""API down WITHIN the staleness threshold: the failed fetch serves
|
||||
the last-good cache — the pass succeeds with the SAME snapshot, so
|
||||
``last_update_success`` stays True (entities remain available) and
|
||||
the outage is logged as a clear warning, never silently."""
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
clock = FakeMonotonic()
|
||||
monkeypatch.setattr(coordinator_module, "_monotonic", clock)
|
||||
error = NestQuestApiError(
|
||||
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
|
||||
status=503,
|
||||
)
|
||||
# Setup's first refresh consumes the good snapshot; the two test
|
||||
# passes hit the scripted failure.
|
||||
client = StubSnapshotClient(FIXTURE_SNAPSHOT, error, error)
|
||||
_entry, coordinator = await _wire_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
client,
|
||||
options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900},
|
||||
)
|
||||
# The successful first pass cached the snapshot (stamped at the
|
||||
# fake clock's start).
|
||||
first = coordinator.data
|
||||
assert first is not None
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
# The API goes down 900 s after that success — exactly the
|
||||
# threshold, still WITHIN the inclusive window: the pass serves the
|
||||
# cached snapshot instead of failing.
|
||||
clock.now += 900
|
||||
with caplog.at_level(
|
||||
logging.WARNING, logger="custom_components.nestquest"
|
||||
):
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.last_update_success is True
|
||||
assert coordinator.data is first
|
||||
assert coordinator.data.children[0].child_name == "Ada"
|
||||
# The fetch WAS attempted (and failed) — the cache is a fallback,
|
||||
# not a replacement for polling.
|
||||
assert client.calls == 2
|
||||
assert any(
|
||||
"last-good" in record.getMessage() for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
async def test_coordinator_recovers_without_reload_after_outage(
|
||||
hass, make_entry, monkeypatch
|
||||
) -> None:
|
||||
"""The full outage arc: within the threshold the cache serves, past
|
||||
it the entities go unavailable, and when the API comes back the
|
||||
next pass succeeds with the FRESH snapshot, re-arms the cache, and
|
||||
the entities recover — without any config-entry reload."""
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
clock = FakeMonotonic()
|
||||
monkeypatch.setattr(coordinator_module, "_monotonic", clock)
|
||||
outage = NestQuestApiError(
|
||||
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
|
||||
status=503,
|
||||
)
|
||||
post_recovery_outage = NestQuestApiError(
|
||||
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 502",
|
||||
status=502,
|
||||
)
|
||||
fresh = {**FIXTURE_SNAPSHOT, "cycle_day": 4}
|
||||
client = StubSnapshotClient(
|
||||
FIXTURE_SNAPSHOT, outage, outage, fresh, post_recovery_outage
|
||||
)
|
||||
_entry, coordinator = await _wire_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
client,
|
||||
options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900},
|
||||
)
|
||||
first = coordinator.data
|
||||
assert first.cycle_day == 3
|
||||
|
||||
# Within the threshold: the outage serves the cache.
|
||||
clock.now += 300
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.last_update_success is True
|
||||
assert coordinator.data is first
|
||||
|
||||
# Past the threshold: the outage fails the pass (unavailable).
|
||||
clock.now += 901
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.last_update_success is False
|
||||
|
||||
# The API returns: the next pass succeeds with the fresh snapshot
|
||||
# and no reload was involved anywhere.
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.last_update_success is True
|
||||
fresh_data = coordinator.data
|
||||
assert fresh_data.cycle_day == 4
|
||||
assert hass.registry.reloaded == []
|
||||
|
||||
# The success re-armed the cache with the FRESH stamp: another
|
||||
# outage within the threshold serves the new snapshot now, not the
|
||||
# pre-outage one.
|
||||
clock.now += 300
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.last_update_success is True
|
||||
assert coordinator.data is fresh_data
|
||||
assert client.calls == 5
|
||||
|
||||
|
||||
async def test_setup_creates_coordinator_with_configured_staleness(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The staleness threshold rides the entry options into the
|
||||
coordinator, alongside the update interval."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
options={CONF_UPDATE_INTERVAL: 60, CONF_SNAPSHOT_STALENESS: 600}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
assert coordinator._staleness_seconds == 600
|
||||
|
||||
|
||||
async def test_setup_staleness_below_interval_falls_back_to_default(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A stored threshold below the update interval (a raw hand-edited
|
||||
option) never reaches the coordinator as-is: it falls back to the
|
||||
shipped default, which still spans the configured interval."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
options={CONF_UPDATE_INTERVAL: 600, CONF_SNAPSHOT_STALENESS: 60}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
assert coordinator._staleness_seconds == DEFAULT_SNAPSHOT_STALENESS
|
||||
|
||||
|
||||
async def test_coordinator_staleness_floor_never_below_interval(hass) -> None:
|
||||
"""A threshold below the interval falls back to whichever of
|
||||
default/interval is larger, so the cache can always span at least
|
||||
one failed poll (here the interval exceeds the shipped default)."""
|
||||
coordinator = NestQuestCoordinator(
|
||||
hass,
|
||||
entry_id="floor",
|
||||
api_client=StubSnapshotClient(),
|
||||
update_interval_seconds=1800,
|
||||
staleness_seconds=60,
|
||||
)
|
||||
assert coordinator._staleness_seconds == 1800
|
||||
|
||||
|
||||
async def test_failed_first_refresh_does_not_crash_setup(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
|
||||
@@ -28,6 +28,7 @@ from custom_components.nestquest.const import (
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_PANEL_TOKEN,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
@@ -35,6 +36,7 @@ from custom_components.nestquest.const import (
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
@@ -59,6 +61,7 @@ VALID_INPUT = {
|
||||
FULL_INPUT = {
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
|
||||
CONF_NOTIFY_TARGET: "",
|
||||
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
@@ -246,6 +249,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
@@ -258,6 +262,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_UPDATE_INTERVAL: 90,
|
||||
CONF_SNAPSHOT_STALENESS: 450,
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
||||
@@ -901,3 +906,112 @@ def test_options_flow_strings_cover_api_section(
|
||||
assert data.get(key), f"Missing field name for '{key}'"
|
||||
assert strings["options"]["errors"]["invalid_url"]
|
||||
assert translations_en["options"] == strings["options"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Last-good snapshot staleness section (Feature 18)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_options_flow_staleness_below_update_interval_refused() -> None:
|
||||
"""A staleness threshold shorter than the submit's update interval
|
||||
is refused with the dedicated error: such a threshold could never
|
||||
serve even one cached snapshot."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 60,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_below_stored_interval_refused() -> None:
|
||||
"""A raw submit setting the staleness but omitting the update
|
||||
interval is checked against the STORED interval, not the default."""
|
||||
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300})
|
||||
flow = _make_flow(entry)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_must_be_positive_int() -> None:
|
||||
"""Non-int, boolean, and non-positive staleness values reject with
|
||||
the plain invalid error."""
|
||||
flow = _make_flow(_make_entry())
|
||||
for bad_staleness in ("900", True, 0, -10):
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_staleness_equal_to_interval_saves() -> None:
|
||||
"""A threshold exactly as long as the update interval is valid (the
|
||||
boundary is inclusive)."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 300,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == 300
|
||||
|
||||
|
||||
def test_options_flow_staleness_defaults_saved() -> None:
|
||||
"""A bare submit saves the staleness default (three update
|
||||
intervals) alongside the other timing defaults."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
||||
|
||||
|
||||
def test_options_flow_staleness_stored_values_round_trip() -> None:
|
||||
"""A stored staleness is the form's default and a raw legacy submit
|
||||
without it keeps the stored value."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
**VALID_INPUT,
|
||||
CONF_UPDATE_INTERVAL: 300,
|
||||
CONF_SNAPSHOT_STALENESS: 1200,
|
||||
}
|
||||
)
|
||||
flow = _make_flow(entry)
|
||||
form = _run(flow.async_step_init(None))
|
||||
schema_defaults = form["data_schema"]({})
|
||||
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
|
||||
|
||||
# A raw submit carrying only the legacy keys keeps the stored
|
||||
# staleness (and never trips the interval cross-check).
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200
|
||||
|
||||
|
||||
def test_options_flow_strings_cover_staleness_section(
|
||||
strings: dict, translations_en: dict
|
||||
) -> None:
|
||||
"""Both string files name the staleness field and its error."""
|
||||
data = strings["options"]["step"]["init"]["data"]
|
||||
assert data.get(CONF_SNAPSHOT_STALENESS), (
|
||||
f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'"
|
||||
)
|
||||
assert strings["options"]["errors"]["invalid_staleness"]
|
||||
assert translations_en["options"] == strings["options"]
|
||||
|
||||
Reference in New Issue
Block a user