mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-19 12:10:14 +03:00
feat: add miband bot application
This commit is contained in:
@@ -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:
|
||||
"""测试用 ssecurity(base64 编码的 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
|
||||
@@ -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&&¬-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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user