feat: add miband bot application

This commit is contained in:
Alex
2026-05-24 21:53:04 +03:00
parent 23876f70e2
commit 7962c423b4
71 changed files with 12533 additions and 0 deletions
View File
+355
View File
@@ -0,0 +1,355 @@
"""conftest.py —— pytest 公共 fixture。"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from mi_fitness.auth import XiaomiAuth
from mi_fitness.models import AuthToken
@pytest.fixture
def ssecurity() -> str:
"""测试用 ssecuritybase64 编码的 16 字节密钥)。"""
return "56nienlY7Ayh4VVJ0ywGGg=="
@pytest.fixture
def auth_token(ssecurity: str) -> AuthToken:
"""构造一个已登录的 AuthToken。"""
return AuthToken(
user_id="123456",
c_user_id="c_test_user",
service_token="test_service_token",
ssecurity=ssecurity,
pass_token="test_pass_token",
device_id="an_test_device",
)
@pytest.fixture
def mock_auth(auth_token: AuthToken) -> XiaomiAuth:
"""构造一个已登录的 mock XiaomiAuth 实例。"""
auth = XiaomiAuth.__new__(XiaomiAuth)
auth.username = "test"
auth._password = ""
auth.token = auth_token
auth._http = MagicMock()
auth._ticket_token = ""
auth._token_path = None
return auth
# region 常用 API 响应模板
RELATIVE_LIST_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"relative_list": [
{
"relative_uid": 1452722403,
"relative_note": "妈妈",
"relative_icon": "https://example.com/avatar.jpg",
"latest_data_time": 1717488000,
"latest_abnormal_record_time": 0,
"source_tag": 1,
},
{
"relative_uid": 9876543210,
"relative_note": "爸爸",
"relative_icon": "",
"latest_data_time": 1717484400,
"latest_abnormal_record_time": 0,
"source_tag": 1,
},
]
},
}
VERIFY_USER_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"userId": 1452722403,
"nickname": "测试用户",
"icon": "https://example.com/icon.jpg",
},
}
INVITE_RESPONSE = {
"code": 0,
"message": "ok",
"result": {"send_ret": 1},
}
DELETE_RESPONSE = {
"code": 0,
"message": "ok",
"result": {"delete_ret": True},
}
OPERATE_INVITE_RESPONSE = {
"code": 0,
"message": "ok",
"result": {"operate_ret": True},
}
INVITE_ID_RESPONSE = {
"code": 0,
"message": "ok",
"result": {"invite_link_id": 467184968352742400},
}
SHARED_TYPES_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"keys": ["goal", "heart_rate", "sleep", "steps"],
},
}
LATEST_DATA_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"data_list": [
{
"time": 1717488000,
"key": "goal",
"value": (
'{"date_time":1717488000,"goal_items":['
'{"field":2,"target_value":400,"achieved_value":13},'
'{"field":1,"target_value":6000,"achieved_value":3716}'
"]}"
),
},
{
"time": 1717488000,
"key": "heart_rate",
"value": '{"time":1717491600,"bpm":84}',
},
{
"time": 1717488000,
"key": "sleep",
"value": (
'{"date_time":1717488000,"total_duration":436,'
'"sleep_stage":3,"sleep_score":86,'
'"long_sleep_evaluation":7,"day_sleep_evaluation":0}'
),
},
{
"time": 1717488000,
"key": "steps",
"value": '{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}',
},
{
"time": 1717488000,
"key": "calories",
"value": '{"date_time":1717488000,"calories":230,"goal":300}',
},
{
"time": 1717488000,
"key": "valid_stand",
"value": '{"date_time":1717488000,"count":7}',
},
{
"time": 1717488000,
"key": "intensity",
"value": '{"date_time":1717488000,"duration":15}',
},
{
"time": 1717488000,
"key": "weight",
"value": '{"time":1717488000,"weight":65.5,"bmi":22.1}',
},
{
"time": 1717488000,
"key": "spo2",
"value": '{"time":1717495200,"spo2":96}',
},
{
"key": "blood_pressure",
},
],
"latest_data_time": 1717495200,
},
}
AGGREGATED_HR_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"data_list": [
{
"sid": "miothealth",
"tag": "daily_report",
"key": "heart_rate",
"time": 1717430400,
"value": (
'{"avg_hr":72,"avg_rhr":62,"max_hr":120,"min_hr":55,'
'"latest_hr":{"bpm":75,"time":1717488000},'
'"abnormal_hr_count":0,'
'"aerobic_hr_zone_duration":30,'
'"anaerobic_hr_zone_duration":0,'
'"extreme_hr_zone_duration":0,'
'"fat_burning_hr_zone_duration":15,'
'"warm_up_hr_zone_duration":10}'
),
"update_time": 1717488000,
"watermark": "w1",
"source_sid_list": [],
}
],
"has_more": False,
},
}
AGGREGATED_SLEEP_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"data_list": [
{
"sid": "miothealth",
"tag": "daily_report",
"key": "sleep",
"time": 1717430400,
"value": (
'{"total_duration":480,"sleep_score":85,"sleep_stage":4,'
'"sleep_deep_duration":120,"sleep_light_duration":200,'
'"sleep_rem_duration":100,"sleep_awake_duration":60,'
'"long_sleep_evaluation":1,"day_sleep_evaluation":0,'
'"avg_hr":60,"max_hr":80,"min_hr":50,"avg_spo2":97,'
'"segment_details":[{"bedtime":1717365600,"wake_up_time":1717394400,'
'"duration":480,"sleep_deep_duration":120,"sleep_light_duration":200,'
'"timezone":28800,"awake_count":2,"sleep_awake_duration":60}]}'
),
"update_time": 1717488000,
"watermark": "w2",
"source_sid_list": [],
}
],
"has_more": False,
},
}
AGGREGATED_STEPS_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"data_list": [
{
"sid": "miothealth",
"tag": "daily_report",
"key": "steps",
"time": 1717430400,
"value": '{"steps":8500,"distance":6200,"calories":320}',
"update_time": 1717488000,
"watermark": "w3",
"source_sid_list": [],
}
],
"has_more": False,
},
}
FITNESS_WEIGHT_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"data_list": [
{
"sid": "xiaomiwear_app_manually",
"tag": "",
"key": "weight",
"time": 1773753142,
"value": '{"bmi":16.97531,"time":1773753142,"weight":55.0}',
"update_time": 1773753142,
"watermark": "w5",
"source_sid_list": [],
}
],
"has_more": False,
},
}
FITNESS_BLOOD_PRESSURE_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"data_list": [
{
"sid": "xiaomiwear_app_manually",
"tag": "",
"key": "blood_pressure",
"time": 1773753098,
"value": (
'{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}'
),
"update_time": 1773753098,
"watermark": "w6",
"source_sid_list": [],
}
],
"has_more": False,
},
}
FAMILY_MEMBER_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"family_user_list": [
{"userId": 123, "nickname": "家人1"},
]
},
}
MESSAGE_LIST_RESPONSE = {
"code": 0,
"message": "ok",
"result": {
"messages": [
{
"msg_id": 152824796151809,
"module": 1,
"type": 1,
"receiver": 3188565001,
"sender": 1452722403,
"extra_data": '{"invite_id":4777767,"auth_data":["heart_rate","sleep"],"nick_name":"测试用户","icon":"https://example.com/avatar.jpg"}',
"is_new": 2,
"data_status": 0,
"create_time": 1772628283,
"last_modify": 1772628283,
},
{
"msg_id": 152821515157506,
"module": 1,
"type": 5,
"receiver": 3188565001,
"sender": 1452722403,
"extra_data": '{"nick_name":"测试用户","icon":"https://example.com/avatar.jpg"}',
"is_new": 2,
"data_status": 1,
"create_time": 1772625154,
"last_modify": 1772625154,
},
],
"msg_total": 2,
},
}
CHECK_NEW_MSG_RESPONSE = {
"code": 0,
"message": "ok",
"result": [{"module": 1, "is_new": True}],
}
CHECK_NO_NEW_MSG_RESPONSE = {
"code": 0,
"message": "ok",
"result": [{"module": 1, "is_new": False}],
}
# endregion
+247
View File
@@ -0,0 +1,247 @@
"""测试认证模块的内部编排逻辑。"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from mi_fitness.auth import XiaomiAuth
from mi_fitness.auth import manager as auth_manager
from mi_fitness.auth import passtoken as passtoken_module
from mi_fitness.auth import password as password_module
from mi_fitness.auth import qr as qr_module
from mi_fitness.exceptions import (
AuthError,
CaptchaRequiredError,
DeviceUntrustedError,
TokenExpiredError,
)
from mi_fitness.models import AuthToken
pytestmark = pytest.mark.asyncio
class _DummyResponse:
def __init__(self, text: str):
self.text = text
def raise_for_status(self) -> None:
"""测试响应默认视为成功。"""
def _http_response(text: str, status_code: int = 200) -> httpx.Response:
request = httpx.Request("GET", "https://example.com/test")
return httpx.Response(status_code=status_code, text=text, request=request)
def _make_auth() -> XiaomiAuth:
auth = XiaomiAuth("13800138000", "secret")
auth._http = MagicMock()
auth._http.cookies = MagicMock()
return auth
async def test_send_verification_code_retries_captcha_once(monkeypatch: pytest.MonkeyPatch) -> None:
auth = _make_auth()
ensure_ready = AsyncMock()
send_ticket = AsyncMock(
side_effect=[
CaptchaRequiredError("需要图形验证码", captcha_url="https://example.com/captcha.png"),
None,
]
)
get_phone_info = AsyncMock(return_value=("191******54", "ticket-token"))
fetch_captcha = AsyncMock(return_value=b"captcha-image")
captcha_handler = AsyncMock(return_value="ABCD")
monkeypatch.setattr(auth_manager._pwd, "ensure_ticket_login_ready", ensure_ready)
monkeypatch.setattr(auth_manager._pwd, "send_ticket", send_ticket)
monkeypatch.setattr(auth_manager._pwd, "get_phone_info", get_phone_info)
monkeypatch.setattr(auth_manager._pwd, "fetch_captcha_image", fetch_captcha)
phone = await auth.send_verification_code(captcha_handler=captcha_handler)
assert phone == "191******54"
assert auth._ticket_token == "ticket-token"
ensure_ready.assert_awaited_once_with(auth._http)
assert [call.kwargs["captcha_code"] for call in send_ticket.await_args_list] == ["", "ABCD"]
get_phone_info.assert_awaited_once_with(auth._http, auth.username, captcha_code="")
fetch_captcha.assert_awaited_once_with(auth._http, "https://example.com/captcha.png")
captcha_handler.assert_awaited_once_with(b"captcha-image")
async def test_send_verification_code_stops_after_max_retries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
auth = _make_auth()
captcha_error = CaptchaRequiredError(
"需要图形验证码",
captcha_url="https://example.com/captcha.png",
)
send_ticket = AsyncMock(side_effect=[captcha_error] * auth_manager._MAX_CAPTCHA_RETRIES)
get_phone_info = AsyncMock()
fetch_captcha = AsyncMock(return_value=b"captcha-image")
captcha_handler = AsyncMock(return_value="ABCD")
monkeypatch.setattr(auth_manager._pwd, "ensure_ticket_login_ready", AsyncMock())
monkeypatch.setattr(auth_manager._pwd, "send_ticket", send_ticket)
monkeypatch.setattr(auth_manager._pwd, "get_phone_info", get_phone_info)
monkeypatch.setattr(auth_manager._pwd, "fetch_captcha_image", fetch_captcha)
with pytest.raises(AuthError, match="已连续重试 3 次"):
await auth.send_verification_code(captcha_handler=captcha_handler)
assert send_ticket.await_count == auth_manager._MAX_CAPTCHA_RETRIES
assert fetch_captcha.await_count == auth_manager._MAX_CAPTCHA_RETRIES
assert captcha_handler.await_count == auth_manager._MAX_CAPTCHA_RETRIES
get_phone_info.assert_not_awaited()
async def test_login_passtoken_uses_shared_service_token_extractor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
http = MagicMock()
http.cookies = MagicMock()
http.get = AsyncMock(
return_value=_DummyResponse(
'&&&START&&&{"ssecurity":"sec","location":"https://example.com/cb?foo=1","nonce":"nonce","cUserId":"cid"}'
)
)
extract_service_token = AsyncMock(return_value="service-token")
monkeypatch.setattr(passtoken_module, "extract_service_token", extract_service_token)
token = AuthToken()
await passtoken_module.login_passtoken(
http,
token,
pass_token="pass-token",
user_id="user-id",
device_id="an_device",
)
assert token.ssecurity == "sec"
assert token.c_user_id == "cid"
assert token.service_token == "service-token"
extract_service_token.assert_awaited_once()
await_args = extract_service_token.await_args
assert await_args is not None
redirect_url = await_args.args[1]
assert redirect_url.startswith("https://example.com/cb?foo=1&clientSign=")
async def test_refresh_reuses_existing_token_and_persists(monkeypatch: pytest.MonkeyPatch) -> None:
auth = _make_auth()
auth.token = AuthToken(
user_id="user-id",
pass_token="pass-token",
device_id="an_device",
service_token="old-token",
ssecurity="old-sec",
)
auth._token_path = Path("token.json")
save_token = MagicMock()
auth.save_token = save_token # type: ignore[method-assign]
async def fake_login_passtoken(*args, **kwargs) -> None:
auth.token.service_token = "new-token"
auth.token.ssecurity = "new-sec"
login_passtoken = AsyncMock(side_effect=fake_login_passtoken)
monkeypatch.setattr(auth_manager, "_pt", MagicMock(login_passtoken=login_passtoken))
monkeypatch.setattr(auth_manager, "_sts", MagicMock(sts_exchange=AsyncMock()))
token = await auth.refresh()
assert token.service_token == "new-token"
assert token.ssecurity == "new-sec"
login_passtoken.assert_awaited_once_with(
auth._http,
auth.token,
pass_token="pass-token",
user_id="user-id",
device_id="an_device",
)
save_token.assert_called_once_with(Path("token.json"))
async def test_refresh_requires_pass_token() -> None:
auth = _make_auth()
auth.token = AuthToken(user_id="user-id", pass_token="")
with pytest.raises(TokenExpiredError, match="无法自动刷新"):
await auth.refresh()
async def test_password_login_wrong_password_raises_auth_error() -> None:
http = MagicMock()
http.post = AsyncMock(
return_value=_http_response('&&&START&&&{"code":70002,"desc":"invalid credential"}')
)
with pytest.raises(AuthError, match="登录失败"):
await password_module._raw_submit_login(http, "13800138000", "bad", "sign", "callback")
async def test_submit_login_requires_device_verification() -> None:
http = MagicMock()
token = AuthToken()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
password_module,
"_raw_submit_login",
AsyncMock(return_value={"code": 70016}),
)
with pytest.raises(DeviceUntrustedError, match="二次验证"):
await password_module.submit_login(
http, token, "13800138000", "secret", "sign", "callback"
)
async def test_submit_login_requires_trusted_device_when_security_status_non_zero() -> None:
http = MagicMock()
token = AuthToken()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
password_module,
"_raw_submit_login",
AsyncMock(return_value={"code": 0, "securityStatus": 8}),
)
with pytest.raises(DeviceUntrustedError, match="设备未受信任"):
await password_module.submit_login(
http, token, "13800138000", "secret", "sign", "callback"
)
async def test_qr_login_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
http = MagicMock()
http.get = AsyncMock(
return_value=_http_response(
'&&&START&&&{"qr":"https://example.com/qr.png","lp":"https://example.com/poll","timeout":0}'
)
)
time_values = iter([0.0, 0.0, 1.0])
monkeypatch.setattr(qr_module.time, "time", lambda: next(time_values))
with pytest.raises(AuthError, match="二维码扫码超时"):
await qr_module.login_qr(http, AuthToken(), max_wait=0)
async def test_login_passtoken_requires_non_empty_inputs() -> None:
http = MagicMock()
http.cookies = MagicMock()
token = AuthToken()
with pytest.raises(AuthError, match="passToken 不能为空"):
await passtoken_module.login_passtoken(http, token, pass_token="", user_id="uid")
with pytest.raises(AuthError, match="userId 不能为空"):
await passtoken_module.login_passtoken(http, token, pass_token="pt", user_id="")
@@ -0,0 +1,165 @@
"""测试认证辅助函数与 STS 交换。"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from mi_fitness.auth import _helpers as auth_helpers
from mi_fitness.auth import sts as auth_sts
from mi_fitness.exceptions import AuthError
from mi_fitness.models import AuthToken
def _response(
*,
text: str = "",
headers: dict[str, str] | None = None,
) -> httpx.Response:
request = httpx.Request("GET", "https://example.com/test")
return httpx.Response(200, text=text, headers=headers, request=request)
def test_parse_mi_response_supports_start_prefix() -> None:
parsed = auth_helpers.parse_mi_response('&&&START&&&{"code":0,"desc":"ok"}')
assert parsed == {"code": 0, "desc": "ok"}
def test_parse_mi_response_raises_auth_error_for_invalid_json() -> None:
with pytest.raises(AuthError, match="响应解析失败"):
auth_helpers.parse_mi_response("&&&START&&&not-json")
def test_normalize_captcha_url_prepends_account_domain() -> None:
assert (
auth_helpers.normalize_captcha_url("/pass/getCode?id=1")
== "https://account.xiaomi.com/pass/getCode?id=1"
)
assert (
auth_helpers.normalize_captcha_url("https://example.com/captcha")
== "https://example.com/captcha"
)
def test_set_cookie_for_domains_writes_both_domains() -> None:
http = MagicMock()
http.cookies = MagicMock()
auth_helpers.set_cookie_for_domains(http, "serviceToken", "st")
assert http.cookies.set.call_count == 2
assert http.cookies.set.call_args_list[0].args == ("serviceToken", "st")
assert http.cookies.set.call_args_list[0].kwargs["domain"] == "xiaomi.com"
assert http.cookies.set.call_args_list[1].kwargs["domain"] == "mi.com"
@pytest.mark.asyncio
async def test_extract_service_token_prefers_set_cookie_header() -> None:
http = MagicMock()
http.get = AsyncMock(
return_value=_response(
headers={
"set-cookie": "serviceToken=header-token; Path=/; HttpOnly",
}
)
)
http.cookies.get.return_value = ""
token = await auth_helpers.extract_service_token(http, "https://example.com/login")
assert token == "header-token"
@pytest.mark.asyncio
async def test_extract_service_token_falls_back_to_redirect_query() -> None:
http = MagicMock()
http.get = AsyncMock(
return_value=_response(
headers={
"location": "https://example.com/callback?serviceToken=query-token",
}
)
)
http.cookies.get.return_value = ""
token = await auth_helpers.extract_service_token(http, "https://example.com/login")
assert token == "query-token"
@pytest.mark.asyncio
async def test_extract_service_token_falls_back_to_cookie_jar() -> None:
http = MagicMock()
http.get = AsyncMock(return_value=_response())
http.cookies.get.return_value = "cookie-token"
token = await auth_helpers.extract_service_token(http, "https://example.com/login")
assert token == "cookie-token"
@pytest.mark.asyncio
async def test_extract_service_token_raises_when_missing_everywhere() -> None:
http = MagicMock()
http.get = AsyncMock(return_value=_response())
http.cookies.get.return_value = ""
with pytest.raises(AuthError, match="未能获取 serviceToken"):
await auth_helpers.extract_service_token(http, "https://example.com/login")
@pytest.mark.asyncio
async def test_extract_credentials_populates_token_and_service_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
http = MagicMock()
token = AuthToken()
extract_service_token = AsyncMock(return_value="service-token")
monkeypatch.setattr(auth_helpers, "extract_service_token", extract_service_token)
await auth_helpers.extract_credentials(
http,
{
"ssecurity": "sec",
"userId": "3188565001",
"passToken": "pt",
"cUserId": "cid",
"location": "https://example.com/callback",
},
token,
)
assert token.ssecurity == "sec"
assert token.user_id == "3188565001"
assert token.pass_token == "pt"
assert token.c_user_id == "cid"
assert token.service_token == "service-token"
extract_service_token.assert_awaited_once_with(http, "https://example.com/callback")
@pytest.mark.asyncio
async def test_sts_exchange_uses_device_id_and_accepts_ok_response(
monkeypatch: pytest.MonkeyPatch,
) -> None:
http = MagicMock()
http.get = AsyncMock(return_value=_response(text="ok"))
token = AuthToken(device_id="an_device")
monkeypatch.setattr(auth_sts.time, "time", lambda: 1.234)
await auth_sts.sts_exchange(http, token)
http.get.assert_awaited_once()
params = http.get.await_args.kwargs["params"]
assert params["d"] == "an_device"
assert params["p_ts"] == "1234"
@pytest.mark.asyncio
async def test_sts_exchange_swallows_network_failure() -> None:
http = MagicMock()
http.get = AsyncMock(side_effect=RuntimeError("boom"))
token = AuthToken(device_id="an_device")
await auth_sts.sts_exchange(http, token)
+71
View File
@@ -0,0 +1,71 @@
"""测试 CLI 入口与二维码登录。"""
from __future__ import annotations
import shutil
from pathlib import Path
from types import SimpleNamespace
from typing import ClassVar, Self
from uuid import uuid4
import pytest
import mi_fitness.cli as cli
def _workspace_tmp_dir() -> Path:
root = Path(".test-tmp") / uuid4().hex
root.mkdir(parents=True, exist_ok=True)
return root
class _FakeAuth:
instances: ClassVar[list["_FakeAuth"]] = []
def __init__(self, *args: object):
self.args = args
self.token = SimpleNamespace(user_id="3188565001")
self.saved_path: Path | None = None
self.login_calls: list[tuple[object, ...]] = []
_FakeAuth.instances.append(self)
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def login_qr(self, qr_callback=None) -> None: # type: ignore[no-untyped-def]
self.login_calls.append(("qr",))
if qr_callback is not None:
await qr_callback("https://example.com/qr.png", "https://example.com/login")
def save_token(self, path: Path | str) -> None:
self.saved_path = Path(path)
@pytest.mark.asyncio
async def test_qr_login_saves_token(monkeypatch: pytest.MonkeyPatch) -> None:
_FakeAuth.instances.clear()
tmp_dir = _workspace_tmp_dir()
monkeypatch.setattr(cli, "XiaomiAuth", _FakeAuth)
monkeypatch.setattr(cli, "TOKEN_FILE", tmp_dir / "token.json")
try:
await cli._qr_login()
auth = _FakeAuth.instances[-1]
assert auth.login_calls == [("qr",)]
assert auth.saved_path == tmp_dir / "token.json"
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def test_main_runs_without_args(monkeypatch: pytest.MonkeyPatch) -> None:
called = False
async def fake_qr_login() -> None:
nonlocal called
called = True
monkeypatch.setattr(cli, "_qr_login", fake_qr_login)
cli.main()
assert called
+963
View File
@@ -0,0 +1,963 @@
"""测试 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
@@ -0,0 +1,138 @@
"""测试基础请求层的边界容错。"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import mi_fitness.client.base as client_base
from mi_fitness.client.base import encrypted_request
from mi_fitness.const import ERR_NOT_RELATIVES, ERR_NOT_SHARED_DATA_TYPE
from mi_fitness.exceptions import (
AuthError,
DataNotSharedError,
DataOutOfSharedTimeScopeError,
FamilyMemberNotFoundError,
)
pytestmark = pytest.mark.asyncio
def _mock_response(status_code: int = 200, text: str = "encrypted") -> httpx.Response:
request = httpx.Request("GET", "https://example.com/test")
return httpx.Response(status_code=status_code, text=text, request=request)
async def test_encrypted_request_accepts_string_zero_code(
monkeypatch: pytest.MonkeyPatch,
auth_token,
) -> None:
http = MagicMock()
http.get = AsyncMock(return_value=_mock_response())
monkeypatch.setattr(
client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"}
)
monkeypatch.setattr(
client_base,
"decrypt_response",
lambda *args, **kwargs: {"code": "0", "result": {"ok": True}},
)
result = await encrypted_request(http, auth_token, "GET", "/app/v1/test")
assert result["result"]["ok"] is True
async def test_encrypted_request_uses_string_code_for_not_relatives(
monkeypatch: pytest.MonkeyPatch,
auth_token,
) -> None:
http = MagicMock()
http.get = AsyncMock(return_value=_mock_response())
monkeypatch.setattr(
client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"}
)
monkeypatch.setattr(
client_base,
"decrypt_response",
lambda *args, **kwargs: {
"code": str(ERR_NOT_RELATIVES),
"desc": "not relatives",
},
)
with pytest.raises(FamilyMemberNotFoundError, match="not relatives"):
await encrypted_request(http, auth_token, "GET", "/app/v1/test")
async def test_encrypted_request_requires_authenticated_token(auth_token) -> None:
http = MagicMock()
auth_token.service_token = ""
with pytest.raises(AuthError, match="未登录"):
await encrypted_request(http, auth_token, "GET", "/app/v1/test")
async def test_encrypted_request_uses_string_code_for_data_not_shared(
monkeypatch: pytest.MonkeyPatch,
auth_token,
) -> None:
http = MagicMock()
http.get = AsyncMock(return_value=_mock_response())
monkeypatch.setattr(
client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"}
)
monkeypatch.setattr(
client_base,
"decrypt_response",
lambda *args, **kwargs: {
"code": str(ERR_NOT_SHARED_DATA_TYPE),
"desc": "not shared data type",
},
)
with pytest.raises(DataNotSharedError, match="not shared data type") as exc_info:
await encrypted_request(
http,
auth_token,
"GET",
"/app/v1/relatives/get_aggregated_data",
params={"relative_uid": 1, "key": "heart_rate"},
)
assert exc_info.value.data_type == "heart_rate"
async def test_encrypted_request_raises_time_scope_error_for_out_of_range_dates(
monkeypatch: pytest.MonkeyPatch,
auth_token,
) -> None:
http = MagicMock()
http.get = AsyncMock(return_value=_mock_response())
monkeypatch.setattr(
client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"}
)
monkeypatch.setattr(
client_base,
"decrypt_response",
lambda *args, **kwargs: {
"code": str(ERR_NOT_SHARED_DATA_TYPE),
"message": "time out of data shared time scope",
},
)
with pytest.raises(DataOutOfSharedTimeScopeError, match="超出亲友共享时间范围") as exc_info:
await encrypted_request(
http,
auth_token,
"GET",
"/app/v1/relatives/get_aggregated_data",
params={"relative_uid": 1, "key": "steps"},
)
assert exc_info.value.data_type == "steps"
@@ -0,0 +1,67 @@
"""测试常量 (const.py)。"""
from __future__ import annotations
from mi_fitness.const import (
ALL_SHARED_DATA_TYPES,
HEALTH_API_BASE,
RELATIVES_AGGREGATED_DATA_PATH,
RELATIVES_DELETE_PATH,
RELATIVES_FITNESS_DATA_PATH,
RELATIVES_GET_APPLIED_SHARED_TYPES_PATH,
RELATIVES_GET_FAMILY_MEMBER_PATH,
RELATIVES_GET_INVITE_ID_PATH,
RELATIVES_GET_SHARED_TYPES_PATH,
RELATIVES_GET_TOPIC_SUBS_PATH,
RELATIVES_LATEST_DATA_PATH,
RELATIVES_LIST_PATH,
RELATIVES_SEND_INVITE_PATH,
RELATIVES_VERIFY_USER_PATH,
VERIFY_TYPE_XIAOMI_ID,
)
class TestAPIEndpoints:
"""API 端点常量测试。"""
def test_base_url_is_https(self) -> None:
assert HEALTH_API_BASE.startswith("https://")
def test_all_paths_start_with_slash(self) -> None:
paths = [
RELATIVES_LIST_PATH,
RELATIVES_LATEST_DATA_PATH,
RELATIVES_AGGREGATED_DATA_PATH,
RELATIVES_FITNESS_DATA_PATH,
RELATIVES_VERIFY_USER_PATH,
RELATIVES_SEND_INVITE_PATH,
RELATIVES_DELETE_PATH,
RELATIVES_GET_SHARED_TYPES_PATH,
RELATIVES_GET_APPLIED_SHARED_TYPES_PATH,
RELATIVES_GET_FAMILY_MEMBER_PATH,
RELATIVES_GET_INVITE_ID_PATH,
RELATIVES_GET_TOPIC_SUBS_PATH,
]
for path in paths:
assert path.startswith(("/app/v1/relatives/", "/app/v1/data/")), f"{path} API 路径前缀不正确"
class TestSharedDataTypes:
"""共享数据类型常量测试。"""
def test_has_10_types(self) -> None:
assert len(ALL_SHARED_DATA_TYPES) == 10
def test_contains_core_types(self) -> None:
for key in ["heart_rate", "sleep", "steps", "weight", "spo2"]:
assert key in ALL_SHARED_DATA_TYPES, f"缺少 {key}"
def test_no_duplicates(self) -> None:
assert len(ALL_SHARED_DATA_TYPES) == len(set(ALL_SHARED_DATA_TYPES))
class TestVerifyTypes:
"""验证类型常量测试。"""
def test_values(self) -> None:
assert VERIFY_TYPE_XIAOMI_ID == 1
+199
View File
@@ -0,0 +1,199 @@
"""测试加密模块 (crypto.py)。
覆盖:RC4、nonce、签名、加密/解密往返。
"""
from __future__ import annotations
import base64
import json
from mi_fitness.crypto import (
_build_sig_message,
_rc4_crypt,
_sha1_b64,
build_encrypted_params,
compute_signed_nonce,
decrypt_data,
decrypt_response,
encrypt_data,
generate_nonce,
)
class TestRC4:
"""RC4 加密/解密测试。"""
def test_encrypt_decrypt_roundtrip(self) -> None:
"""加密后解密应还原明文。"""
key = b"test_key_123456"
plaintext = b"Hello, MiSDK!"
encrypted = _rc4_crypt(key, plaintext)
decrypted = _rc4_crypt(key, encrypted)
assert decrypted == plaintext
def test_different_keys_produce_different_output(self) -> None:
"""不同密钥应产生不同密文。"""
data = b"same data"
enc1 = _rc4_crypt(b"key_aaa", data)
enc2 = _rc4_crypt(b"key_bbb", data)
assert enc1 != enc2
def test_empty_data(self) -> None:
"""空数据加密应返回空。"""
result = _rc4_crypt(b"key", b"")
assert result == b""
def test_skip_parameter(self) -> None:
"""skip=0 和 skip=1024 应产生不同结果。"""
key = b"test"
data = b"data"
r1 = _rc4_crypt(key, data, skip=0)
r2 = _rc4_crypt(key, data, skip=1024)
assert r1 != r2
def test_large_data(self) -> None:
"""大数据块也能正确往返。"""
key = b"large_key"
data = b"x" * 100_000
assert _rc4_crypt(key, _rc4_crypt(key, data)) == data
class TestNonce:
"""nonce 生成测试。"""
def test_nonce_is_base64(self) -> None:
"""nonce 应为有效的 base64 字符串。"""
nonce = generate_nonce()
decoded = base64.b64decode(nonce)
assert len(decoded) == 12 # 8 random + 4 time
def test_nonce_unique(self) -> None:
"""连续生成的 nonce 应不同(随机部分不同)。"""
nonces = {generate_nonce() for _ in range(10)}
assert len(nonces) == 10
class TestSignedNonce:
"""compute_signed_nonce 测试。"""
def test_deterministic(self, ssecurity: str) -> None:
"""相同输入应产生相同结果。"""
nonce = generate_nonce()
r1 = compute_signed_nonce(ssecurity, nonce)
r2 = compute_signed_nonce(ssecurity, nonce)
assert r1 == r2
def test_output_is_base64(self, ssecurity: str) -> None:
"""输出应为有效 base64。"""
nonce = generate_nonce()
snonce = compute_signed_nonce(ssecurity, nonce)
decoded = base64.b64decode(snonce)
assert len(decoded) == 32 # SHA256
class TestSignature:
"""签名生成测试(纯 SHA1 + Base64)。"""
def test_sha1_b64_output(self) -> None:
"""SHA1 base64 输出应为 28 字符。"""
result = _sha1_b64("test message")
decoded = base64.b64decode(result)
assert len(decoded) == 20 # SHA1
def test_sig_msg_format(self) -> None:
"""签名消息应为 METHOD&/path&k=v&signedNonce 格式。"""
msg = _build_sig_message("GET", "/test/path", {"data": "hello"}, "snonce123")
assert msg == "GET&/test/path&data=hello&snonce123"
def test_sig_msg_method_uppercase(self) -> None:
"""方法名应转为大写。"""
msg = _build_sig_message("get", "/path", {}, "sn")
assert msg.startswith("GET&")
def test_sig_msg_adds_leading_slash(self) -> None:
"""路径没有前导 / 时应自动添加。"""
msg = _build_sig_message("GET", "path", {}, "sn")
assert "&/path&" in msg
def test_sig_msg_sorted_params(self) -> None:
"""参数应按 key 字典序排序。"""
msg = _build_sig_message("GET", "/p", {"z": "1", "a": "2"}, "sn")
assert msg == "GET&/p&a=2&z=1&sn"
class TestEncryptDecryptData:
"""encrypt_data / decrypt_data 测试。"""
def test_roundtrip(self, ssecurity: str) -> None:
"""加密后解密应还原。"""
nonce = generate_nonce()
snonce = compute_signed_nonce(ssecurity, nonce)
plaintext = '{"key": "value", "中文": "测试"}'
encrypted = encrypt_data(snonce, plaintext)
decrypted = decrypt_data(snonce, encrypted)
assert decrypted == plaintext
def test_encrypted_is_base64(self, ssecurity: str) -> None:
"""密文应为 base64 编码。"""
nonce = generate_nonce()
snonce = compute_signed_nonce(ssecurity, nonce)
encrypted = encrypt_data(snonce, "test")
base64.b64decode(encrypted) # 不抛异常即可
class TestBuildEncryptedParams:
"""build_encrypted_params 集成测试。"""
def test_has_required_keys(self, ssecurity: str) -> None:
"""返回应包含 data, _nonce, signature, rc4_hash__。"""
result = build_encrypted_params(
"GET",
"/app/v1/relatives/get_relative_list",
ssecurity,
{"relative_uid": 123},
)
assert "data" in result
assert "_nonce" in result
assert "signature" in result
assert "rc4_hash__" in result
def test_no_params_no_data_key(self, ssecurity: str) -> None:
"""无参数时不应有 data 字段。"""
result = build_encrypted_params(
"GET",
"/app/v1/relatives/get_relative_list",
ssecurity,
)
assert "data" not in result
assert "_nonce" in result
def test_can_decrypt_own_params(self, ssecurity: str) -> None:
"""能解密自己加密的参数。"""
params = {"test": "hello", "num": 42}
result = build_encrypted_params("POST", "/test", ssecurity, params)
snonce = compute_signed_nonce(ssecurity, result["_nonce"])
decrypted = decrypt_data(snonce, result["data"])
parsed = json.loads(decrypted)
assert parsed == params
class TestDecryptResponse:
"""decrypt_response 集成测试。"""
def test_roundtrip(self, ssecurity: str) -> None:
"""构造加密响应并解密。"""
nonce = generate_nonce()
snonce = compute_signed_nonce(ssecurity, nonce)
original = {"code": 0, "result": {"data": "test"}}
encrypted = encrypt_data(snonce, json.dumps(original))
decrypted = decrypt_response(ssecurity, nonce, encrypted)
assert decrypted == original
def test_non_json_returns_string(self, ssecurity: str) -> None:
"""非 JSON 响应应返回字符串。"""
nonce = generate_nonce()
snonce = compute_signed_nonce(ssecurity, nonce)
encrypted = encrypt_data(snonce, "not json at all")
result = decrypt_response(ssecurity, nonce, encrypted)
assert result == "not json at all"
@@ -0,0 +1,80 @@
"""测试异常类 (exceptions.py)。"""
from __future__ import annotations
from mi_fitness.exceptions import (
APIError,
AuthError,
CaptchaRequiredError,
DataNotSharedError,
DataOutOfSharedTimeScopeError,
FamilyMemberNotFoundError,
MiSDKError,
TokenExpiredError,
)
class TestExceptionHierarchy:
"""异常继承关系测试。"""
def test_base_exception(self) -> None:
e = MiSDKError("test")
assert str(e) == "test"
assert isinstance(e, Exception)
def test_auth_error_inherits(self) -> None:
e = AuthError("auth fail")
assert isinstance(e, MiSDKError)
def test_token_expired_inherits_auth(self) -> None:
e = TokenExpiredError("expired")
assert isinstance(e, AuthError)
assert isinstance(e, MiSDKError)
def test_api_error_attributes(self) -> None:
e = APIError(
"api fail",
status_code=500,
code=1001,
response_body='{"error": true}',
)
assert isinstance(e, MiSDKError)
assert e.status_code == 500
assert e.code == 1001
assert e.response_body == '{"error": true}'
assert str(e) == "api fail"
def test_api_error_defaults(self) -> None:
e = APIError("msg")
assert e.status_code == 0
assert e.code == 0
assert e.response_body == ""
def test_family_member_not_found(self) -> None:
e = FamilyMemberNotFoundError("未找到")
assert isinstance(e, MiSDKError)
def test_captcha_required_inherits_auth(self) -> None:
e = CaptchaRequiredError("需要验证码")
assert isinstance(e, AuthError)
assert isinstance(e, MiSDKError)
def test_captcha_required_url(self) -> None:
url = "https://account.xiaomi.com/pass/getCode?icodeType=login"
e = CaptchaRequiredError("验证码风控", captcha_url=url)
assert e.captcha_url == url
assert str(e) == "验证码风控"
def test_captcha_required_default_url(self) -> None:
e = CaptchaRequiredError("msg")
assert e.captcha_url == ""
def test_data_not_shared_error(self) -> None:
e = DataNotSharedError("未共享", data_type="heart_rate")
assert isinstance(e, MiSDKError)
assert e.data_type == "heart_rate"
def test_time_scope_error_inherits_data_not_shared(self) -> None:
e = DataOutOfSharedTimeScopeError("超出范围", data_type="steps")
assert isinstance(e, DataNotSharedError)
assert e.data_type == "steps"
+83
View File
@@ -0,0 +1,83 @@
"""测试重试 HTTP 客户端 (http.py)。"""
from __future__ import annotations
import httpx
import pytest
from mi_fitness.http import RetryAsyncClient
pytestmark = pytest.mark.asyncio
async def test_retries_on_idempotent_get_status_code() -> None:
"""GET 遇到可重试状态码时应自动重试。"""
call_count = 0
async def handler(_: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count < 3:
return httpx.Response(status_code=503, json={"message": "busy"})
return httpx.Response(status_code=200, json={"ok": True})
transport = httpx.MockTransport(handler)
async with RetryAsyncClient(
transport=transport,
retry_attempts=3,
retry_wait_min=0.0,
retry_wait_max=0.0,
retry_wait_multiplier=0.0,
) as client:
resp = await client.get("https://example.com/ping")
assert resp.status_code == 200
assert call_count == 3
async def test_does_not_retry_non_idempotent_post_by_default() -> None:
"""POST 默认不重试,避免非幂等接口重复提交。"""
call_count = 0
async def handler(_: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
return httpx.Response(status_code=503, json={"message": "busy"})
transport = httpx.MockTransport(handler)
async with RetryAsyncClient(
transport=transport,
retry_attempts=3,
retry_wait_min=0.0,
retry_wait_max=0.0,
retry_wait_multiplier=0.0,
) as client:
resp = await client.post("https://example.com/send", data={"a": "1"})
assert resp.status_code == 503
assert call_count == 1
async def test_retries_on_network_error_for_get() -> None:
"""GET 遇到网络异常时应自动重试。"""
call_count = 0
async def handler(_: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count < 2:
raise httpx.ConnectError("connect failed")
return httpx.Response(status_code=200, json={"ok": True})
transport = httpx.MockTransport(handler)
async with RetryAsyncClient(
transport=transport,
retry_attempts=3,
retry_wait_min=0.0,
retry_wait_max=0.0,
retry_wait_multiplier=0.0,
) as client:
resp = await client.get("https://example.com/health")
assert resp.status_code == 200
assert call_count == 2
+807
View File
@@ -0,0 +1,807 @@
"""测试数据模型 (models.py)。
覆盖:模型构造、字段解析、响应包装器的属性方法。
"""
from __future__ import annotations
import json
from mi_fitness.models import (
AggregatedDataItem,
AggregatedDataResponse,
AuthToken,
BloodPressureData,
CaloriesData,
CheckNewMsgResponse,
DeleteRelativeResponse,
FamilyMember,
FamilyMemberResponse,
GoalData,
GoalMetric,
HeartRateData,
IntensityData,
InviteMessage,
InviteResponse,
InviteUniqueIdResponse,
LatestDataItem,
LatestDataResponse,
LatestDataSnapshot,
LatestHeartRate,
MessageListResponse,
OperateInviteResponse,
RelativeListResponse,
SharedDataTypesResponse,
SleepData,
SleepSegment,
Spo2Data,
Spo2SummaryData,
StepData,
ValidStandData,
VerifiedUserInfo,
VerifyUserResponse,
WeightData,
)
# region 基础模型测试
class TestAuthToken:
"""AuthToken 模型测试。"""
def test_defaults(self) -> None:
"""空构造应全部默认空串。"""
t = AuthToken()
assert t.user_id == ""
assert t.ssecurity == ""
assert t.service_token == ""
def test_serialization_roundtrip(self) -> None:
"""JSON 序列化往返。"""
t = AuthToken(user_id="123", ssecurity="abc")
data = t.model_dump_json()
t2 = AuthToken.model_validate_json(data)
assert t2.user_id == t.user_id
assert t2.ssecurity == t.ssecurity
class TestFamilyMember:
"""FamilyMember 模型测试。"""
def test_construction(self) -> None:
m = FamilyMember(relative_uid=123, relative_note="妈妈")
assert m.relative_uid == 123
assert m.relative_note == "妈妈"
assert m.latest_data_time == 0
def test_from_dict(self) -> None:
data = {
"relative_uid": 9999,
"relative_note": "爸爸",
"relative_icon": "https://img.example.com/a.jpg",
"latest_data_time": 1717488000,
"latest_abnormal_record_time": 0,
"source_tag": 1,
}
m = FamilyMember(**data)
assert m.relative_uid == 9999
assert m.source_tag == 1
class TestLatestHeartRate:
def test_defaults(self) -> None:
hr = LatestHeartRate()
assert hr.bpm == 0
assert hr.time == 0
class TestHeartRateData:
def test_full_construction(self) -> None:
hr = HeartRateData(
time=1717488000,
avg_hr=72,
max_hr=120,
min_hr=55,
latest_hr=LatestHeartRate(bpm=75, time=1717488000),
)
assert hr.avg_hr == 72
assert hr.latest_hr is not None
assert hr.latest_hr.bpm == 75
class TestSleepData:
def test_with_segments(self) -> None:
seg = SleepSegment(bedtime=100, wake_up_time=200, duration=100)
sd = SleepData(
time=1717488000,
total_duration=480,
sleep_score=85,
segment_details=[seg],
)
assert sd.total_duration == 480
assert len(sd.segment_details) == 1
assert sd.segment_details[0].duration == 100
class TestStepData:
def test_construction(self) -> None:
s = StepData(time=1717488000, steps=8500, distance=6200, calories=320)
assert s.steps == 8500
class TestWeightData:
def test_construction(self) -> None:
w = WeightData(time=1717488000, weight=65.5, bmi=22.1)
assert w.weight == 65.5
assert w.bmi == 22.1
class TestVerifiedUserInfo:
def test_alias_user_id(self) -> None:
"""userId 别名应映射到 user_id。"""
info = VerifiedUserInfo(**{"userId": 12345, "nickname": "测试", "icon": "url"})
assert info.user_id == 12345
assert info.nickname == "测试"
def test_populate_by_name(self) -> None:
"""也可以用 user_id 直接构造。"""
info = VerifiedUserInfo(user_id=999, nickname="直接") # type: ignore[call-arg]
assert info.user_id == 999
# endregion
# region LatestDataItem 测试
class TestLatestDataItem:
def test_parse_json_value(self) -> None:
item = LatestDataItem(
time=1717488000,
key="heart_rate",
value='{"avg_hr": 72}',
)
parsed = item.parse_value()
assert isinstance(parsed, dict)
assert parsed["avg_hr"] == 72
def test_parse_numeric_value(self) -> None:
item = LatestDataItem(time=1717488000, key="goal", value=10000)
parsed = item.parse_value()
assert parsed == 10000
def test_parse_invalid_json(self) -> None:
item = LatestDataItem(time=1717488000, key="bad", value="not{json")
parsed = item.parse_value()
assert parsed == {}
def test_parse_dict_value(self) -> None:
item = LatestDataItem(
time=1717488000,
key="weight",
value={"weight": 65.5}, # type: ignore[arg-type]
)
parsed = item.parse_value()
assert parsed == {"weight": 65.5}
def test_parse_json_list_returns_empty_dict(self) -> None:
item = LatestDataItem(time=1717488000, key="bad", value='["unexpected"]')
assert item.parse_value() == {}
def test_as_goal(self) -> None:
item = LatestDataItem(
time=1717488000,
key="goal",
value={
"date_time": 1717488000,
"goal_items": [
{"field": 1, "target_value": 6000, "achieved_value": 3716},
],
}, # type: ignore[arg-type]
)
goal = item.as_goal()
assert isinstance(goal, GoalData)
assert goal.time == 1717488000
assert len(goal.goal_items) == 1
assert goal.goal_items[0].target_value == 6000
assert goal.goal_items[0].metric == GoalMetric.STEPS
assert goal.goal_items[0].metric_key == "steps"
assert goal.goal_items[0].metric_label == "步数"
def test_goal_accessors_and_unknown_items(self) -> None:
goal = GoalData.model_validate(
{
"time": 1717488000,
"goal_items": [
{"field": 2, "target_value": 400, "achieved_value": 347},
{"field": 1, "target_value": 6000, "achieved_value": 6203},
{"field": 4, "target_value": 30, "achieved_value": 27},
{"field": 99, "target_value": 1, "achieved_value": 0},
],
}
)
assert goal.calories_goal is not None
assert goal.calories_goal.target_value == 400
assert goal.steps_goal is not None
assert goal.steps_goal.achieved_value == 6203
assert goal.intensity_goal is not None
assert goal.intensity_goal.target_value == 30
assert goal.available_metrics == [
GoalMetric.CALORIES,
GoalMetric.STEPS,
GoalMetric.INTENSITY,
]
assert len(goal.unknown_goal_items) == 1
assert goal.unknown_goal_items[0].metric is None
assert goal.unknown_goal_items[0].metric_key == "unknown:99"
assert goal.get_item(GoalMetric.STEPS) is goal.steps_goal
def test_as_heart_rate(self) -> None:
item = LatestDataItem(
time=1717488000,
key="heart_rate",
value='{"time":1717491600,"bpm":84}',
)
heart_rate = item.as_heart_rate()
assert isinstance(heart_rate, LatestHeartRate)
assert heart_rate.time == 1717491600
assert heart_rate.bpm == 84
def test_as_heart_rate_returns_none_for_invalid_payload(self) -> None:
item = LatestDataItem(
time=1717488000,
key="heart_rate",
value='{"time":1717491600,"bpm":{"bad":1}}',
)
assert item.as_heart_rate() is None
def test_as_sleep_uses_date_time_alias(self) -> None:
item = LatestDataItem(
time=1717488000,
key="sleep",
value='{"date_time":1717488000,"total_duration":436,"sleep_score":86}',
)
sleep = item.as_sleep()
assert isinstance(sleep, SleepData)
assert sleep.time == 1717488000
assert sleep.total_duration == 436
assert sleep.sleep_score == 86
def test_as_steps_and_latest_metrics(self) -> None:
steps_item = LatestDataItem(
time=1717488000,
key="steps",
value='{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}',
)
calories_item = LatestDataItem(
time=1717488000,
key="calories",
value='{"date_time":1717488000,"calories":230,"goal":300}',
)
stand_item = LatestDataItem(
time=1717488000,
key="valid_stand",
value='{"date_time":1717488000,"count":7}',
)
intensity_item = LatestDataItem(
time=1717488000,
key="intensity",
value='{"date_time":1717488000,"duration":15}',
)
spo2_item = LatestDataItem(
time=1717488000,
key="spo2",
value='{"time":1717495200,"spo2":96}',
)
steps = steps_item.as_steps()
calories = calories_item.as_calories()
stand = stand_item.as_valid_stand()
intensity = intensity_item.as_intensity()
spo2 = spo2_item.as_spo2()
assert isinstance(steps, StepData)
assert steps.goal == 6000
assert isinstance(calories, CaloriesData)
assert calories.goal == 300
assert isinstance(stand, ValidStandData)
assert stand.count == 7
assert isinstance(intensity, IntensityData)
assert intensity.duration == 15
assert isinstance(spo2, Spo2Data)
assert spo2.spo2 == 96
def test_as_blood_pressure_returns_none_when_value_missing(self) -> None:
item = LatestDataItem(time=1717488000, key="blood_pressure")
assert item.as_blood_pressure() is None
def test_as_blood_pressure_supports_fitness_aliases(self) -> None:
item = LatestDataItem(
time=1773753098,
key="blood_pressure",
value='{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}',
)
blood_pressure = item.as_blood_pressure()
assert isinstance(blood_pressure, BloodPressureData)
assert blood_pressure.systolic == 33
assert blood_pressure.diastolic == 30
assert blood_pressure.pulse == 60
# endregion
# region AggregatedDataItem 测试
class TestAggregatedDataItem:
def test_stringify_dict_value(self) -> None:
"""dict value 应被自动转为 JSON 字符串。"""
item = AggregatedDataItem(
time=1717488000,
key="heart_rate",
value={"avg_hr": 72}, # type: ignore[arg-type]
)
assert isinstance(item.value, str)
parsed = json.loads(item.value)
assert parsed["avg_hr"] == 72
def test_as_heart_rate(self) -> None:
value = json.dumps(
{
"avg_hr": 72,
"max_hr": 120,
"min_hr": 55,
"avg_rhr": 62,
"latest_hr": {"bpm": 75, "time": 1717488000},
}
)
item = AggregatedDataItem(time=1717430400, key="heart_rate", value=value)
hr = item.as_heart_rate()
assert isinstance(hr, HeartRateData)
assert hr.avg_hr == 72
assert hr.time == 1717430400
assert hr.latest_hr is not None
assert hr.latest_hr.bpm == 75
def test_as_sleep(self) -> None:
value = json.dumps(
{
"total_duration": 480,
"sleep_score": 85,
"segment_details": [
{"bedtime": 100, "wake_up_time": 200, "duration": 100},
],
}
)
item = AggregatedDataItem(time=1717430400, key="sleep", value=value)
sd = item.as_sleep()
assert isinstance(sd, SleepData)
assert sd.total_duration == 480
assert len(sd.segment_details) == 1
def test_as_sleep_ignores_invalid_segments(self) -> None:
value = json.dumps(
{
"total_duration": 480,
"segment_details": [
{"bedtime": 100, "wake_up_time": 200, "duration": 100},
"bad-segment",
1,
],
}
)
item = AggregatedDataItem(time=1717430400, key="sleep", value=value)
sd = item.as_sleep()
assert len(sd.segment_details) == 1
assert sd.segment_details[0].duration == 100
def test_as_steps(self) -> None:
value = json.dumps({"steps": 8500, "distance": 6200, "calories": 320})
item = AggregatedDataItem(time=1717430400, key="steps", value=value)
st = item.as_steps()
assert isinstance(st, StepData)
assert st.steps == 8500
assert st.time == 1717430400
def test_as_simple_latest_metrics(self) -> None:
calories_item = AggregatedDataItem(
time=1717430400, key="calories", value='{"calories":338}'
)
stand_item = AggregatedDataItem(time=1717430400, key="valid_stand", value='{"count":10}')
intensity_item = AggregatedDataItem(
time=1717430400, key="intensity", value='{"duration":19}'
)
calories = calories_item.as_calories()
stand = stand_item.as_valid_stand()
intensity = intensity_item.as_intensity()
assert isinstance(calories, CaloriesData)
assert calories.time == 1717430400
assert calories.calories == 338
assert isinstance(stand, ValidStandData)
assert stand.count == 10
assert isinstance(intensity, IntensityData)
assert intensity.duration == 19
def test_as_weight_and_blood_pressure(self) -> None:
weight_item = AggregatedDataItem(
time=1773753142,
key="weight",
value='{"time":1773753142,"weight":55.0,"bmi":16.97531}',
)
blood_pressure_item = AggregatedDataItem(
time=1773753098,
key="blood_pressure",
value='{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}',
)
weight = weight_item.as_weight()
blood_pressure = blood_pressure_item.as_blood_pressure()
assert isinstance(weight, WeightData)
assert weight.weight == 55.0
assert weight.bmi == 16.97531
assert isinstance(blood_pressure, BloodPressureData)
assert blood_pressure.systolic == 33
assert blood_pressure.diastolic == 30
assert blood_pressure.pulse == 60
def test_as_spo2_summary(self) -> None:
value = json.dumps(
{
"avg_spo2": 96,
"lack_spo2_count": 0,
"latest_spo2": {
"spo2": 96,
"time": 1761161730,
"dbKey": "single_spo2",
},
"max_spo2": 96,
"min_spo2": 96,
}
)
item = AggregatedDataItem(time=1761091200, key="spo2", value=value)
spo2 = item.as_spo2()
assert isinstance(spo2, Spo2SummaryData)
assert spo2.time == 1761091200
assert spo2.avg_spo2 == 96
assert spo2.latest_spo2 is not None
assert spo2.latest_spo2.spo2 == 96
# endregion
# region 响应包装器测试
class TestRelativeListResponse:
def test_parse_relatives(self) -> None:
resp = RelativeListResponse(
code=0,
result={
"relative_list": [
{"relative_uid": 111, "relative_note": "A"},
{"relative_uid": 222, "relative_note": "B"},
]
},
)
members = resp.relatives
assert len(members) == 2
assert members[0].relative_uid == 111
def test_empty_list(self) -> None:
resp = RelativeListResponse(code=0, result={"relative_list": []})
assert resp.relatives == []
def test_skips_invalid_relatives(self) -> None:
resp = RelativeListResponse(
code=0,
result={
"relative_list": [
123,
{"relative_note": "缺少 uid"},
{"relative_uid": 222, "relative_note": "B"},
]
},
)
members = resp.relatives
assert len(members) == 1
assert members[0].relative_uid == 222
class TestLatestDataResponse:
def test_parse_data_items(self) -> None:
resp = LatestDataResponse(
code=0,
result={
"data_list": [
{"time": 100, "key": "hr", "value": "{}"},
]
},
)
items = resp.data_items
assert len(items) == 1
assert items[0].key == "hr"
def test_non_mapping_result_is_tolerated(self) -> None:
resp = LatestDataResponse(code=0, result=None) # type: ignore[arg-type]
assert resp.data_items == []
def test_snapshot_returns_typed_metrics(self) -> None:
resp = LatestDataResponse(
code=0,
result={
"data_list": [
{
"time": 1717488000,
"key": "heart_rate",
"value": '{"time":1717491600,"bpm":84}',
},
{
"time": 1717488000,
"key": "steps",
"value": '{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}',
},
{"time": 1717488000, "key": "spo2", "value": '{"time":1717495200,"spo2":96}'},
{"time": 1717488000, "key": "mood", "value": '{"score":80}'},
],
"latest_data_time": 1717495200,
},
)
snapshot = resp.snapshot
assert isinstance(snapshot, LatestDataSnapshot)
assert snapshot.updated_time == 1717495200
assert snapshot.heart_rate is not None
assert snapshot.heart_rate.bpm == 84
assert snapshot.steps is not None
assert snapshot.steps.goal == 6000
assert snapshot.spo2 is not None
assert snapshot.spo2.spo2 == 96
assert snapshot.extras == {"mood": {"score": 80}}
assert snapshot.available_keys == ["heart_rate", "steps", "spo2", "mood"]
def test_snapshot_preserves_invalid_known_payload_in_extras(self) -> None:
resp = LatestDataResponse(
code=0,
result={
"data_list": [
{
"time": 1717488000,
"key": "heart_rate",
"value": '{"time":1717491600,"bpm":{"bad":1}}',
},
],
"latest_data_time": 1717495200,
},
)
snapshot = resp.snapshot
assert snapshot.heart_rate is None
assert snapshot.extras == {"heart_rate": {"time": 1717491600, "bpm": {"bad": 1}}}
assert snapshot.available_keys == ["heart_rate"]
class TestAggregatedDataResponse:
def test_parse_data_items(self) -> None:
resp = AggregatedDataResponse(
code=0,
result={
"data_list": [
{
"sid": "test",
"tag": "daily_report",
"key": "steps",
"time": 100,
"value": "{}",
"update_time": 200,
}
],
"has_more": True,
"next_key": "abc",
},
)
assert len(resp.data_items) == 1
assert resp.has_more is True
assert resp.next_key == "abc"
def test_string_flags_are_normalized(self) -> None:
resp = AggregatedDataResponse(
code=0,
result={
"data_list": [],
"has_more": "false",
"next_key": 123,
},
)
assert resp.has_more is False
assert resp.next_key == "123"
class TestVerifyUserResponse:
def test_with_user(self) -> None:
resp = VerifyUserResponse(
code=0,
result={"userId": 12345, "nickname": "用户", "icon": "url"},
)
info = resp.user_info
assert info is not None
assert info.user_id == 12345
def test_no_user(self) -> None:
resp = VerifyUserResponse(code=0, result={})
assert resp.user_info is None
class TestInviteResponse:
def test_success(self) -> None:
resp = InviteResponse(code=0, result={"send_ret": 1})
assert resp.success is True
def test_failure(self) -> None:
resp = InviteResponse(code=0, result={"send_ret": 0})
assert resp.success is False
def test_string_success_flag(self) -> None:
resp = InviteResponse(code=0, result={"send_ret": "1"})
assert resp.success is True
class TestDeleteRelativeResponse:
def test_success(self) -> None:
resp = DeleteRelativeResponse(code=0, result={"delete_ret": True})
assert resp.success is True
def test_failure(self) -> None:
resp = DeleteRelativeResponse(code=0, result={"delete_ret": False})
assert resp.success is False
def test_string_false_is_false(self) -> None:
resp = DeleteRelativeResponse(code=0, result={"delete_ret": "false"})
assert resp.success is False
class TestOperateInviteResponse:
def test_success(self) -> None:
resp = OperateInviteResponse(code=0, result={"operate_ret": True})
assert resp.success is True
def test_failure(self) -> None:
resp = OperateInviteResponse(code=0, result={"operate_ret": False})
assert resp.success is False
class TestSharedDataTypesResponse:
def test_parse_keys(self) -> None:
resp = SharedDataTypesResponse(
code=0,
result={"keys": ["goal", "heart_rate", "sleep"]},
)
assert resp.keys == ["goal", "heart_rate", "sleep"]
def test_empty(self) -> None:
resp = SharedDataTypesResponse(code=0, result={})
assert resp.keys == []
def test_ignores_non_string_keys(self) -> None:
resp = SharedDataTypesResponse(
code=0,
result={"keys": ["goal", 1, None, "sleep"]},
)
assert resp.keys == ["goal", "sleep"]
class TestInviteUniqueIdResponse:
def test_parse_id(self) -> None:
resp = InviteUniqueIdResponse(
code=0,
result={"invite_link_id": 467184968352742400},
)
assert resp.invite_link_id == 467184968352742400
class TestFamilyMemberResponse:
def test_parse_list(self) -> None:
resp = FamilyMemberResponse(
code=0,
result={"family_user_list": [{"userId": 1}, {"userId": 2}]},
)
assert len(resp.family_user_list) == 2
def test_empty(self) -> None:
resp = FamilyMemberResponse(code=0, result={})
assert resp.family_user_list == []
# endregion
# region 消息模型测试
class TestInviteMessage:
def test_pending_invite(self) -> None:
msg = InviteMessage(
msg_id=152824796151809,
module=1,
type=1,
sender=1452722403,
extra_data='{"invite_id":4777767,"nick_name":"测试","icon":"https://example.com/a.jpg"}',
data_status=0,
)
assert msg.invite_id == 4777767
assert msg.nick_name == "测试"
assert msg.icon == "https://example.com/a.jpg"
assert msg.is_pending is True
def test_processed_notification(self) -> None:
msg = InviteMessage(type=5, data_status=1, extra_data='{"nick_name":"用户"}')
assert msg.invite_id is None
assert msg.nick_name == "用户"
assert msg.is_pending is False
def test_invalid_extra_data(self) -> None:
msg = InviteMessage(extra_data="not json")
assert msg.invite_id is None
assert msg.nick_name == ""
assert msg.icon == ""
def test_empty_extra_data(self) -> None:
msg = InviteMessage(extra_data="")
assert msg.invite_id is None
assert msg.nick_name == ""
def test_json_list_extra_data_is_ignored(self) -> None:
msg = InviteMessage(extra_data='["bad-shape"]')
assert msg.invite_id is None
assert msg.nick_name == ""
assert msg.icon == ""
class TestMessageListResponse:
def test_parse_messages(self) -> None:
resp = MessageListResponse(
code=0,
result={
"messages": [
{"msg_id": 1, "type": 1, "sender": 100, "data_status": 0},
{"msg_id": 2, "type": 5, "sender": 200, "data_status": 1},
],
"msg_total": 2,
},
)
assert len(resp.messages) == 2
assert resp.msg_total == 2
assert resp.messages[0].msg_id == 1
def test_empty(self) -> None:
resp = MessageListResponse(code=0, result={})
assert resp.messages == []
assert resp.msg_total == 0
def test_single_message_dict_is_tolerated(self) -> None:
resp = MessageListResponse(
code=0,
result={"messages": {"msg_id": 1, "type": 1, "sender": 100}, "msg_total": "1"},
)
assert len(resp.messages) == 1
assert resp.msg_total == 1
class TestCheckNewMsgResponse:
def test_has_new(self) -> None:
resp = CheckNewMsgResponse(code=0, result=[{"module": 1, "is_new": True}])
assert resp.has_new(1) is True
assert resp.has_new(2) is False
def test_no_new(self) -> None:
resp = CheckNewMsgResponse(code=0, result=[{"module": 1, "is_new": False}])
assert resp.has_new(1) is False
def test_empty(self) -> None:
resp = CheckNewMsgResponse(code=0, result=[])
assert resp.has_new(1) is False
def test_single_dict_result_is_tolerated(self) -> None:
resp = CheckNewMsgResponse(code=0, result={"module": 1, "is_new": "true"}) # type: ignore[arg-type]
assert resp.has_new(1) is True
# endregion