| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055 |
- """Tests for device.py functionality."""
- from __future__ import annotations
- import logging
- from typing import Any
- from unittest.mock import AsyncMock, MagicMock, patch
- import aiohttp
- import pytest
- from switchbot import fetch_cloud_devices, fetch_cloud_devices_by_token
- from switchbot.adv_parser import (
- _MODEL_TO_MAC_CACHE,
- parse_advertisement_data,
- populate_model_to_mac_cache,
- )
- from switchbot.const import (
- SwitchbotAccountConnectionError,
- SwitchbotApiError,
- SwitchbotAuthenticationError,
- SwitchbotModel,
- )
- from switchbot.devices.device import (
- API_MODEL_TO_ENUM,
- SwitchbotBaseDevice,
- SwitchbotDevice,
- SwitchbotEncryptedDevice,
- _extract_region,
- _masked_device_id,
- )
- from .test_adv_parser import generate_advertisement_data, generate_ble_device
- @pytest.mark.parametrize(
- ("api_model", "expected_model"),
- [
- ("WoCurtain", SwitchbotModel.CURTAIN),
- ("WoCurtain3", SwitchbotModel.CURTAIN_3),
- ("WoPlugUS", SwitchbotModel.PLUG_MINI_US),
- ("WoPlugJP", SwitchbotModel.PLUG_MINI_JP),
- ("WoMeter", SwitchbotModel.METER),
- ("WoMeterPlus", SwitchbotModel.METER_PLUS),
- ("W1079000", SwitchbotModel.METER_PRO),
- ("W1079001", SwitchbotModel.METER_PRO_CO2),
- ("WoIOSensor", SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER),
- ("WoCeiling", SwitchbotModel.CEILING_LIGHT),
- ("WoCeilingPro", SwitchbotModel.CEILING_LIGHT_PRO),
- ("WoLinkMini", SwitchbotModel.HUB_MINI),
- ("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER),
- ],
- )
- def test_api_model_mapping(api_model: str, expected_model: SwitchbotModel) -> None:
- """Test API model names map to the exact physical product."""
- assert API_MODEL_TO_ENUM[api_model] is expected_model
- def test_legacy_plug_is_not_mapped_to_plug_mini() -> None:
- """Test the legacy Plug is not identified as a Plug Mini."""
- assert "WoPlug" not in API_MODEL_TO_ENUM
- assert SwitchbotModel.PLUG_MINI not in API_MODEL_TO_ENUM.values()
- def test_deprecated_model_aliases() -> None:
- """Test old model names remain as compatibility aliases."""
- assert SwitchbotModel.IO_METER is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
- assert SwitchbotModel.METER_PRO_C is SwitchbotModel.METER_PRO_CO2
- assert SwitchbotModel.IO_METER.name == "INDOOR_OUTDOOR_THERMO_HYGROMETER"
- assert SwitchbotModel.METER_PRO_C.name == "METER_PRO_CO2"
- assert SwitchbotModel["IO_METER"] is SwitchbotModel.INDOOR_OUTDOOR_THERMO_HYGROMETER
- assert SwitchbotModel["METER_PRO_C"] is SwitchbotModel.METER_PRO_CO2
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("api_model", "expected_model", "can_parse"),
- [
- pytest.param("WoLinkMini", SwitchbotModel.HUB_MINI, False, id="ordinary"),
- pytest.param("WoLinkMatter", SwitchbotModel.HUBMINI_MATTER, True, id="matter"),
- ],
- )
- async def test_hub_mini_cloud_cache_parsing(
- mock_user_info: dict[str, Any],
- api_model: str,
- expected_model: SwitchbotModel,
- can_parse: bool,
- ) -> None:
- """Cloud hints distinguish ordinary Hub Mini from Matter during parsing."""
- mac_address = "E6:A1:CD:1F:5B:65"
- device_response = {
- "Items": [
- {
- "device_mac": "e6a1cd1f5b65",
- "device_detail": {"device_type": api_model},
- }
- ]
- }
- ble_device = generate_ble_device(mac_address)
- adv_data = generate_advertisement_data(
- manufacturer_data={
- 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00"
- },
- )
- with (
- patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
- patch.object(
- SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
- ),
- patch.object(SwitchbotBaseDevice, "api_request", return_value=device_response),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- devices = await fetch_cloud_devices_by_token(session, "test-token")
- assert devices == {mac_address: expected_model}
- assert _MODEL_TO_MAC_CACHE[mac_address] is expected_model
- assert (parse_advertisement_data(ble_device, adv_data) is not None) is can_parse
- def test_hubmini_matter_model_name() -> None:
- """Preserve the existing Hub Mini Matter enum name."""
- assert SwitchbotModel.HUBMINI_MATTER.name == "HUBMINI_MATTER"
- @pytest.fixture
- def mock_auth_response() -> dict[str, Any]:
- """Mock authentication response."""
- return {
- "access_token": "test_token_123",
- "refresh_token": "refresh_token_123",
- "expires_in": 3600,
- }
- @pytest.fixture
- def mock_user_info() -> dict[str, Any]:
- """Mock user info response."""
- return {
- "botRegion": "us",
- "country": "us",
- "email": "test@example.com",
- }
- @pytest.fixture
- def mock_device_response() -> dict[str, Any]:
- """Mock device list response."""
- return {
- "Items": [
- {
- "device_mac": "aabbccddeeff",
- "device_name": "Test Bot",
- "device_detail": {
- "device_type": "WoHand",
- "version": "1.0.0",
- },
- },
- {
- "device_mac": "112233445566",
- "device_name": "Test Curtain",
- "device_detail": {
- "device_type": "WoCurtain",
- "version": "2.0.0",
- },
- },
- {
- "device_mac": "778899aabbcc",
- "device_name": "Test Lock",
- "device_detail": {
- "device_type": "WoLock",
- "version": "1.5.0",
- },
- },
- {
- "device_mac": "ddeeff001122",
- "device_name": "Unknown Device",
- "device_detail": {
- "device_type": "WoUnknown",
- "version": "1.0.0",
- "extra_field": "extra_value",
- },
- },
- {
- "device_mac": "invalid_device",
- # Missing device_detail
- },
- {
- "device_mac": "another_invalid",
- "device_detail": {
- # Missing device_type
- "version": "1.0.0",
- },
- },
- ]
- }
- @pytest.mark.asyncio
- async def test_get_devices(
- mock_auth_response: dict[str, Any],
- mock_user_info: dict[str, Any],
- mock_device_response: dict[str, Any],
- caplog: pytest.LogCaptureFixture,
- ) -> None:
- """Test get_devices method."""
- caplog.set_level(logging.DEBUG)
- with (
- patch.object(
- SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
- ),
- patch.object(
- SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
- ),
- patch.object(
- SwitchbotBaseDevice, "api_request", return_value=mock_device_response
- ) as mock_api_request,
- patch(
- "switchbot.devices.device.populate_model_to_mac_cache"
- ) as mock_populate_cache,
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- result = await SwitchbotBaseDevice.get_devices(
- session, "test@example.com", "password123"
- )
- # Check that api_request was called with correct parameters
- mock_api_request.assert_called_once_with(
- session,
- "wonderlabs.us",
- "wonder/device/v3/getdevice",
- {"required_type": "All"},
- {"authorization": "test_token_123"},
- )
- # Check returned dictionary
- assert len(result) == 3 # Only valid devices with known models
- assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
- assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
- assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
- # Check that cache was populated
- assert mock_populate_cache.call_count == 3
- mock_populate_cache.assert_any_call("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
- mock_populate_cache.assert_any_call("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
- mock_populate_cache.assert_any_call("77:88:99:AA:BB:CC", SwitchbotModel.LOCK)
- # Check that unknown model was logged
- assert "Unknown model WoUnknown for device DD:EE:FF:00:11:22" in caplog.text
- assert "extra_field" in caplog.text
- assert "extra_value" in caplog.text
- @pytest.mark.asyncio
- async def test_get_devices_with_region(
- mock_auth_response: dict[str, Any],
- mock_device_response: dict[str, Any],
- caplog: pytest.LogCaptureFixture,
- ) -> None:
- """Test get_devices with different region."""
- mock_user_info_eu = {
- "botRegion": "eu",
- "country": "de",
- "email": "test@example.com",
- }
- with (
- patch.object(
- SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
- ),
- patch.object(
- SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info_eu
- ),
- patch.object(
- SwitchbotBaseDevice, "api_request", return_value=mock_device_response
- ) as mock_api_request,
- patch("switchbot.devices.device.populate_model_to_mac_cache"),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- await SwitchbotBaseDevice.get_devices(
- session, "test@example.com", "password123"
- )
- # Check that EU region was used
- mock_api_request.assert_called_once_with(
- session,
- "wonderlabs.eu",
- "wonder/device/v3/getdevice",
- {"required_type": "All"},
- {"authorization": "test_token_123"},
- )
- @pytest.mark.asyncio
- async def test_get_devices_no_region(
- mock_auth_response: dict[str, Any],
- mock_device_response: dict[str, Any],
- ) -> None:
- """Test get_devices with no region specified (defaults to us)."""
- mock_user_info_no_region = {
- "email": "test@example.com",
- }
- with (
- patch.object(
- SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
- ),
- patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- return_value=mock_user_info_no_region,
- ),
- patch.object(
- SwitchbotBaseDevice, "api_request", return_value=mock_device_response
- ) as mock_api_request,
- patch("switchbot.devices.device.populate_model_to_mac_cache"),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- await SwitchbotBaseDevice.get_devices(
- session, "test@example.com", "password123"
- )
- # Check that default US region was used
- mock_api_request.assert_called_once_with(
- session,
- "wonderlabs.us",
- "wonder/device/v3/getdevice",
- {"required_type": "All"},
- {"authorization": "test_token_123"},
- )
- @pytest.mark.asyncio
- async def test_get_devices_empty_region(
- mock_auth_response: dict[str, Any],
- mock_device_response: dict[str, Any],
- ) -> None:
- """Test get_devices with empty region string (defaults to us)."""
- mock_user_info_empty_region = {
- "botRegion": "",
- "email": "test@example.com",
- }
- with (
- patch.object(
- SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
- ),
- patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- return_value=mock_user_info_empty_region,
- ),
- patch.object(
- SwitchbotBaseDevice, "api_request", return_value=mock_device_response
- ) as mock_api_request,
- patch("switchbot.devices.device.populate_model_to_mac_cache"),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- await SwitchbotBaseDevice.get_devices(
- session, "test@example.com", "password123"
- )
- # Check that default US region was used
- mock_api_request.assert_called_once_with(
- session,
- "wonderlabs.us",
- "wonder/device/v3/getdevice",
- {"required_type": "All"},
- {"authorization": "test_token_123"},
- )
- @pytest.mark.asyncio
- async def test_fetch_cloud_devices(
- mock_auth_response: dict[str, Any],
- mock_user_info: dict[str, Any],
- mock_device_response: dict[str, Any],
- ) -> None:
- """Test fetch_cloud_devices wrapper function."""
- with (
- patch.object(
- SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
- ),
- patch.object(
- SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
- ),
- patch.object(
- SwitchbotBaseDevice, "api_request", return_value=mock_device_response
- ),
- patch(
- "switchbot.devices.device.populate_model_to_mac_cache"
- ) as mock_populate_cache,
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- result = await fetch_cloud_devices(session, "test@example.com", "password123")
- # Check returned dictionary
- assert len(result) == 3
- assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
- assert result["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
- assert result["77:88:99:AA:BB:CC"] == SwitchbotModel.LOCK
- # Check that cache was populated
- assert mock_populate_cache.call_count == 3
- @pytest.mark.asyncio
- @pytest.mark.parametrize("region", ["us", "eu", "jp"])
- async def test_fetch_cloud_devices_by_token(
- mock_user_info: dict[str, Any],
- mock_device_response: dict[str, Any],
- region: str,
- caplog: pytest.LogCaptureFixture,
- ) -> None:
- """Test fetching cloud devices with an OAuth access token."""
- caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
- with (
- patch.object(SwitchbotBaseDevice, "_get_auth_result") as mock_get_auth_result,
- patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- return_value={**mock_user_info, "botRegion": region},
- ) as mock_get_user_info,
- patch.object(
- SwitchbotBaseDevice,
- "api_request",
- return_value=mock_device_response,
- ) as mock_api_request,
- patch(
- "switchbot.devices.device.populate_model_to_mac_cache"
- ) as mock_populate_cache,
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- result = await fetch_cloud_devices_by_token(session, "oauth-access-token")
- mock_get_auth_result.assert_not_called()
- mock_get_user_info.assert_awaited_once_with(
- session, {"authorization": "oauth-access-token"}
- )
- mock_api_request.assert_awaited_once_with(
- session,
- f"wonderlabs.{region}",
- "wonder/device/v3/getdevice",
- {"required_type": "All"},
- {"authorization": "oauth-access-token"},
- )
- assert result["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
- assert mock_populate_cache.call_count == 3
- assert "retrieval finished; supported_devices=3 duration_ms=" in caplog.text
- assert f"region resolved to {region}" in caplog.text
- assert "oauth-access-token" not in caplog.text
- @pytest.mark.asyncio
- async def test_fetch_cloud_devices_by_token_connection_error(
- mock_user_info: dict[str, Any],
- ) -> None:
- """Test an API error while fetching cloud devices with an OAuth token."""
- with (
- patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotBaseDevice,
- "api_request",
- side_effect=Exception("Network error"),
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(
- SwitchbotAccountConnectionError, match="Failed to retrieve devices"
- ):
- await fetch_cloud_devices_by_token(session, "oauth-access-token")
- @pytest.mark.asyncio
- async def test_fetch_cloud_devices_by_token_authentication_error() -> None:
- """Test an authentication error while fetching devices with an OAuth token."""
- with patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- side_effect=SwitchbotAuthenticationError("invalid token"),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotAuthenticationError, match="invalid token"):
- await fetch_cloud_devices_by_token(session, "oauth-access-token")
- @pytest.mark.asyncio
- async def test_get_devices_preserves_authentication_error_after_user_info(
- mock_user_info: dict[str, Any],
- ) -> None:
- """Test device retrieval preserves authentication errors."""
- with (
- patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotBaseDevice,
- "api_request",
- side_effect=SwitchbotAuthenticationError("expired token"),
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotAuthenticationError, match="expired token"):
- await fetch_cloud_devices_by_token(session, "oauth-access-token")
- @pytest.mark.asyncio
- async def test_get_devices_preserves_api_error_after_user_info(
- mock_user_info: dict[str, Any],
- ) -> None:
- """Test device retrieval preserves API errors."""
- with (
- patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotBaseDevice,
- "api_request",
- side_effect=SwitchbotApiError("API error"),
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotApiError, match="API error"):
- await fetch_cloud_devices_by_token(session, "oauth-access-token")
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "device_info",
- [
- pytest.param({}, id="missing-items"),
- pytest.param({"Items": None}, id="invalid-items"),
- pytest.param({"Items": {}}, id="items-not-list"),
- pytest.param({"Items": ["invalid"]}, id="invalid-item"),
- ],
- )
- async def test_get_devices_rejects_invalid_response(
- mock_user_info: dict[str, Any], device_info: dict[str, Any]
- ) -> None:
- """Test malformed device responses retain their API error classification."""
- with (
- patch.object(
- SwitchbotBaseDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotBaseDevice,
- "api_request",
- return_value=device_info,
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotApiError, match="Invalid device response"):
- await fetch_cloud_devices_by_token(session, "oauth-access-token")
- @pytest.mark.asyncio
- async def test_get_user_info_preserves_authentication_error() -> None:
- """Test user info retrieval preserves authentication errors."""
- with patch.object(
- SwitchbotBaseDevice,
- "api_request",
- side_effect=SwitchbotAuthenticationError("invalid token"),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotAuthenticationError, match="invalid token"):
- await SwitchbotBaseDevice._async_get_user_info(
- session,
- {"authorization": "invalid-token"},
- )
- @pytest.mark.asyncio
- async def test_get_user_info_preserves_api_error() -> None:
- """Test user info retrieval preserves API errors."""
- with patch.object(
- SwitchbotBaseDevice,
- "api_request",
- side_effect=SwitchbotApiError("API error"),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotApiError, match="API error"):
- await SwitchbotBaseDevice._async_get_user_info(
- session,
- {"authorization": "invalid-token"},
- )
- @pytest.mark.asyncio
- async def test_api_request_debug_logs_response_shape_without_values(
- caplog: pytest.LogCaptureFixture,
- ) -> None:
- """Test API debug logs contain response fields but no sensitive values."""
- response = MagicMock()
- response.status = 200
- response.headers = {"x-amzn-requestid": "api-request-id"}
- response.json = AsyncMock(
- return_value={
- "statusCode": 100,
- "message": "success",
- "body": {
- "access_token": "sensitive-access-token",
- "deviceId": "sensitive-device-id",
- "encryptionKey": "sensitive-encryption-key",
- },
- }
- )
- session = MagicMock(spec=aiohttp.ClientSession)
- session.post.return_value.__aenter__ = AsyncMock(return_value=response)
- session.post.return_value.__aexit__ = AsyncMock(return_value=None)
- caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
- result = await SwitchbotBaseDevice.api_request(
- session, "account", "account/api/v1/user/userinfo"
- )
- assert result["deviceId"] == "sensitive-device-id"
- assert "response fields=['body', 'message', 'statusCode']" in caplog.text
- assert "body fields=['access_token', 'deviceId', 'encryptionKey']" in caplog.text
- assert "duration_ms=" in caplog.text
- assert "request_id=api-request-id" in caplog.text
- for sensitive_value in (
- "sensitive-access-token",
- "sensitive-device-id",
- "sensitive-encryption-key",
- ):
- assert sensitive_value not in caplog.text
- @pytest.mark.asyncio
- async def test_api_request_authentication_error() -> None:
- """Test HTTP authentication errors retain their specific error type."""
- response = MagicMock()
- response.status = 401
- session = MagicMock(spec=aiohttp.ClientSession)
- session.post.return_value.__aenter__.return_value = response
- with pytest.raises(SwitchbotAuthenticationError, match="Authentication rejected"):
- await SwitchbotBaseDevice.api_request(
- session,
- "account",
- "account/api/v1/user/userinfo",
- {},
- {"authorization": "invalid-token"},
- )
- @pytest.mark.asyncio
- async def test_retrieve_encryption_key_with_password() -> None:
- """Test the password flow delegates with its access token."""
- key_details = {
- "key_id": "ff",
- "encryption_key": "ffffffffffffffffffffffffffffffff",
- }
- with (
- patch.object(
- SwitchbotEncryptedDevice,
- "_get_auth_result",
- return_value={"access_token": "password-access-token"},
- ) as mock_get_auth_result,
- patch.object(
- SwitchbotEncryptedDevice,
- "_async_retrieve_encryption_key",
- return_value=key_details,
- ) as mock_retrieve_key,
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- result = await SwitchbotEncryptedDevice.async_retrieve_encryption_key(
- session,
- "aa:bb:cc:dd:ee:ff",
- "test@example.com",
- "password",
- )
- mock_get_auth_result.assert_awaited_once_with(
- session, "test@example.com", "password"
- )
- mock_retrieve_key.assert_awaited_once_with(
- session,
- "aa:bb:cc:dd:ee:ff",
- {"authorization": "password-access-token"},
- )
- assert result == key_details
- @pytest.mark.asyncio
- async def test_retrieve_encryption_key_by_token_api_error(
- mock_user_info: dict[str, Any],
- ) -> None:
- """Test an API error while retrieving a key with an OAuth token."""
- with (
- patch.object(
- SwitchbotEncryptedDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotEncryptedDevice,
- "api_request",
- side_effect=SwitchbotApiError("API error"),
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotApiError, match="API error"):
- await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
- session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
- )
- @pytest.mark.asyncio
- async def test_retrieve_encryption_key_by_token_authentication_error(
- mock_user_info: dict[str, Any],
- ) -> None:
- """Test key retrieval preserves authentication errors."""
- with (
- patch.object(
- SwitchbotEncryptedDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotEncryptedDevice,
- "api_request",
- side_effect=SwitchbotAuthenticationError("expired token"),
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotAuthenticationError, match="expired token"):
- await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
- session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
- )
- @pytest.mark.asyncio
- async def test_retrieve_encryption_key_by_token_connection_error(
- mock_user_info: dict[str, Any],
- ) -> None:
- """Test key retrieval maps unexpected request errors to connection errors."""
- with (
- patch.object(
- SwitchbotEncryptedDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotEncryptedDevice,
- "api_request",
- side_effect=Exception("network error"),
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(
- SwitchbotAccountConnectionError,
- match="Failed to retrieve encryption key",
- ):
- await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
- session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
- )
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "device_info",
- [
- pytest.param({}, id="missing-communication-key"),
- pytest.param({"communicationKey": None}, id="invalid-communication-key"),
- pytest.param(
- {"communicationKey": {"key": "encryption-key"}}, id="missing-key-id"
- ),
- pytest.param(
- {"communicationKey": {"keyId": "ff"}}, id="missing-encryption-key"
- ),
- pytest.param(
- {"communicationKey": {"keyId": 1, "key": "encryption-key"}},
- id="invalid-key-id",
- ),
- ],
- )
- async def test_retrieve_encryption_key_by_token_invalid_response(
- mock_user_info: dict[str, Any], device_info: dict[str, Any]
- ) -> None:
- """Test malformed key responses retain their API error classification."""
- with (
- patch.object(
- SwitchbotEncryptedDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ),
- patch.object(
- SwitchbotEncryptedDevice,
- "api_request",
- return_value=device_info,
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotApiError, match="Invalid encryption key response"):
- await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
- session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
- )
- @pytest.mark.asyncio
- async def test_retrieve_encryption_key_by_token(
- mock_user_info: dict[str, Any],
- caplog: pytest.LogCaptureFixture,
- ) -> None:
- """Test retrieving an encryption key with an OAuth access token."""
- caplog.set_level(logging.DEBUG, logger="switchbot.devices.device")
- with (
- patch.object(
- SwitchbotEncryptedDevice,
- "_async_get_user_info",
- return_value=mock_user_info,
- ) as mock_get_user_info,
- patch.object(
- SwitchbotEncryptedDevice,
- "api_request",
- return_value={
- "communicationKey": {
- "keyId": "ff",
- "key": "ffffffffffffffffffffffffffffffff",
- }
- },
- ) as mock_api_request,
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- result = await SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token(
- session, "aa:bb:cc:dd:ee:ff", "oauth-access-token"
- )
- auth_headers = {"authorization": "oauth-access-token"}
- mock_get_user_info.assert_awaited_once_with(session, auth_headers)
- mock_api_request.assert_awaited_once_with(
- session,
- "wonderlabs.us",
- "wonder/keys/v1/communicate",
- {"device_mac": "AABBCCDDEEFF", "keyType": "user"},
- auth_headers,
- )
- assert result == {
- "key_id": "ff",
- "encryption_key": "ffffffffffffffffffffffffffffffff",
- }
- assert "device=****EEFF" in caplog.text
- assert "retrieval finished; device=****EEFF duration_ms=" in caplog.text
- for sensitive_value in (
- "aa:bb:cc:dd:ee:ff",
- "oauth-access-token",
- "ffffffffffffffffffffffffffffffff",
- ):
- assert sensitive_value not in caplog.text
- @pytest.mark.asyncio
- async def test_get_devices_authentication_error() -> None:
- """Test get_devices with authentication error."""
- with patch.object(
- SwitchbotBaseDevice,
- "_get_auth_result",
- side_effect=Exception("Auth failed"),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotAuthenticationError) as exc_info:
- await SwitchbotBaseDevice.get_devices(
- session, "test@example.com", "wrong_password"
- )
- assert "Authentication failed" in str(exc_info.value)
- @pytest.mark.asyncio
- async def test_get_devices_connection_error(
- mock_auth_response: dict[str, Any],
- mock_user_info: dict[str, Any],
- ) -> None:
- """Test get_devices with connection error."""
- with (
- patch.object(
- SwitchbotBaseDevice, "_get_auth_result", return_value=mock_auth_response
- ),
- patch.object(
- SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
- ),
- patch.object(
- SwitchbotBaseDevice,
- "api_request",
- side_effect=Exception("Network error"),
- ),
- ):
- session = MagicMock(spec=aiohttp.ClientSession)
- with pytest.raises(SwitchbotAccountConnectionError) as exc_info:
- await SwitchbotBaseDevice.get_devices(
- session, "test@example.com", "password123"
- )
- assert "Failed to retrieve devices" in str(exc_info.value)
- @pytest.mark.asyncio
- async def test_populate_model_to_mac_cache() -> None:
- """Test the populate_model_to_mac_cache helper function."""
- # Clear the cache first
- _MODEL_TO_MAC_CACHE.clear()
- # Populate cache with test data
- populate_model_to_mac_cache("AA:BB:CC:DD:EE:FF", SwitchbotModel.BOT)
- populate_model_to_mac_cache("11:22:33:44:55:66", SwitchbotModel.CURTAIN)
- # Check cache contents
- assert _MODEL_TO_MAC_CACHE["AA:BB:CC:DD:EE:FF"] == SwitchbotModel.BOT
- assert _MODEL_TO_MAC_CACHE["11:22:33:44:55:66"] == SwitchbotModel.CURTAIN
- assert len(_MODEL_TO_MAC_CACHE) == 2
- # Clear cache after test
- _MODEL_TO_MAC_CACHE.clear()
- def test_masked_device_id_empty() -> None:
- """Test an empty device identifier is represented safely."""
- assert _masked_device_id("") == "unknown"
- def test_extract_region(caplog: pytest.LogCaptureFixture) -> None:
- """Test the _extract_region helper function."""
- caplog.set_level(logging.WARNING, logger="switchbot.devices.device")
- # Test with botRegion present and not empty
- assert _extract_region({"botRegion": "eu", "country": "de"}) == "eu"
- assert _extract_region({"botRegion": "us", "country": "us"}) == "us"
- assert _extract_region({"botRegion": "jp", "country": "jp"}) == "jp"
- # Test with botRegion empty string
- assert _extract_region({"botRegion": "", "country": "de"}) == "us"
- # Test with botRegion missing
- assert _extract_region({"country": "de"}) == "us"
- # Test with empty dict
- assert _extract_region({}) == "us"
- assert "account region missing; defaulting to us" in caplog.text
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("commands", "results", "final_result"),
- [
- # All fail -> False
- (("command1", "command2"), [(b"\x01", False), (None, False)], False),
- # First fails -> False (short-circuits, second not called)
- (("command1", "command2"), [(b"\x01", False)], False),
- # First succeeds, second fails -> False
- (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], False),
- # All succeed -> True
- (("command1", "command2"), [(b"\x01", True), (b"\x01", True)], True),
- ],
- )
- async def test_send_command_sequence(
- commands: tuple[str, ...],
- results: list[tuple[bytes | None, bool]],
- final_result: bool,
- ) -> None:
- """Test sending command sequence where all must succeed."""
- ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
- device = SwitchbotDevice(ble_device)
- device._send_command = AsyncMock(side_effect=[r[0] for r in results])
- device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
- result = await device._send_command_sequence(list(commands))
- assert result is final_result
- def test_update_parsed_data_without_advertisement_does_not_log_exception(
- caplog: pytest.LogCaptureFixture,
- ) -> None:
- """
- Calling _update_parsed_data before any advertisement is a no-op, not an error.
- Regression for #285: previously emitted ``_LOGGER.exception(...)`` outside an
- ``except`` block, which logged "No advertisement data to update / NoneType: None"
- on every press()/turn_on()/update() until the first advertisement arrived.
- """
- ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
- device = SwitchbotDevice(ble_device)
- assert device._sb_adv_data is None
- with caplog.at_level(logging.DEBUG, logger="switchbot.devices.device"):
- result = device._update_parsed_data({"isOn": True})
- assert result is False
- exception_records = [
- record for record in caplog.records if record.levelno >= logging.WARNING
- ]
- assert exception_records == []
- assert all(record.exc_info is None for record in caplog.records)
- @pytest.mark.asyncio
- async def test_hub4_cloud_model_cache(mock_user_info: dict[str, Any]) -> None:
- """Map the backend product ID and populate the Bluetooth model cache."""
- response = {
- "Items": [
- {
- "device_mac": "e6a1cd1f5b65",
- "device_detail": {"device_type": "W1213000"},
- }
- ]
- }
- with (
- patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
- patch.object(
- SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
- ),
- patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
- ):
- devices = await fetch_cloud_devices_by_token(
- MagicMock(spec=aiohttp.ClientSession), "test-token"
- )
- assert devices == {"E6:A1:CD:1F:5B:65": SwitchbotModel.HUB4}
- assert _MODEL_TO_MAC_CACHE["E6:A1:CD:1F:5B:65"] is SwitchbotModel.HUB4
- @pytest.mark.asyncio
- async def test_plug_mini_it_cloud_model_cache(mock_user_info: dict[str, Any]) -> None:
- """Map the backend product ID and populate the Bluetooth model cache."""
- response = {
- "Items": [
- {
- "device_mac": "e6a1cd1f5b65",
- "device_detail": {"device_type": "W1197000"},
- }
- ]
- }
- with (
- patch.dict(_MODEL_TO_MAC_CACHE, {}, clear=True),
- patch.object(
- SwitchbotBaseDevice, "_async_get_user_info", return_value=mock_user_info
- ),
- patch.object(SwitchbotBaseDevice, "api_request", return_value=response),
- ):
- devices = await fetch_cloud_devices_by_token(
- MagicMock(spec=aiohttp.ClientSession), "test-token"
- )
- assert devices == {"E6:A1:CD:1F:5B:65": SwitchbotModel.PLUG_MINI_IT}
- assert _MODEL_TO_MAC_CACHE["E6:A1:CD:1F:5B:65"] is SwitchbotModel.PLUG_MINI_IT
|