errors
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user