feat: fetch MyWhoosh activities and FIT files

This commit is contained in:
Bastian Wagner
2026-08-15 14:52:03 +02:00
parent 01d2266c8e
commit 21074a35da
2 changed files with 263 additions and 1 deletions

View File

@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone
import httpx import httpx
from app.mywhoosh.models import MyWhooshToken from app.mywhoosh.models import MyWhooshActivity, MyWhooshToken
from app.mywhoosh.tokenstore import MyWhooshTokenStore from app.mywhoosh.tokenstore import MyWhooshTokenStore
LOGIN_URL = "https://services.mywhoosh.com/http-service/api/login" LOGIN_URL = "https://services.mywhoosh.com/http-service/api/login"
@@ -67,3 +68,102 @@ class MyWhooshClient:
async def ensure_authenticated(self, email: str, password: str) -> None: async def ensure_authenticated(self, email: str, password: str) -> None:
if self.token is None: if self.token is None:
await self.login(email, password) await self.login(email, password)
async def _authenticated_post(self, url: str, payload: dict, email: str, password: str) -> httpx.Response:
await self.ensure_authenticated(email, password)
for attempt in range(2):
assert self.token is not None
try:
response = await self.http.post(
url,
json=payload,
headers={"Authorization": f"Bearer {self.token.access_token}"},
)
except httpx.TransportError as exc:
raise MyWhooshTransientError("MyWhoosh request failed") from exc
if response.status_code not in {401, 403}:
if response.status_code >= 500:
raise MyWhooshTransientError(f"MyWhoosh returned HTTP {response.status_code}")
return response
if attempt == 0:
self.token_store.clear()
self.token = None
await self.login(email, password)
continue
raise MyWhooshAuthError("MyWhoosh session rejected after reauthentication")
raise AssertionError("unreachable")
async def list_activities(self, email: str, password: str) -> list[MyWhooshActivity]:
activities: list[MyWhooshActivity] = []
page = 1
total_pages = 1
while page <= total_pages:
response = await self._authenticated_post(
ACTIVITIES_BASE + "rider/profile/activities",
{"sortDate": "DESC", "page": page},
email,
password,
)
if response.status_code >= 400:
raise MyWhooshIntegrationError(f"MyWhoosh activities returned HTTP {response.status_code}")
try:
body = response.json()
except ValueError as exc:
raise MyWhooshIntegrationError("MyWhoosh activities returned invalid JSON") from exc
try:
data = body["data"]
total_pages = int(data["totalPages"])
results = data["results"]
except (KeyError, TypeError, ValueError) as exc:
raise MyWhooshIntegrationError("MyWhoosh activities response has unexpected shape") from exc
if not isinstance(results, list):
raise MyWhooshIntegrationError("MyWhoosh activities response has unexpected shape")
for row in results:
activities.append(self._normalize_activity(row))
page += 1
return activities
def _normalize_activity(self, row: object) -> MyWhooshActivity:
if not isinstance(row, dict):
raise MyWhooshIntegrationError("MyWhoosh activity row is not an object")
activity_id = row.get("id")
activity_file_id = row.get("activityFileId")
if not activity_id or not activity_file_id:
raise MyWhooshIntegrationError("MyWhoosh activity row missing stable id or activityFileId")
raw_started = row.get("startDatetime")
started_at: datetime | None = None
if raw_started:
try:
started_at = datetime.fromisoformat(str(raw_started).replace("Z", "+00:00")).astimezone(
timezone.utc
)
except ValueError as exc:
raise MyWhooshIntegrationError("MyWhoosh activity row has invalid startDatetime") from exc
return MyWhooshActivity(
id=str(activity_id),
title=str(row.get("title") or ""),
activity_file_id=str(activity_file_id),
started_at=started_at,
)
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
response = await self._authenticated_post(
ACTIVITIES_BASE + "rider/profile/download-activity-file",
{"fileId": activity_file_id},
email,
password,
)
if response.status_code >= 400:
raise MyWhooshIntegrationError(f"download metadata returned HTTP {response.status_code}")
url = response.json().get("data")
if not isinstance(url, str) or not url:
raise MyWhooshIntegrationError("MyWhoosh download response has no URL")
try:
fit_response = await self.http.get(url)
except httpx.TransportError as exc:
raise MyWhooshTransientError("FIT download failed") from exc
if fit_response.status_code >= 500:
raise MyWhooshTransientError(f"FIT host returned HTTP {fit_response.status_code}")
if fit_response.status_code >= 400:
raise MyWhooshIntegrationError(f"FIT host returned HTTP {fit_response.status_code}")
return fit_response.content

