fix(garmin): startup tokenstore resume recovers after its 10s timeout

The background login thread now flips _auth_state to authenticated
itself when a slow tokenstore resume succeeds after the 10s wait --
previously its result landed in an abandoned private queue and the
docstring's promised late recovery never happened, leaving every
subsequent call failing until an explicit authenticate. Guarded to only
ever transition from unauthenticated, so an explicit authenticate/MFA
flow that took over meanwhile is never clobbered; the timeout branch no
longer re-writes the state (a redundant write that could race a success
at the boundary). Structured-log messages reworked alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:17:24 +02:00
parent 0e6cf00dba
commit 78c494affb
2 changed files with 98 additions and 22 deletions

View File

@@ -1,4 +1,5 @@
import os
import time
import queue
import threading
from unittest.mock import MagicMock, patch
@@ -302,3 +303,51 @@ def test_call_does_not_mark_other_errors_as_not_found():
assert resp["id"] == 12
assert resp["error"] == "rate limited"
assert "not_found" not in resp
def test_startup_login_late_success_flips_auth_state_for_later_calls():
"""A tokenstore resume that outlives the 10s wait must still recover
the subprocess: the background thread itself flips _auth_state on
success, so the next `call` command passes the auth check without an
explicit authenticate."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
# Simulate the 10s timeout: the reader gives up immediately,
# while the (still-blocked) login thread keeps running.
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
release.set() # the slow login now completes, after the timeout
for _ in range(100):
if wrapper._auth_state == "authenticated":
break
time.sleep(0.01)
assert wrapper._auth_state == "authenticated"
def test_startup_login_late_success_never_overrides_explicit_auth_flow():
"""If an explicit authenticate/MFA flow moved the state while the
startup resume was still in flight, the late success must not clobber
it -- the explicit flow owns the state once it has moved it."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
wrapper._auth_state = "mfa_pending" # an explicit flow took over meanwhile
release.set()
time.sleep(0.2) # give the thread ample time to (wrongly) flip it
assert wrapper._auth_state == "mfa_pending"