mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-17 01:27:06 +03:00
964 lines
32 KiB
Python
964 lines
32 KiB
Python
"""测试 API 客户端 (client.py)。
|
||
|
||
使用 mock 替换 _request 方法,验证各业务方法的参数组装和响应解析。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
|
||
from mi_fitness.client import MiHealthClient
|
||
from mi_fitness.const import (
|
||
ALL_SHARED_DATA_TYPES,
|
||
MESSAGE_GET_LIST_PATH,
|
||
MESSAGE_MODULE_RELATIVES,
|
||
RELATIVES_DELETE_PATH,
|
||
RELATIVES_GET_INVITE_ID_PATH,
|
||
RELATIVES_LIST_PATH,
|
||
RELATIVES_OPERATE_INVITE_PATH,
|
||
RELATIVES_VERIFY_USER_PATH,
|
||
VERIFY_TYPE_XIAOMI_ID,
|
||
)
|
||
from mi_fitness.exceptions import (
|
||
DataNotSharedError,
|
||
DataOutOfSharedTimeScopeError,
|
||
FamilyMemberNotFoundError,
|
||
TokenExpiredError,
|
||
)
|
||
from mi_fitness.models import (
|
||
CaloriesData,
|
||
GoalData,
|
||
HeartRateData,
|
||
IntensityData,
|
||
LatestDataSnapshot,
|
||
SleepData,
|
||
Spo2Data,
|
||
Spo2SummaryData,
|
||
StepData,
|
||
ValidStandData,
|
||
WeightData,
|
||
)
|
||
|
||
from .conftest import (
|
||
AGGREGATED_HR_RESPONSE,
|
||
AGGREGATED_SLEEP_RESPONSE,
|
||
AGGREGATED_STEPS_RESPONSE,
|
||
CHECK_NEW_MSG_RESPONSE,
|
||
CHECK_NO_NEW_MSG_RESPONSE,
|
||
DELETE_RESPONSE,
|
||
FAMILY_MEMBER_RESPONSE,
|
||
FITNESS_BLOOD_PRESSURE_RESPONSE,
|
||
FITNESS_WEIGHT_RESPONSE,
|
||
INVITE_ID_RESPONSE,
|
||
INVITE_RESPONSE,
|
||
LATEST_DATA_RESPONSE,
|
||
MESSAGE_LIST_RESPONSE,
|
||
OPERATE_INVITE_RESPONSE,
|
||
RELATIVE_LIST_RESPONSE,
|
||
SHARED_TYPES_RESPONSE,
|
||
VERIFY_USER_RESPONSE,
|
||
)
|
||
|
||
# 使用 pytest fixture 中的 mock_auth
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
def _make_client(mock_auth: Any) -> MiHealthClient:
|
||
"""用 mock auth 构造客户端实例。"""
|
||
return MiHealthClient(mock_auth)
|
||
|
||
|
||
# region 亲友列表
|
||
class TestGetRelatives:
|
||
async def test_returns_family_members(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE)
|
||
|
||
members = await client.get_relatives()
|
||
assert len(members) == 2
|
||
assert members[0].relative_uid == 1452722403
|
||
assert members[0].relative_note == "妈妈"
|
||
assert members[1].relative_note == "爸爸"
|
||
|
||
client._request.assert_called_once_with("GET", RELATIVES_LIST_PATH)
|
||
|
||
async def test_empty_list(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value={"code": 0, "result": {"relative_list": []}})
|
||
members = await client.get_relatives()
|
||
assert members == []
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 查找亲友
|
||
class TestFindRelative:
|
||
async def test_find_by_uid(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE)
|
||
|
||
member = await client.find_relative(1452722403)
|
||
assert member.relative_uid == 1452722403
|
||
|
||
async def test_find_by_note(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE)
|
||
|
||
member = await client.find_relative("妈")
|
||
assert member.relative_note == "妈妈"
|
||
|
||
async def test_not_found_raises(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE)
|
||
|
||
with pytest.raises(FamilyMemberNotFoundError):
|
||
await client.find_relative("不存在的人")
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 验证用户
|
||
class TestVerifyUser:
|
||
async def test_verify_by_xiaomi_id(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=VERIFY_USER_RESPONSE)
|
||
|
||
info = await client.verify_user(1452722403)
|
||
assert info is not None
|
||
assert info.user_id == 1452722403
|
||
assert info.nickname == "测试用户"
|
||
|
||
client._request.assert_called_once_with(
|
||
"GET",
|
||
RELATIVES_VERIFY_USER_PATH,
|
||
params={"verify_id": 1452722403, "verify_type": VERIFY_TYPE_XIAOMI_ID},
|
||
)
|
||
|
||
async def test_verify_returns_none_for_missing_user(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value={"code": 0, "result": {}})
|
||
info = await client.verify_user(999)
|
||
assert info is None
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 邀请亲友
|
||
class TestInviteRelative:
|
||
async def test_invite_success(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=INVITE_RESPONSE)
|
||
|
||
result = await client.invite_relative(1452722403)
|
||
assert result is True
|
||
|
||
call_args = client._request.call_args
|
||
params = call_args.kwargs["params"]
|
||
assert params["relative_uid"] == 1452722403
|
||
assert params["auth_content"]["auth_data"] == ALL_SHARED_DATA_TYPES
|
||
assert params["auth_content"]["auth_time_range"] == 3
|
||
|
||
async def test_invite_with_custom_types(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=INVITE_RESPONSE)
|
||
|
||
await client.invite_relative(
|
||
123,
|
||
shared_data_types=["heart_rate", "sleep"],
|
||
auth_time_range=1,
|
||
)
|
||
|
||
params = client._request.call_args.kwargs["params"]
|
||
assert params["auth_content"]["auth_data"] == ["heart_rate", "sleep"]
|
||
assert params["auth_content"]["auth_time_range"] == 1
|
||
|
||
async def test_invite_with_note(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=INVITE_RESPONSE)
|
||
|
||
await client.invite_relative(123, relative_note="好友")
|
||
|
||
params = client._request.call_args.kwargs["params"]
|
||
assert params["relative_note"] == "好友"
|
||
|
||
async def test_invite_failure(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value={"code": 0, "result": {"send_ret": 0}})
|
||
result = await client.invite_relative(123)
|
||
assert result is False
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 操作邀请(同意/拒绝)
|
||
class TestOperateInvite:
|
||
async def test_accept_success(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE)
|
||
|
||
result = await client.accept_invite(4777767, 152824796151809)
|
||
assert result is True
|
||
|
||
call_args = client._request.call_args
|
||
params = call_args.kwargs["params"]
|
||
assert params["invite_id"] == 4777767
|
||
assert params["msg_id"] == 152824796151809
|
||
assert params["operate"] == 1
|
||
assert params["auth_content"]["auth_data"] == ALL_SHARED_DATA_TYPES
|
||
|
||
async def test_accept_with_custom_types(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE)
|
||
|
||
await client.accept_invite(123, 456, shared_data_types=["heart_rate", "sleep"])
|
||
|
||
params = client._request.call_args.kwargs["params"]
|
||
assert params["auth_content"]["auth_data"] == ["heart_rate", "sleep"]
|
||
|
||
async def test_reject_success(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE)
|
||
|
||
result = await client.reject_invite(4777767, 152824796151809)
|
||
assert result is True
|
||
|
||
params = client._request.call_args.kwargs["params"]
|
||
assert params["operate"] == 2
|
||
|
||
async def test_accept_calls_correct_endpoint(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE)
|
||
|
||
await client.accept_invite(1, 2)
|
||
|
||
client._request.assert_called_once_with(
|
||
"POST",
|
||
RELATIVES_OPERATE_INVITE_PATH,
|
||
params={
|
||
"auth_content": {
|
||
"auth_time_range": 3,
|
||
"auth_data": ALL_SHARED_DATA_TYPES,
|
||
},
|
||
"invite_id": 1,
|
||
"msg_id": 2,
|
||
"operate": 1,
|
||
},
|
||
)
|
||
|
||
async def test_operate_failure(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value={"code": 0, "result": {"operate_ret": False}})
|
||
result = await client.accept_invite(1, 2)
|
||
assert result is False
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 删除亲友
|
||
class TestDeleteRelative:
|
||
async def test_delete_success(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=DELETE_RESPONSE)
|
||
|
||
result = await client.delete_relative(1452722403)
|
||
assert result is True
|
||
|
||
client._request.assert_called_once_with(
|
||
"POST",
|
||
RELATIVES_DELETE_PATH,
|
||
params={"relative_uid": 1452722403},
|
||
)
|
||
|
||
async def test_delete_failure(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value={"code": 0, "result": {"delete_ret": False}})
|
||
result = await client.delete_relative(123)
|
||
assert result is False
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 邀请链接 ID
|
||
class TestGetInviteLinkId:
|
||
async def test_returns_snowflake_id(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=INVITE_ID_RESPONSE)
|
||
|
||
link_id = await client.get_invite_link_id()
|
||
assert link_id == 467184968352742400
|
||
|
||
client._request.assert_called_once_with("GET", RELATIVES_GET_INVITE_ID_PATH)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 共享数据类型
|
||
class TestGetSharedDataTypes:
|
||
async def test_returns_keys(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=SHARED_TYPES_RESPONSE)
|
||
|
||
keys = await client.get_shared_data_types(123)
|
||
assert "heart_rate" in keys
|
||
|
||
async def test_direction_parameter(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=SHARED_TYPES_RESPONSE)
|
||
|
||
await client.get_shared_data_types(123, direction=1)
|
||
params = client._request.call_args.kwargs["params"]
|
||
assert params["type"] == 1
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 家庭成员
|
||
class TestGetFamilyMembers:
|
||
async def test_returns_list(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=FAMILY_MEMBER_RESPONSE)
|
||
|
||
members = await client.get_family_members()
|
||
assert len(members) == 1
|
||
assert members[0]["userId"] == 123
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 最新数据
|
||
class TestGetLatestData:
|
||
async def test_returns_typed_snapshot(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE)
|
||
|
||
latest = await client.get_latest_data(123)
|
||
assert isinstance(latest, LatestDataSnapshot)
|
||
assert latest.updated_time == 1717495200
|
||
assert latest.goal is not None
|
||
assert len(latest.goal.goal_items) == 2
|
||
assert latest.heart_rate is not None
|
||
assert latest.heart_rate.bpm == 84
|
||
assert latest.sleep is not None
|
||
assert latest.sleep.total_duration == 436
|
||
assert latest.steps is not None
|
||
assert latest.steps.goal == 6000
|
||
assert latest.calories is not None
|
||
assert latest.calories.calories == 230
|
||
assert latest.valid_stand is not None
|
||
assert latest.valid_stand.count == 7
|
||
assert latest.intensity is not None
|
||
assert latest.intensity.duration == 15
|
||
assert latest.weight is not None
|
||
assert latest.weight.weight == 65.5
|
||
assert latest.spo2 is not None
|
||
assert latest.spo2.spo2 == 96
|
||
assert latest.blood_pressure is None
|
||
|
||
|
||
class TestGetLatestItems:
|
||
async def test_returns_raw_items(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE)
|
||
|
||
items = await client.get_latest_items(123)
|
||
assert len(items) == 10
|
||
assert items[0].key == "goal"
|
||
assert items[1].key == "heart_rate"
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 心率数据
|
||
class TestGetHeartRate:
|
||
async def test_returns_heart_rate_data(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=AGGREGATED_HR_RESPONSE)
|
||
|
||
data = await client.get_heart_rate(123, date(2024, 6, 4))
|
||
assert len(data) == 1
|
||
assert isinstance(data[0], HeartRateData)
|
||
assert data[0].avg_hr == 72
|
||
assert data[0].latest_hr is not None
|
||
assert data[0].latest_hr.bpm == 75
|
||
|
||
async def test_skips_invalid_heart_rate_item(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(
|
||
return_value={
|
||
"code": 0,
|
||
"result": {
|
||
"data_list": [
|
||
{
|
||
"sid": "miothealth",
|
||
"tag": "daily_report",
|
||
"key": "heart_rate",
|
||
"time": 1717430400,
|
||
"value": '{"avg_hr":{"bad":1}}',
|
||
"update_time": 1717488000,
|
||
"watermark": "w1",
|
||
"source_sid_list": [],
|
||
},
|
||
AGGREGATED_HR_RESPONSE["result"]["data_list"][0],
|
||
]
|
||
},
|
||
}
|
||
)
|
||
|
||
data = await client.get_heart_rate(123, date(2024, 6, 4))
|
||
|
||
assert len(data) == 1
|
||
assert data[0].avg_hr == 72
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 睡眠数据
|
||
class TestGetSleep:
|
||
async def test_returns_sleep_data(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=AGGREGATED_SLEEP_RESPONSE)
|
||
|
||
data = await client.get_sleep(123, date(2024, 6, 4))
|
||
assert len(data) == 1
|
||
assert isinstance(data[0], SleepData)
|
||
assert data[0].total_duration == 480
|
||
assert data[0].sleep_score == 85
|
||
assert len(data[0].segment_details) == 1
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 步数数据
|
||
class TestGetSteps:
|
||
async def test_returns_step_data(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=AGGREGATED_STEPS_RESPONSE)
|
||
|
||
data = await client.get_steps(123, date(2024, 6, 4))
|
||
assert len(data) == 1
|
||
assert isinstance(data[0], StepData)
|
||
assert data[0].steps == 8500
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 其它聚合指标
|
||
class TestOtherAggregatedMetrics:
|
||
async def test_history_days_uses_trailing_window(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value={"code": 0, "result": {"data_list": []}})
|
||
|
||
await client.get_calories_history(123, date(2024, 6, 4), days=7)
|
||
|
||
call = client._request.call_args
|
||
assert call is not None
|
||
params = call.kwargs["params"]
|
||
assert params["key"] == "calories"
|
||
assert params["limit"] == 7
|
||
assert params["end_time"] - params["start_time"] == 86400 * 7 - 1
|
||
|
||
async def test_returns_calories_valid_stand_and_intensity(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(
|
||
side_effect=[
|
||
{
|
||
"code": 0,
|
||
"result": {
|
||
"data_list": [
|
||
{
|
||
"sid": "default",
|
||
"tag": "daily_report",
|
||
"key": "calories",
|
||
"time": 1717430400,
|
||
"value": '{"calories":338}',
|
||
"update_time": 1717488000,
|
||
"watermark": "w1",
|
||
"source_sid_list": [],
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"code": 0,
|
||
"result": {
|
||
"data_list": [
|
||
{
|
||
"sid": "default",
|
||
"tag": "daily_report",
|
||
"key": "valid_stand",
|
||
"time": 1717430400,
|
||
"value": '{"count":10}',
|
||
"update_time": 1717488000,
|
||
"watermark": "w2",
|
||
"source_sid_list": [],
|
||
}
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"code": 0,
|
||
"result": {
|
||
"data_list": [
|
||
{
|
||
"sid": "default",
|
||
"tag": "daily_report",
|
||
"key": "intensity",
|
||
"time": 1717430400,
|
||
"value": '{"duration":19}',
|
||
"update_time": 1717488000,
|
||
"watermark": "w3",
|
||
"source_sid_list": [],
|
||
}
|
||
]
|
||
},
|
||
},
|
||
]
|
||
)
|
||
|
||
calories = await client.get_calories_history(123, date(2024, 6, 4))
|
||
stand = await client.get_valid_stand_history(123, date(2024, 6, 4))
|
||
intensity = await client.get_intensity_history(123, date(2024, 6, 4))
|
||
|
||
assert isinstance(calories[0], CaloriesData)
|
||
assert calories[0].calories == 338
|
||
assert isinstance(stand[0], ValidStandData)
|
||
assert stand[0].count == 10
|
||
assert isinstance(intensity[0], IntensityData)
|
||
assert intensity[0].duration == 19
|
||
|
||
async def test_returns_spo2_history(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(
|
||
return_value={
|
||
"code": 0,
|
||
"result": {
|
||
"data_list": [
|
||
{
|
||
"sid": "default",
|
||
"tag": "daily_report",
|
||
"key": "spo2",
|
||
"time": 1761091200,
|
||
"value": (
|
||
'{"avg_spo2":96,"lack_spo2_count":0,'
|
||
'"latest_spo2":{"spo2":96,"time":1761161730},'
|
||
'"max_spo2":96,"min_spo2":96}'
|
||
),
|
||
"update_time": 1762064917,
|
||
"watermark": "w4",
|
||
"source_sid_list": [],
|
||
}
|
||
]
|
||
},
|
||
}
|
||
)
|
||
|
||
series = await client.get_spo2_history(123, date(2025, 10, 22))
|
||
|
||
assert len(series) == 1
|
||
assert isinstance(series[0], Spo2SummaryData)
|
||
assert series[0].avg_spo2 == 96
|
||
assert series[0].latest_spo2 is not None
|
||
assert series[0].latest_spo2.spo2 == 96
|
||
|
||
async def test_returns_weight_history(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=FITNESS_WEIGHT_RESPONSE)
|
||
|
||
series = await client.get_weight_history(123, date(2026, 3, 17), days=7)
|
||
|
||
assert len(series) == 1
|
||
assert isinstance(series[0], WeightData)
|
||
assert series[0].weight == 55.0
|
||
assert series[0].bmi == 16.97531
|
||
|
||
async def test_returns_blood_pressure_history(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=FITNESS_BLOOD_PRESSURE_RESPONSE)
|
||
|
||
series = await client.get_blood_pressure_history(123, date(2026, 3, 17), days=7)
|
||
|
||
assert len(series) == 1
|
||
assert series[0].systolic == 33
|
||
assert series[0].diastolic == 30
|
||
assert series[0].pulse == 60
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 体重数据
|
||
class TestGetWeight:
|
||
async def test_returns_weight_data(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE)
|
||
|
||
weight = await client.get_weight(123)
|
||
assert weight is not None
|
||
assert isinstance(weight, WeightData)
|
||
assert weight.weight == 65.5
|
||
assert weight.bmi == 22.1
|
||
|
||
async def test_returns_none_when_no_weight(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
resp = {
|
||
"code": 0,
|
||
"result": {
|
||
"data_list": [
|
||
{"time": 100, "key": "heart_rate", "value": "{}"},
|
||
]
|
||
},
|
||
}
|
||
client._request = AsyncMock(return_value=resp)
|
||
client.get_shared_data_types = AsyncMock(return_value=["weight"]) # type: ignore[method-assign]
|
||
|
||
weight = await client.get_weight(123)
|
||
assert weight is None
|
||
|
||
async def test_raises_not_shared_when_weight_disabled(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value={"code": 0, "result": {"data_list": []}})
|
||
client.get_shared_data_types = AsyncMock(return_value=["steps"]) # type: ignore[method-assign]
|
||
|
||
with pytest.raises(DataNotSharedError, match="weight"):
|
||
await client.get_weight(123)
|
||
|
||
|
||
class TestGetLatestMetricHelpers:
|
||
async def test_returns_goal_and_other_latest_metric_types(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE)
|
||
|
||
goal = await client.get_goal(123)
|
||
calories = await client.get_calories(123)
|
||
stand = await client.get_valid_stand(123)
|
||
intensity = await client.get_intensity(123)
|
||
spo2 = await client.get_spo2(123)
|
||
|
||
assert isinstance(goal, GoalData)
|
||
assert len(goal.goal_items) == 2
|
||
assert goal.steps_goal is not None
|
||
assert goal.steps_goal.target_value == 6000
|
||
assert goal.calories_goal is not None
|
||
assert goal.calories_goal.target_value == 400
|
||
assert goal.intensity_goal is None
|
||
assert isinstance(calories, CaloriesData)
|
||
assert calories.calories == 230
|
||
assert isinstance(stand, ValidStandData)
|
||
assert stand.count == 7
|
||
assert isinstance(intensity, IntensityData)
|
||
assert intensity.duration == 15
|
||
assert isinstance(spo2, Spo2Data)
|
||
assert spo2.spo2 == 96
|
||
|
||
async def test_blood_pressure_returns_none_when_shared_but_no_payload(
|
||
self, mock_auth: Any
|
||
) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE)
|
||
client.get_shared_data_types = AsyncMock(return_value=["blood_pressure"]) # type: ignore[method-assign]
|
||
|
||
blood_pressure = await client.get_blood_pressure(123)
|
||
|
||
assert blood_pressure is None
|
||
|
||
async def test_blood_pressure_supports_fitness_aliases_in_latest_payload(
|
||
self, mock_auth: Any
|
||
) -> None:
|
||
client = _make_client(mock_auth)
|
||
resp = {
|
||
"code": 0,
|
||
"result": {
|
||
"data_list": [
|
||
{
|
||
"time": 1773753098,
|
||
"key": "blood_pressure",
|
||
"value": (
|
||
'{"systolic_pressure":33,"diastolic_pressure":30,'
|
||
'"pulse":60,"time":1773753098}'
|
||
),
|
||
}
|
||
]
|
||
},
|
||
}
|
||
client._request = AsyncMock(return_value=resp)
|
||
|
||
blood_pressure = await client.get_blood_pressure(123)
|
||
|
||
assert blood_pressure is not None
|
||
assert blood_pressure.systolic == 33
|
||
assert blood_pressure.diastolic == 30
|
||
assert blood_pressure.pulse == 60
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 每日摘要
|
||
class TestGetDailySummary:
|
||
async def test_returns_summary_dict(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
# 依次返回心率、睡眠、步数的响应
|
||
client._request = AsyncMock(
|
||
side_effect=[
|
||
AGGREGATED_HR_RESPONSE,
|
||
AGGREGATED_SLEEP_RESPONSE,
|
||
AGGREGATED_STEPS_RESPONSE,
|
||
]
|
||
)
|
||
|
||
summary = await client.get_daily_summary(123, date(2024, 6, 4))
|
||
assert summary.date == "2024-06-04"
|
||
assert summary.relative_uid == 123
|
||
assert summary.heart_rate is not None
|
||
assert summary.sleep is not None
|
||
assert summary.steps is not None
|
||
|
||
async def test_get_latest_daily_summary_uses_relative_latest_date(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client.find_relative = AsyncMock(
|
||
return_value=type(
|
||
"Member",
|
||
(),
|
||
{
|
||
"relative_uid": 123,
|
||
"relative_note": "测试",
|
||
"latest_data_time": 1717488000,
|
||
},
|
||
)()
|
||
)
|
||
client._request = AsyncMock(
|
||
side_effect=[
|
||
AGGREGATED_HR_RESPONSE,
|
||
AGGREGATED_SLEEP_RESPONSE,
|
||
AGGREGATED_STEPS_RESPONSE,
|
||
]
|
||
)
|
||
|
||
summary = await client.get_latest_daily_summary(123)
|
||
|
||
assert summary.date == "2024-06-04"
|
||
assert summary.heart_rate is not None
|
||
assert summary.sleep is not None
|
||
assert summary.steps is not None
|
||
|
||
async def test_get_latest_daily_summary_falls_back_to_latest_snapshot_time(
|
||
self, mock_auth: Any
|
||
) -> None:
|
||
client = _make_client(mock_auth)
|
||
client.find_relative = AsyncMock(
|
||
return_value=type(
|
||
"Member",
|
||
(),
|
||
{
|
||
"relative_uid": 123,
|
||
"relative_note": "测试",
|
||
"latest_data_time": 0,
|
||
},
|
||
)()
|
||
)
|
||
client._request = AsyncMock(
|
||
side_effect=[
|
||
LATEST_DATA_RESPONSE,
|
||
AGGREGATED_HR_RESPONSE,
|
||
AGGREGATED_SLEEP_RESPONSE,
|
||
AGGREGATED_STEPS_RESPONSE,
|
||
]
|
||
)
|
||
|
||
summary = await client.get_latest_daily_summary(123)
|
||
|
||
assert summary.date == "2024-06-04"
|
||
assert summary.heart_rate is not None
|
||
assert summary.sleep is not None
|
||
assert summary.steps is not None
|
||
|
||
async def test_daily_summary_returns_partial_results_when_some_data_not_shared(
|
||
self,
|
||
mock_auth: Any,
|
||
) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(
|
||
side_effect=[
|
||
DataNotSharedError("未共享该数据类型", data_type="heart_rate"),
|
||
DataNotSharedError("未共享该数据类型", data_type="sleep"),
|
||
AGGREGATED_STEPS_RESPONSE,
|
||
]
|
||
)
|
||
|
||
summary = await client.get_daily_summary(123, date(2024, 6, 4))
|
||
|
||
assert summary.date == "2024-06-04"
|
||
assert summary.heart_rate is None
|
||
assert summary.sleep is None
|
||
assert summary.steps is not None
|
||
assert summary.steps.steps == 8500
|
||
|
||
async def test_daily_summary_raises_when_query_date_exceeds_shared_time_scope(
|
||
self,
|
||
mock_auth: Any,
|
||
) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(
|
||
side_effect=[
|
||
DataOutOfSharedTimeScopeError("超出亲友共享时间范围", data_type="heart_rate"),
|
||
AGGREGATED_SLEEP_RESPONSE,
|
||
AGGREGATED_STEPS_RESPONSE,
|
||
]
|
||
)
|
||
|
||
with pytest.raises(DataOutOfSharedTimeScopeError, match="超出亲友共享时间范围"):
|
||
await client.get_daily_summary(123, date(2024, 6, 4))
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 上下文管理器
|
||
class TestContextManager:
|
||
async def test_async_context_manager(self, mock_auth: Any) -> None:
|
||
async with MiHealthClient(mock_auth) as client:
|
||
assert client is not None
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 自动刷新
|
||
class TestAutoRefresh:
|
||
async def test_request_refreshes_and_retries_once(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
|
||
async def refresh() -> Any:
|
||
mock_auth.token.service_token = "refreshed-token"
|
||
return mock_auth.token
|
||
|
||
mock_auth.refresh = AsyncMock(side_effect=refresh)
|
||
client._http = MagicMock()
|
||
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
import mi_fitness.client.api as client_api_module
|
||
|
||
encrypted = AsyncMock(
|
||
side_effect=[
|
||
TokenExpiredError("expired"),
|
||
RELATIVE_LIST_RESPONSE,
|
||
]
|
||
)
|
||
mp.setattr(client_api_module, "encrypted_request", encrypted)
|
||
|
||
members = await client.get_relatives()
|
||
|
||
assert len(members) == 2
|
||
mock_auth.refresh.assert_awaited_once()
|
||
assert encrypted.await_count == 2
|
||
|
||
async def test_request_does_not_refresh_twice(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
mock_auth.refresh = AsyncMock(return_value=mock_auth.token)
|
||
client._http = MagicMock()
|
||
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
import mi_fitness.client.api as client_api_module
|
||
|
||
encrypted = AsyncMock(side_effect=TokenExpiredError("expired"))
|
||
mp.setattr(client_api_module, "encrypted_request", encrypted)
|
||
|
||
with pytest.raises(TokenExpiredError):
|
||
await client._request("GET", RELATIVES_LIST_PATH)
|
||
|
||
mock_auth.refresh.assert_awaited_once()
|
||
assert encrypted.await_count == 2
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 消息接口
|
||
class TestGetInviteMessages:
|
||
async def test_returns_all_messages(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE)
|
||
|
||
messages = await client.get_invite_messages()
|
||
assert len(messages) == 2
|
||
assert messages[0].msg_id == 152824796151809
|
||
assert messages[0].sender == 1452722403
|
||
assert messages[0].invite_id == 4777767
|
||
assert messages[0].nick_name == "测试用户"
|
||
assert messages[0].is_pending is True
|
||
|
||
client._request.assert_called_once_with(
|
||
"POST",
|
||
MESSAGE_GET_LIST_PATH,
|
||
params={"module": MESSAGE_MODULE_RELATIVES, "limit": 30},
|
||
)
|
||
|
||
async def test_pending_only(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE)
|
||
|
||
messages = await client.get_invite_messages(pending_only=True)
|
||
assert len(messages) == 1
|
||
assert messages[0].is_pending is True
|
||
assert messages[0].invite_id == 4777767
|
||
|
||
async def test_custom_limit(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE)
|
||
|
||
await client.get_invite_messages(limit=10)
|
||
|
||
params = client._request.call_args.kwargs["params"]
|
||
assert params["limit"] == 10
|
||
|
||
|
||
class TestHasNewInvite:
|
||
async def test_has_new(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=CHECK_NEW_MSG_RESPONSE)
|
||
|
||
result = await client.has_new_invite()
|
||
assert result is True
|
||
|
||
async def test_no_new(self, mock_auth: Any) -> None:
|
||
client = _make_client(mock_auth)
|
||
client._request = AsyncMock(return_value=CHECK_NO_NEW_MSG_RESPONSE)
|
||
|
||
result = await client.has_new_invite()
|
||
assert result is False
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 时间戳工具(同步测试,不需要 asyncio mark)
|
||
@pytest.mark.filterwarnings("ignore::pytest.PytestWarning")
|
||
class TestDateToTimestamps:
|
||
def test_specific_date(self) -> None:
|
||
start, end = MiHealthClient._date_to_timestamps(date(2024, 6, 4))
|
||
assert end - start == 86399 # 23:59:59
|
||
|
||
def test_today_default(self) -> None:
|
||
start, end = MiHealthClient._date_to_timestamps()
|
||
assert end - start == 86399
|
||
|
||
|
||
# endregion
|