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_skips_row_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", }, { "id": "a-2", "title": "Ride with id", "activityFileId": "f-2", "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))) activities = await client.list_activities("rider@example.com", "secret") assert [a.id for a in activities] == ["a-2"] @pytest.mark.asyncio async def test_list_activities_skips_row_with_unparseable_start_datetime(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": [ { "id": "a-1", "title": "Ride with bad date", "activityFileId": "f-1", "startDatetime": "not-a-date", }, { "id": "a-2", "title": "Ride with good date", "activityFileId": "f-2", "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))) activities = await client.list_activities("rider@example.com", "secret") assert [a.id for a in activities] == ["a-2"] @pytest.mark.asyncio async def test_list_activities_raises_integration_error_on_envelope_shape_failure(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}}) 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") @pytest.mark.asyncio async def test_list_activities_respects_max_pages(tmp_path) -> None: calls = [] async def handler(request: httpx.Request) -> httpx.Response: if request.url.path.endswith("/activities"): payload = json.loads(request.content) page = payload["page"] calls.append(page) result = { "data": { "totalPages": 5, "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", max_pages=1) assert calls == [1] assert [a.id for a in activities] == ["a-1"] @pytest.mark.asyncio async def test_download_fit_raises_integration_error_on_invalid_json(tmp_path) -> None: async def handler(request: httpx.Request) -> httpx.Response: if request.url.path.endswith("/download-activity-file"): return httpx.Response(200, content=b"not json", headers={"content-type": "application/json"}) 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.download_fit("f-1", "rider@example.com", "secret")