This commit is contained in:
Bastian Wagner
2026-08-15 20:31:57 +02:00
parent 85b0d861b4
commit 7c9e19ba0b
8 changed files with 146 additions and 5 deletions

View File

@@ -1,9 +1,12 @@
import json
import httpx
import pytest
from app.mywhoosh.client import (
MyWhooshAuthError,
MyWhooshClient,
MyWhooshDeviceConflictError,
MyWhooshIntegrationError,
MyWhooshTransientError,
)
@@ -44,6 +47,42 @@ async def test_invalid_credentials_raise_auth_error(tmp_path) -> None:
await client.login("rider@example.com", "bad")
@pytest.mark.asyncio
async def test_device_conflict_message_raises_device_conflict_error(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"Success": False, "Message": "You are already logged in from another device."}
)
client = MyWhooshClient(
MyWhooshTokenStore(tmp_path / "mywhoosh.json"),
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
with pytest.raises(MyWhooshDeviceConflictError):
await client.login("rider@example.com", "secret")
@pytest.mark.asyncio
async def test_login_reuses_stable_device_id_across_calls(tmp_path) -> None:
seen_device_ids = []
async def handler(request: httpx.Request) -> httpx.Response:
seen_device_ids.append(json.loads(request.content)["DeviceId"])
return httpx.Response(
200,
json={"Success": True, "AccessToken": "access", "RefreshToken": "refresh", "WhooshId": "w-1"},
)
store = MyWhooshTokenStore(tmp_path / "mywhoosh.json")
client = MyWhooshClient(store, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
await client.login("rider@example.com", "secret")
await client.login("rider@example.com", "secret")
assert len(seen_device_ids) == 2
assert seen_device_ids[0] == seen_device_ids[1]
assert seen_device_ids[0] == store.get_or_create_device_id()
@pytest.mark.asyncio
async def test_login_returns_json_array_raises_integration_error(tmp_path) -> None:
async def handler(request: httpx.Request) -> httpx.Response:

View File

@@ -41,3 +41,14 @@ def test_clear_removes_token_and_load_returns_none(tmp_path: Path) -> None:
assert store.load() is None
assert not store.path.exists()
def test_get_or_create_device_id_persists_and_is_stable(tmp_path: Path) -> None:
path = tmp_path / "tokens" / "7" / "mywhoosh.json"
device_id = MyWhooshTokenStore(path).get_or_create_device_id()
reloaded_id = MyWhooshTokenStore(path).get_or_create_device_id()
assert reloaded_id == device_id
device_id_path = path.with_name("device_id")
assert device_id_path.read_text("utf-8").strip() == device_id

View File

@@ -6,10 +6,11 @@ from sqlalchemy import select
from app.db.models import ActivityStatus, HealthState, SyncRun, SyncRunStatus, SyncUser
from app.db.repositories import UserRepository
from app.garmin.uploader import UploadResult
from app.mywhoosh.client import MyWhooshDeviceConflictError
from app.mywhoosh.models import MyWhooshActivity
from app.sync.manager import SyncManager
from tests.sync.conftest import FakeFitConverter, _create_user
from tests.sync.fakes import FakeMyWhooshClient
from tests.sync.fakes import FakeGarminUploader, FakeMyWhooshClient
@pytest.mark.asyncio
@@ -188,6 +189,46 @@ async def test_garmin_action_required_cleared_after_successful_import(
assert reloaded.health_state == HealthState.HEALTHY
class DeviceConflictMyWhooshClient:
"""Fake MyWhoosh client that always raises MyWhooshDeviceConflictError
from list_activities, simulating MyWhoosh's "already logged in from
another device" response."""
async def list_activities(self, email: str, password: str):
raise MyWhooshDeviceConflictError("You are already logged in from another device.")
async def download_fit(self, activity_file_id: str, email: str, password: str) -> bytes:
raise AssertionError("download_fit should not be reached in this test")
@pytest.mark.asyncio
async def test_device_conflict_sets_distinct_action_reason(seeded_user: SyncUser, session_factory, cipher, settings) -> None:
"""A MyWhoosh device-conflict response must be distinguishable in the UI
from a generic auth failure, so users get an actionable hint instead of
being told to re-check their password."""
mywhoosh = DeviceConflictMyWhooshClient()
converter = FakeFitConverter()
garmin = FakeGarminUploader()
manager = SyncManager(
session_factory=session_factory,
credential_cipher=cipher,
settings=settings,
mywhoosh_factory=lambda token_store: mywhoosh,
garmin_factory=lambda email, password, tokenstore: garmin,
fit_converter=converter,
)
outcome = await manager.sync_user(seeded_user.id)
assert outcome.status == "failed"
assert "another device" in outcome.message
with session_factory() as session:
reloaded = UserRepository(session).get(seeded_user.id)
assert reloaded.action_reason == "mywhoosh_device_conflict"
assert reloaded.mywhoosh_state == "device_conflict"
assert reloaded.health_state == HealthState.ACTION_REQUIRED
@pytest.mark.asyncio
async def test_mfa_code_reaches_real_garmin_uploader_via_sync_manager(
session_factory, cipher, settings, seeded_user: SyncUser