Files
mi-fitness-health/tests/test_mi_fitness_cloud.py
T

151 lines
6.6 KiB
Python

import pytest
from datetime import datetime, timezone, timedelta
from mi_fitness_mcp.adapters.mi_fitness_cloud import (
MiFitnessCloudAdapter,
_is_authentication_error,
)
async def _collect(async_iterable):
items = []
async for item in async_iterable:
items.append(item)
return items
def test_optional_number_helpers():
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
assert adapter._optional_float(0) is None
assert adapter._optional_float("1.5") == 1.5
assert adapter._optional_int(0) is None
assert adapter._optional_int("7") == 7
def test_parse_value_dict_and_json():
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
assert adapter._parse_value({"value": {"steps": 1}}) == {"steps": 1}
assert adapter._parse_value({"value": '{"steps": 2}'}) == {"steps": 2}
def test_record_datetime_uses_zone_offset():
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
dt = adapter._record_datetime({"time": 0, "zone_offset": 10800})
assert dt.isoformat().startswith("1970-01-01T03:00:00")
@pytest.mark.asyncio
async def test_iter_daily_activity_uses_latest_cloud_daily_report(monkeypatch):
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1", region="cn")
adapter._connected = True
adapter._client = object()
async def fake_request(base_url, path, payload):
# CN 午夜对应 UTC 前一天,日报没有 zone_offset;重复日报取最新版本。
if path.endswith("get_aggregated_fitness_data_by_time"):
assert payload["tag"] == "daily_report"
values = (
['{"steps": 10, "distance": 8}', '{"steps": 20, "distance": 16}']
if payload["key"] == "steps" else ['{"calories": 7}']
)
return {"data_list": [{"time": 1743436800, "update_time": i + 1, "value": v} for i, v in enumerate(values)], "has_more": False}
# 旧实现会误把多来源明细相加。
return {"data_list": [{"time": 1743467400, "value": '{"steps": 99, "distance": 99, "calories": 99}'}], "has_more": False}
monkeypatch.setattr(adapter, "_request", fake_request)
items = await _collect(adapter.iter_daily_activity("2025-04-01", "2025-04-01"))
assert len(items) == 1
assert items[0].date == "2025-04-01"
assert items[0].steps == 20
assert items[0].distance_m == 16
assert items[0].active_kcal == 7
@pytest.mark.asyncio
async def test_daily_activity_rejects_missing_calorie_report(monkeypatch):
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1", region="cn")
adapter._connected = True
adapter._client = object()
async def fake_request(base_url, path, payload):
items = [{"time": 1743436800, "value": '{"steps": 20, "distance": 16}'}] if payload["key"] == "steps" else []
return {"data_list": items, "has_more": False}
monkeypatch.setattr(adapter, "_request", fake_request)
with pytest.raises(ValueError, match="Incomplete daily report"):
await _collect(adapter.iter_daily_activity("2025-04-01", "2025-04-01"))
@pytest.mark.asyncio
async def test_fetch_key_rejects_repeated_pagination_cursor(monkeypatch):
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1", region="cn")
adapter._client = object()
async def fake_request(base_url, api_path, payload):
return {"data_list": [], "has_more": True, "next_key": "same"}
monkeypatch.setattr(adapter, "_request", fake_request)
with pytest.raises(RuntimeError, match="cursor loop"):
await adapter._fetch_key("steps", "2026-07-06", "2026-07-12")
@pytest.mark.asyncio
async def test_connect_failure_closes_client(monkeypatch):
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="bad", region="cn")
async def failed_login(user_id, pass_token):
raise RuntimeError("invalid credentials")
monkeypatch.setattr(adapter, "_login_with_token", failed_login)
assert await adapter.connect() is False
assert adapter._client is None
assert "invalid credentials" in adapter.last_error
def test_authentication_error_detection():
assert _is_authentication_error(401, "denied")
assert _is_authentication_error(0, "session expired")
assert _is_authentication_error(-10001, "unknown")
assert not _is_authentication_error(500, "temporary server failure")
@pytest.mark.asyncio
async def test_sleep_daily_report_does_not_subtract_awake_twice(monkeypatch):
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1", region="cn")
adapter._connected = True
adapter._client = object()
cn = timezone(timedelta(hours=8))
bedtime = int(datetime(2025, 4, 1, 23, 40, tzinfo=cn).timestamp())
wake = bedtime + 40 * 60
segment = {"bedtime": bedtime, "wake_up_time": wake, "duration": 30, "sleep_awake_duration": 10, "sleep_deep_duration": 10, "sleep_light_duration": 20}
async def fake_request(base_url, path, payload):
if path.endswith("get_aggregated_fitness_data_by_time"):
return {"data_list": [{"time": wake, "update_time": wake, "value": {"total_duration": 30, "sleep_score": 84, "segment_details": [segment]}}], "has_more": False}
return {"data_list": [{"time": wake, "zone_offset": 28800, "value": segment}], "has_more": False}
monkeypatch.setattr(adapter, "_request", fake_request)
sessions = await _collect(adapter.iter_sleep_sessions("2025-04-02", "2025-04-02"))
assert len(sessions) == 1
assert sessions[0].duration_minutes == 30
assert sessions[0].time_asleep_minutes == 30
assert sessions[0].time_awake_minutes == 10
assert sessions[0].sleep_score == 84
def test_sleep_queries_use_wake_date_and_latest_canonical_version(tmp_path):
from mi_fitness_mcp.models import SleepSession
from mi_fitness_mcp.storage import Database
cn = timezone(timedelta(hours=8))
db = Database(tmp_path / "test.sqlite")
start = datetime(2025, 4, 1, 23, 40, tzinfo=cn)
end = start + timedelta(minutes=40)
for name, source, collected in [("raw", "raw:1", end), ("old", "daily_report:2025-04-02", end), ("new", "daily_report:2025-04-02", end + timedelta(minutes=5))]:
db.insert_sleep_session(SleepSession(id=name, sleep_id=name, provider="mi_fitness", source_type="cloud_session", source_record_id=source, user_id="u1", timezone="Asia/Shanghai", collected_at=collected, start_at=start, end_at=end, duration_minutes=30, time_asleep_minutes=30, time_awake_minutes=10))
sessions = db.query_sleep_sessions("u1", "2025-04-02", "2025-04-02")
assert [row["id"] for row in sessions] == ["new"]
with db._get_connection() as conn:
assert conn.execute("SELECT COUNT(*) FROM sleep_sessions").fetchone()[0] == 3