View File

@@ -0,0 +1,162 @@
import json
from datetime import datetime, timezone
import httpx
import pytest
from app.mywhoosh.client import MyWhooshClient, MyWhooshIntegrationError
from app.mywhoosh.models import MyWhooshActivity, MyWhooshToken
from app.mywhoosh.tokenstore import MyWhooshTokenStore
@pytest.mark.asyncio
async def test_list_activities_paginates_and_normalizes(tmp_path) -> None:
calls = []
async def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
if request.url.path.endswith("/activities"):
payload = json.loads(request.content)
page = payload["page"]
result = {
"data": {
"totalPages": 2,
"results": [
{
"id": f"a-{page}",
"title": f"Ride {page}",
"activityFileId": f"f-{page}",
"startDatetime": "2026-08-15T06:00:00.000Z",
}
],
}
}
return httpx.Response(200, json=result)
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
activities = await client.list_activities("rider@example.com", "secret")
assert activities == [
MyWhooshActivity(
id="a-1",
title="Ride 1",
activity_file_id="f-1",
started_at=datetime(2026, 8, 15, 6, 0, 0, tzinfo=timezone.utc),
),
MyWhooshActivity(
id="a-2",
title="Ride 2",
activity_file_id="f-2",
started_at=datetime(2026, 8, 15, 6, 0, 0, tzinfo=timezone.utc),
),
]
activity_calls = [c for c in calls if c.endswith("/activities")]
assert len(activity_calls) == 2
@pytest.mark.asyncio
async def test_list_activities_reauthenticates_once_on_expired_token(tmp_path) -> None:
activity_call_count = 0
login_call_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal activity_call_count, login_call_count
if request.url.path.endswith("/activities"):
activity_call_count += 1
if activity_call_count == 1:
return httpx.Response(401, json={"message": "expired"})
return httpx.Response(
200,
json={
"data": {
"totalPages": 1,
"results": [
{
"id": "a-1",
"title": "Ride 1",
"activityFileId": "f-1",
"startDatetime": "2026-08-15T06:00:00.000Z",
}
],
}
},
)
if request.url.path.endswith("/login"):
login_call_count += 1
return httpx.Response(
200,
json={
"Success": True,
"AccessToken": "fresh-access",
"RefreshToken": "fresh-refresh",
"WhooshId": "w-1",
},
)
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="stale", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
activities = await client.list_activities("rider@example.com", "secret")
assert len(activities) == 1
assert activities[0].id == "a-1"
assert login_call_count == 1
assert activity_call_count == 2
assert store.load().access_token == "fresh-access"
@pytest.mark.asyncio
async def test_download_fit_fetches_signed_url_bytes(tmp_path) -> None:
fit_bytes = b"\x0e\x10\x8b\x08.FIT" + b"\x00" * 20
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/download-activity-file"):
payload = json.loads(request.content)
assert payload == {"fileId": "f-1"}
return httpx.Response(200, json={"data": "https://signed.example/activity.fit"})
if str(request.url) == "https://signed.example/activity.fit":
return httpx.Response(200, content=fit_bytes)
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
result = await client.download_fit("f-1", "rider@example.com", "secret")
assert result == fit_bytes
@pytest.mark.asyncio
async def test_list_activities_raises_integration_error_on_missing_stable_id(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/activities"):
return httpx.Response(
200,
json={
"data": {
"totalPages": 1,
"results": [
{
"title": "Ride without id",
"activityFileId": "f-1",
"startDatetime": "2026-08-15T06:00:00.000Z",
}
],
}
},
)
raise AssertionError(request.url)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
store.save(MyWhooshToken(access_token="cached", refresh_token=None, whoosh_id=None))
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
with pytest.raises(MyWhooshIntegrationError):
await client.list_activities("rider@example.com", "secret")