From 21074a35daac6a1c9ed688fe15e1403f70d146a4 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 15 Aug 2026 14:52:03 +0200 Subject: [PATCH] feat: fetch MyWhoosh activities and FIT files --- app/mywhoosh/client.py | 102 +++++++++++++- tests/mywhoosh/test_client_activities.py | 162 +++++++++++++++++++++++ 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 tests/mywhoosh/test_client_activities.py diff --git a/app/mywhoosh/client.py b/app/mywhoosh/client.py index 5711ce9..e224225 100644 --- a/app/mywhoosh/client.py +++ b/app/mywhoosh/client.py @@ -1,10 +1,11 @@ from __future__ import annotations import uuid +from datetime import datetime, timezone import httpx -from app.mywhoosh.models import MyWhooshToken +from app.mywhoosh.models import MyWhooshActivity, MyWhooshToken from app.mywhoosh.tokenstore import MyWhooshTokenStore 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: if self.token is None: 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 diff --git a/tests/mywhoosh/test_client_activities.py b/tests/mywhoosh/test_client_activities.py new file mode 100644 index 0000000..14619eb --- /dev/null +++ b/tests/mywhoosh/test_client_activities.py @@ -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")