pytest-homeassistant-custom-component 0.13.163__py3-none-any.whl → 0.13.298__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. pytest_homeassistant_custom_component/common.py +289 -46
  2. pytest_homeassistant_custom_component/components/__init__.py +351 -0
  3. pytest_homeassistant_custom_component/components/diagnostics/__init__.py +71 -0
  4. pytest_homeassistant_custom_component/components/recorder/common.py +143 -13
  5. pytest_homeassistant_custom_component/components/recorder/db_schema_0.py +1 -1
  6. pytest_homeassistant_custom_component/const.py +4 -3
  7. pytest_homeassistant_custom_component/patch_json.py +41 -0
  8. pytest_homeassistant_custom_component/patch_recorder.py +1 -1
  9. pytest_homeassistant_custom_component/patch_time.py +66 -0
  10. pytest_homeassistant_custom_component/plugins.py +429 -177
  11. pytest_homeassistant_custom_component/syrupy.py +188 -1
  12. pytest_homeassistant_custom_component/test_util/aiohttp.py +56 -20
  13. pytest_homeassistant_custom_component/typing.py +5 -0
  14. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/METADATA +54 -29
  15. pytest_homeassistant_custom_component-0.13.298.dist-info/RECORD +28 -0
  16. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/WHEEL +1 -1
  17. pytest_homeassistant_custom_component-0.13.163.dist-info/RECORD +0 -26
  18. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/entry_points.txt +0 -0
  19. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info/licenses}/LICENSE +0 -0
  20. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info/licenses}/LICENSE_HA_CORE.md +0 -0
  21. {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/top_level.txt +0 -0
@@ -15,15 +15,18 @@ import gc
15
15
  import itertools
16
16
  import logging
17
17
  import os
18
+ import pathlib
18
19
  import reprlib
19
20
  from shutil import rmtree
20
21
  import sqlite3
21
22
  import ssl
23
+ import sys
22
24
  import threading
23
25
  from typing import TYPE_CHECKING, Any, cast
24
26
  from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch
25
27
 
26
28
  from aiohttp import client
29
+ from aiohttp.resolver import AsyncResolver
27
30
  from aiohttp.test_utils import (
28
31
  BaseTestServer,
29
32
  TestClient,
@@ -36,33 +39,47 @@ import bcrypt
36
39
  import freezegun
37
40
  import multidict
38
41
  import pytest
42
+ import pytest_asyncio
39
43
  import pytest_socket
40
44
  import requests_mock
41
45
  import respx
42
46
  from syrupy.assertion import SnapshotAssertion
47
+ from syrupy.session import SnapshotSession
48
+
49
+ # Setup patching of JSON functions before any other Home Assistant imports
50
+ from . import patch_json # isort:skip
43
51
 
44
52
  from homeassistant import block_async_io
45
53
  from homeassistant.exceptions import ServiceNotFound
46
54
 
47
55
  # Setup patching of recorder functions before any other Home Assistant imports
48
- from . import patch_recorder # noqa: F401, isort:skip
56
+ from . import patch_recorder # isort:skip
49
57
 
50
58
  # Setup patching of dt_util time functions before any other Home Assistant imports
51
- from . import patch_time # noqa: F401, isort:skip
59
+ from . import patch_time # isort:skip
52
60
 
53
- from homeassistant import core as ha, loader, runner
61
+ from homeassistant import components, core as ha, loader, runner
54
62
  from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
55
63
  from homeassistant.auth.models import Credentials
56
64
  from homeassistant.auth.providers import homeassistant
57
65
  from homeassistant.components.device_tracker.legacy import Device
66
+
67
+ # pylint: disable-next=hass-component-root-import
58
68
  from homeassistant.components.websocket_api.auth import (
59
69
  TYPE_AUTH,
60
70
  TYPE_AUTH_OK,
61
71
  TYPE_AUTH_REQUIRED,
62
72
  )
73
+
74
+ # pylint: disable-next=hass-component-root-import
63
75
  from homeassistant.components.websocket_api.http import URL
64
76
  from homeassistant.config import YAML_CONFIG_FILE
65
- from homeassistant.config_entries import ConfigEntries, ConfigEntry, ConfigEntryState
77
+ from homeassistant.config_entries import (
78
+ ConfigEntries,
79
+ ConfigEntry,
80
+ ConfigEntryState,
81
+ ConfigSubentryData,
82
+ )
66
83
  from homeassistant.const import BASE_PLATFORMS, HASSIO_USER_NAME
67
84
  from homeassistant.core import (
68
85
  Context,
@@ -79,26 +96,30 @@ from homeassistant.helpers import (
79
96
  device_registry as dr,
80
97
  entity_registry as er,
81
98
  floor_registry as fr,
99
+ frame,
82
100
  issue_registry as ir,
83
101
  label_registry as lr,
84
102
  recorder as recorder_helper,
103
+ translation as translation_helper,
85
104
  )
86
105
  from homeassistant.helpers.dispatcher import async_dispatcher_send
106
+ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
87
107
  from homeassistant.helpers.translation import _TranslationsCacheData
88
108
  from homeassistant.helpers.typing import ConfigType
89
109
  from homeassistant.setup import async_setup_component
90
- from homeassistant.util import dt as dt_util, location
110
+ from homeassistant.util import dt as dt_util, location as location_util
91
111
  from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_handles
92
112
  from homeassistant.util.json import json_loads
93
113
 
94
114
  from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS
95
- from .syrupy import HomeAssistantSnapshotExtension
115
+ from .syrupy import HomeAssistantSnapshotExtension, override_syrupy_finish
96
116
  from .typing import (
97
117
  ClientSessionGenerator,
98
118
  MockHAClientWebSocket,
99
119
  MqttMockHAClient,
100
120
  MqttMockHAClientGenerator,
101
121
  MqttMockPahoClient,
122
+ RecorderInstanceContextManager,
102
123
  RecorderInstanceGenerator,
103
124
  WebSocketGenerator,
104
125
  )
@@ -106,14 +127,17 @@ from .typing import (
106
127
  if TYPE_CHECKING:
107
128
  # Local import to avoid processing recorder and SQLite modules when running a
108
129
  # testcase which does not use the recorder.
130
+ from homeassistant.auth.models import RefreshToken
109
131
  from homeassistant.components import recorder
110
132
 
133
+
111
134
  pytest.register_assert_rewrite(".common")
112
135
 
113
136
  from .common import ( # noqa: E402, isort:skip
114
137
  CLIENT_ID,
115
138
  INSTANCES,
116
139
  MockConfigEntry,
140
+ MockMqttReasonCode,
117
141
  MockUser,
118
142
  async_fire_mqtt_message,
119
143
  async_test_home_assistant,
@@ -139,6 +163,7 @@ asyncio.set_event_loop_policy = lambda policy: None
139
163
  def pytest_addoption(parser: pytest.Parser) -> None:
140
164
  """Register custom pytest options."""
141
165
  parser.addoption("--dburl", action="store", default="sqlite://")
166
+ parser.addoption("--drop-existing-db", action="store_const", const=True)
142
167
 
143
168
 
144
169
  def pytest_configure(config: pytest.Config) -> None:
@@ -149,6 +174,11 @@ def pytest_configure(config: pytest.Config) -> None:
149
174
  if config.getoption("verbose") > 0:
150
175
  logging.getLogger().setLevel(logging.DEBUG)
151
176
 
177
+ # Override default finish to detect unused snapshots despite xdist
178
+ # Temporary workaround until it is finalised inside syrupy
179
+ # See https://github.com/syrupy-project/syrupy/pull/901
180
+ SnapshotSession.finish = override_syrupy_finish
181
+
152
182
 
153
183
  def pytest_runtest_setup() -> None:
154
184
  """Prepare pytest_socket and freezegun.
@@ -161,69 +191,33 @@ def pytest_runtest_setup() -> None:
161
191
  destinations will be allowed.
162
192
 
163
193
  freezegun:
164
- Modified to include https://github.com/spulec/freezegun/pull/424
194
+ Modified to include https://github.com/spulec/freezegun/pull/424 and improve class str.
165
195
  """
166
196
  pytest_socket.socket_allow_hosts(["127.0.0.1"])
167
197
  pytest_socket.disable_socket(allow_unix_socket=True)
168
198
 
169
- freezegun.api.datetime_to_fakedatetime = ha_datetime_to_fakedatetime # type: ignore[attr-defined]
170
- freezegun.api.FakeDatetime = HAFakeDatetime # type: ignore[attr-defined]
199
+ freezegun.api.FakeDate = patch_time.HAFakeDate # type: ignore[attr-defined]
200
+
201
+ freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined]
202
+ freezegun.api.FakeDatetime = patch_time.HAFakeDatetime # type: ignore[attr-defined]
171
203
 
172
204
  def adapt_datetime(val):
173
205
  return val.isoformat(" ")
174
206
 
175
207
  # Setup HAFakeDatetime converter for sqlite3
176
- sqlite3.register_adapter(HAFakeDatetime, adapt_datetime)
208
+ sqlite3.register_adapter(patch_time.HAFakeDatetime, adapt_datetime)
177
209
 
178
210
  # Setup HAFakeDatetime converter for pymysql
179
211
  try:
180
- # pylint: disable-next=import-outside-toplevel
181
- import MySQLdb.converters as MySQLdb_converters
212
+ import MySQLdb.converters as MySQLdb_converters # noqa: PLC0415
182
213
  except ImportError:
183
214
  pass
184
215
  else:
185
- MySQLdb_converters.conversions[HAFakeDatetime] = (
216
+ MySQLdb_converters.conversions[patch_time.HAFakeDatetime] = (
186
217
  MySQLdb_converters.DateTime2literal
187
218
  )
188
219
 
189
220
 
190
- def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type: ignore[name-defined]
191
- """Convert datetime to FakeDatetime.
192
-
193
- Modified to include https://github.com/spulec/freezegun/pull/424.
194
- """
195
- return freezegun.api.FakeDatetime( # type: ignore[attr-defined]
196
- datetime.year,
197
- datetime.month,
198
- datetime.day,
199
- datetime.hour,
200
- datetime.minute,
201
- datetime.second,
202
- datetime.microsecond,
203
- datetime.tzinfo,
204
- fold=datetime.fold,
205
- )
206
-
207
-
208
- class HAFakeDatetime(freezegun.api.FakeDatetime): # type: ignore[name-defined]
209
- """Modified to include https://github.com/spulec/freezegun/pull/424."""
210
-
211
- @classmethod
212
- def now(cls, tz=None):
213
- """Return frozen now."""
214
- now = cls._time_to_freeze() or freezegun.api.real_datetime.now()
215
- if tz:
216
- result = tz.fromutc(now.replace(tzinfo=tz))
217
- else:
218
- result = now
219
-
220
- # Add the _tz_offset only if it's non-zero to preserve fold
221
- if cls._tz_offset():
222
- result += cls._tz_offset()
223
-
224
- return ha_datetime_to_fakedatetime(result)
225
-
226
-
227
221
  def check_real[**_P, _R](func: Callable[_P, Coroutine[Any, Any, _R]]):
228
222
  """Force a function to require a keyword _test_real to be passed in."""
229
223
 
@@ -242,7 +236,9 @@ def check_real[**_P, _R](func: Callable[_P, Coroutine[Any, Any, _R]]):
242
236
 
243
237
 
244
238
  # Guard a few functions that would make network connections
245
- location.async_detect_location_info = check_real(location.async_detect_location_info)
239
+ location_util.async_detect_location_info = check_real(
240
+ location_util.async_detect_location_info
241
+ )
246
242
 
247
243
 
248
244
  @pytest.fixture(name="caplog")
@@ -263,6 +259,7 @@ def garbage_collection() -> None:
263
259
  to run per test case if needed.
264
260
  """
265
261
  gc.collect()
262
+ gc.freeze()
266
263
 
267
264
 
268
265
  @pytest.fixture(autouse=True)
@@ -340,18 +337,18 @@ def long_repr_strings() -> Generator[None]:
340
337
 
341
338
 
342
339
  @pytest.fixture(autouse=True)
343
- def enable_event_loop_debug(event_loop: asyncio.AbstractEventLoop) -> None:
340
+ def enable_event_loop_debug() -> None:
344
341
  """Enable event loop debug mode."""
345
- event_loop.set_debug(True)
342
+ asyncio.get_event_loop().set_debug(True)
346
343
 
347
344
 
348
345
  @pytest.fixture(autouse=True)
349
346
  def verify_cleanup(
350
- event_loop: asyncio.AbstractEventLoop,
351
347
  expected_lingering_tasks: bool,
352
348
  expected_lingering_timers: bool,
353
349
  ) -> Generator[None]:
354
350
  """Verify that the test has cleaned up resources correctly."""
351
+ event_loop = asyncio.get_event_loop()
355
352
  threads_before = frozenset(threading.enumerate())
356
353
  tasks_before = asyncio.all_tasks(event_loop)
357
354
  yield
@@ -392,8 +389,10 @@ def verify_cleanup(
392
389
  # Verify no threads where left behind.
393
390
  threads = frozenset(threading.enumerate()) - threads_before
394
391
  for thread in threads:
395
- assert isinstance(thread, threading._DummyThread) or thread.name.startswith(
396
- "waitpid-"
392
+ assert (
393
+ isinstance(thread, threading._DummyThread)
394
+ or thread.name.startswith("waitpid-")
395
+ or "_run_safe_shutdown_loop" in thread.name
397
396
  )
398
397
 
399
398
  try:
@@ -405,20 +404,34 @@ def verify_cleanup(
405
404
 
406
405
  try:
407
406
  # Verify respx.mock has been cleaned up
408
- assert not respx.mock.routes, "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock"
407
+ assert not respx.mock.routes, (
408
+ "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock"
409
+ )
409
410
  finally:
410
411
  # Clear mock routes not break subsequent tests
411
412
  respx.mock.clear()
412
413
 
413
414
 
414
415
  @pytest.fixture(autouse=True)
415
- def reset_hass_threading_local_object() -> Generator[None]:
416
- """Reset the _Hass threading.local object for every test case."""
416
+ def reset_globals() -> Generator[None]:
417
+ """Reset global objects for every test case."""
417
418
  yield
419
+
420
+ # Reset the _Hass threading.local object
418
421
  ha._hass.__dict__.clear()
419
422
 
423
+ # Reset the frame helper globals
424
+ frame.async_setup(None)
425
+ frame._REPORTED_INTEGRATIONS.clear()
426
+
427
+ # Reset patch_json
428
+ if patch_json.mock_objects:
429
+ obj = patch_json.mock_objects.pop()
430
+ patch_json.mock_objects.clear()
431
+ pytest.fail(f"Test attempted to serialize mock object {obj}")
420
432
 
421
- @pytest.fixture(scope="session", autouse=True)
433
+
434
+ @pytest.fixture(autouse=True, scope="session")
422
435
  def bcrypt_cost() -> Generator[None]:
423
436
  """Run with reduced rounds during tests, to speed up uses."""
424
437
  gensalt_orig = bcrypt.gensalt
@@ -488,9 +501,7 @@ def aiohttp_client_cls() -> type[CoalescingClient]:
488
501
 
489
502
 
490
503
  @pytest.fixture
491
- def aiohttp_client(
492
- event_loop: asyncio.AbstractEventLoop,
493
- ) -> Generator[ClientSessionGenerator]:
504
+ def aiohttp_client() -> Generator[ClientSessionGenerator]:
494
505
  """Override the default aiohttp_client since 3.x does not support aiohttp_client_cls.
495
506
 
496
507
  Remove this when upgrading to 4.x as aiohttp_client_cls
@@ -500,34 +511,35 @@ def aiohttp_client(
500
511
  aiohttp_client(server, **kwargs)
501
512
  aiohttp_client(raw_server, **kwargs)
502
513
  """
503
- loop = event_loop
514
+ loop = asyncio.get_event_loop()
504
515
  clients = []
505
516
 
506
517
  async def go(
507
- __param: Application | BaseTestServer,
518
+ param: Application | BaseTestServer,
519
+ /,
508
520
  *args: Any,
509
521
  server_kwargs: dict[str, Any] | None = None,
510
522
  **kwargs: Any,
511
523
  ) -> TestClient:
512
- if isinstance(__param, Callable) and not isinstance( # type: ignore[arg-type]
513
- __param, (Application, BaseTestServer)
524
+ if isinstance(param, Callable) and not isinstance( # type: ignore[arg-type]
525
+ param, (Application, BaseTestServer)
514
526
  ):
515
- __param = __param(loop, *args, **kwargs)
527
+ param = param(loop, *args, **kwargs)
516
528
  kwargs = {}
517
529
  else:
518
530
  assert not args, "args should be empty"
519
531
 
520
532
  client: TestClient
521
- if isinstance(__param, Application):
533
+ if isinstance(param, Application):
522
534
  server_kwargs = server_kwargs or {}
523
- server = TestServer(__param, loop=loop, **server_kwargs)
535
+ server = TestServer(param, loop=loop, **server_kwargs)
524
536
  # Registering a view after starting the server should still work.
525
537
  server.app._router.freeze = lambda: None
526
538
  client = CoalescingClient(server, loop=loop, **kwargs)
527
- elif isinstance(__param, BaseTestServer):
528
- client = TestClient(__param, loop=loop, **kwargs)
539
+ elif isinstance(param, BaseTestServer):
540
+ client = TestClient(param, loop=loop, **kwargs)
529
541
  else:
530
- raise TypeError(f"Unknown argument type: {type(__param)!r}")
542
+ raise TypeError(f"Unknown argument type: {type(param)!r}")
531
543
 
532
544
  await client.start_server()
533
545
  clients.append(client)
@@ -572,7 +584,7 @@ async def hass(
572
584
  exceptions.append(
573
585
  Exception(
574
586
  "Received exception handler without exception, "
575
- f"but with message: {context["message"]}"
587
+ f"but with message: {context['message']}"
576
588
  )
577
589
  )
578
590
  orig_exception_handler(loop, context)
@@ -581,6 +593,7 @@ async def hass(
581
593
  async with async_test_home_assistant(loop, load_registries) as hass:
582
594
  orig_exception_handler = loop.get_exception_handler()
583
595
  loop.set_exception_handler(exc_handle)
596
+ frame.async_setup(hass)
584
597
 
585
598
  yield hass
586
599
 
@@ -821,7 +834,7 @@ def hass_client_no_auth(
821
834
  @pytest.fixture
822
835
  def current_request() -> Generator[MagicMock]:
823
836
  """Mock current request."""
824
- with patch("homeassistant.components.http.current_request") as mock_request_context:
837
+ with patch("homeassistant.helpers.http.current_request") as mock_request_context:
825
838
  mocked_request = make_mocked_request(
826
839
  "GET",
827
840
  "/some/request",
@@ -916,7 +929,19 @@ def fail_on_log_exception(
916
929
 
917
930
  @pytest.fixture
918
931
  def mqtt_config_entry_data() -> dict[str, Any] | None:
919
- """Fixture to allow overriding MQTT config."""
932
+ """Fixture to allow overriding MQTT entry data."""
933
+ return None
934
+
935
+
936
+ @pytest.fixture
937
+ def mqtt_config_subentries_data() -> tuple[ConfigSubentryData] | None:
938
+ """Fixture to allow overriding MQTT subentries data."""
939
+ return None
940
+
941
+
942
+ @pytest.fixture
943
+ def mqtt_config_entry_options() -> dict[str, Any] | None:
944
+ """Fixture to allow overriding MQTT entry options."""
920
945
  return None
921
946
 
922
947
 
@@ -949,17 +974,23 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]:
949
974
  def _async_fire_mqtt_message(topic, payload, qos, retain):
950
975
  async_fire_mqtt_message(hass, topic, payload or b"", qos, retain)
951
976
  mid = get_mid()
952
- hass.loop.call_soon(mock_client.on_publish, 0, 0, mid)
977
+ hass.loop.call_soon(
978
+ mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None
979
+ )
953
980
  return FakeInfo(mid)
954
981
 
955
982
  def _subscribe(topic, qos=0):
956
983
  mid = get_mid()
957
- hass.loop.call_soon(mock_client.on_subscribe, 0, 0, mid)
984
+ hass.loop.call_soon(
985
+ mock_client.on_subscribe, Mock(), 0, mid, [MockMqttReasonCode()], None
986
+ )
958
987
  return (0, mid)
959
988
 
960
989
  def _unsubscribe(topic):
961
990
  mid = get_mid()
962
- hass.loop.call_soon(mock_client.on_unsubscribe, 0, 0, mid)
991
+ hass.loop.call_soon(
992
+ mock_client.on_unsubscribe, Mock(), 0, mid, [MockMqttReasonCode()], None
993
+ )
963
994
  return (0, mid)
964
995
 
965
996
  def _connect(*args, **kwargs):
@@ -968,7 +999,7 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]:
968
999
  # the behavior.
969
1000
  mock_client.reconnect()
970
1001
  hass.loop.call_soon_threadsafe(
971
- mock_client.on_connect, mock_client, None, 0, 0, 0
1002
+ mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode()
972
1003
  )
973
1004
  mock_client.on_socket_open(
974
1005
  mock_client, None, Mock(fileno=Mock(return_value=-1))
@@ -993,6 +1024,8 @@ async def mqtt_mock(
993
1024
  mock_hass_config: None,
994
1025
  mqtt_client_mock: MqttMockPahoClient,
995
1026
  mqtt_config_entry_data: dict[str, Any] | None,
1027
+ mqtt_config_entry_options: dict[str, Any] | None,
1028
+ mqtt_config_subentries_data: tuple[ConfigSubentryData] | None,
996
1029
  mqtt_mock_entry: MqttMockHAClientGenerator,
997
1030
  ) -> AsyncGenerator[MqttMockHAClient]:
998
1031
  """Fixture to mock MQTT component."""
@@ -1004,24 +1037,29 @@ async def _mqtt_mock_entry(
1004
1037
  hass: HomeAssistant,
1005
1038
  mqtt_client_mock: MqttMockPahoClient,
1006
1039
  mqtt_config_entry_data: dict[str, Any] | None,
1040
+ mqtt_config_entry_options: dict[str, Any] | None,
1041
+ mqtt_config_subentries_data: tuple[ConfigSubentryData] | None,
1007
1042
  ) -> AsyncGenerator[MqttMockHAClientGenerator]:
1008
1043
  """Fixture to mock a delayed setup of the MQTT config entry."""
1009
1044
  # Local import to avoid processing MQTT modules when running a testcase
1010
1045
  # which does not use MQTT.
1011
- from homeassistant.components import mqtt # pylint: disable=import-outside-toplevel
1046
+ from homeassistant.components import mqtt # noqa: PLC0415
1012
1047
 
1013
1048
  if mqtt_config_entry_data is None:
1014
- mqtt_config_entry_data = {
1015
- mqtt.CONF_BROKER: "mock-broker",
1016
- mqtt.CONF_BIRTH_MESSAGE: {},
1017
- }
1049
+ mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"}
1050
+ if mqtt_config_entry_options is None:
1051
+ mqtt_config_entry_options = {mqtt.CONF_BIRTH_MESSAGE: {}}
1018
1052
 
1019
1053
  await hass.async_block_till_done()
1020
1054
 
1021
1055
  entry = MockConfigEntry(
1022
1056
  data=mqtt_config_entry_data,
1057
+ options=mqtt_config_entry_options,
1058
+ subentries_data=mqtt_config_subentries_data,
1023
1059
  domain=mqtt.DOMAIN,
1024
1060
  title="MQTT",
1061
+ version=1,
1062
+ minor_version=2,
1025
1063
  )
1026
1064
  entry.add_to_hass(hass)
1027
1065
 
@@ -1037,12 +1075,11 @@ async def _mqtt_mock_entry(
1037
1075
 
1038
1076
  # Assert that MQTT is setup
1039
1077
  assert real_mqtt_instance is not None, "MQTT was not setup correctly"
1040
- mock_mqtt_instance.conf = real_mqtt_instance.conf # For diagnostics
1041
1078
  mock_mqtt_instance._mqttc = mqtt_client_mock
1042
1079
 
1043
1080
  # connected set to True to get a more realistic behavior when subscribing
1044
1081
  mock_mqtt_instance.connected = True
1045
- mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, 0, 0)
1082
+ mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, MockMqttReasonCode())
1046
1083
 
1047
1084
  async_dispatcher_send(hass, mqtt.MQTT_CONNECTION_STATE, True)
1048
1085
  await hass.async_block_till_done()
@@ -1132,6 +1169,8 @@ async def mqtt_mock_entry(
1132
1169
  hass: HomeAssistant,
1133
1170
  mqtt_client_mock: MqttMockPahoClient,
1134
1171
  mqtt_config_entry_data: dict[str, Any] | None,
1172
+ mqtt_config_entry_options: dict[str, Any] | None,
1173
+ mqtt_config_subentries_data: tuple[ConfigSubentryData] | None,
1135
1174
  ) -> AsyncGenerator[MqttMockHAClientGenerator]:
1136
1175
  """Set up an MQTT config entry."""
1137
1176
 
@@ -1148,7 +1187,11 @@ async def mqtt_mock_entry(
1148
1187
  return await mqtt_mock_entry(_async_setup_config_entry)
1149
1188
 
1150
1189
  async with _mqtt_mock_entry(
1151
- hass, mqtt_client_mock, mqtt_config_entry_data
1190
+ hass,
1191
+ mqtt_client_mock,
1192
+ mqtt_config_entry_data,
1193
+ mqtt_config_entry_options,
1194
+ mqtt_config_subentries_data,
1152
1195
  ) as mqtt_mock_entry:
1153
1196
  yield _setup_mqtt_entry
1154
1197
 
@@ -1156,15 +1199,31 @@ async def mqtt_mock_entry(
1156
1199
  @pytest.fixture(autouse=True, scope="session")
1157
1200
  def mock_network() -> Generator[None]:
1158
1201
  """Mock network."""
1159
- with patch(
1160
- "homeassistant.components.network.util.ifaddr.get_adapters",
1161
- return_value=[
1162
- Mock(
1163
- nice_name="eth0",
1164
- ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)],
1165
- index=0,
1166
- )
1167
- ],
1202
+ with (
1203
+ patch(
1204
+ "homeassistant.components.network.util.ifaddr.get_adapters",
1205
+ return_value=[
1206
+ Mock(
1207
+ nice_name="eth0",
1208
+ ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)],
1209
+ index=0,
1210
+ )
1211
+ ],
1212
+ ),
1213
+ patch(
1214
+ "homeassistant.components.network.async_get_loaded_adapters",
1215
+ return_value=[
1216
+ {
1217
+ "auto": True,
1218
+ "default": True,
1219
+ "enabled": True,
1220
+ "index": 0,
1221
+ "ipv4": [{"address": "10.10.10.10", "network_prefix": 24}],
1222
+ "ipv6": [],
1223
+ "name": "eth0",
1224
+ }
1225
+ ],
1226
+ ),
1168
1227
  ):
1169
1228
  yield
1170
1229
 
@@ -1185,7 +1244,11 @@ def mock_get_source_ip() -> Generator[_patch]:
1185
1244
 
1186
1245
  @pytest.fixture(autouse=True, scope="session")
1187
1246
  def translations_once() -> Generator[_patch]:
1188
- """Only load translations once per session."""
1247
+ """Only load translations once per session.
1248
+
1249
+ Note: To avoid issues with tests that mock integrations, translations for
1250
+ mocked integrations are cleaned up by the evict_faked_translations fixture.
1251
+ """
1189
1252
  cache = _TranslationsCacheData({}, {})
1190
1253
  patcher = patch(
1191
1254
  "homeassistant.helpers.translation._TranslationsCacheData",
@@ -1198,6 +1261,30 @@ def translations_once() -> Generator[_patch]:
1198
1261
  patcher.stop()
1199
1262
 
1200
1263
 
1264
+ @pytest.fixture(autouse=True, scope="module")
1265
+ def evict_faked_translations(translations_once) -> Generator[_patch]:
1266
+ """Clear translations for mocked integrations from the cache after each module."""
1267
+ real_component_strings = translation_helper._async_get_component_strings
1268
+ with patch(
1269
+ "homeassistant.helpers.translation._async_get_component_strings",
1270
+ wraps=real_component_strings,
1271
+ ) as mock_component_strings:
1272
+ yield
1273
+ cache: _TranslationsCacheData = translations_once.kwargs["return_value"]
1274
+ component_paths = components.__path__
1275
+
1276
+ for call in mock_component_strings.mock_calls:
1277
+ integrations: dict[str, loader.Integration] = call.args[3]
1278
+ for domain, integration in integrations.items():
1279
+ if any(
1280
+ pathlib.Path(f"{component_path}/{domain}") == integration.file_path
1281
+ for component_path in component_paths
1282
+ ):
1283
+ continue
1284
+ for loaded_for_lang in cache.loaded.values():
1285
+ loaded_for_lang.discard(domain)
1286
+
1287
+
1201
1288
  @pytest.fixture
1202
1289
  def disable_translations_once(
1203
1290
  translations_once: _patch,
@@ -1208,15 +1295,45 @@ def disable_translations_once(
1208
1295
  translations_once.start()
1209
1296
 
1210
1297
 
1298
+ @pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session")
1299
+ async def mock_zeroconf_resolver() -> AsyncGenerator[_patch]:
1300
+ """Mock out the zeroconf resolver."""
1301
+ resolver = AsyncResolver()
1302
+ resolver.real_close = resolver.close
1303
+ patcher = patch(
1304
+ "homeassistant.helpers.aiohttp_client._async_make_resolver",
1305
+ return_value=resolver,
1306
+ )
1307
+ patcher.start()
1308
+ try:
1309
+ yield patcher
1310
+ finally:
1311
+ patcher.stop()
1312
+
1313
+
1314
+ @pytest.fixture
1315
+ def disable_mock_zeroconf_resolver(
1316
+ mock_zeroconf_resolver: _patch,
1317
+ ) -> Generator[None]:
1318
+ """Disable the zeroconf resolver."""
1319
+ mock_zeroconf_resolver.stop()
1320
+ yield
1321
+ mock_zeroconf_resolver.start()
1322
+
1323
+
1211
1324
  @pytest.fixture
1212
1325
  def mock_zeroconf() -> Generator[MagicMock]:
1213
1326
  """Mock zeroconf."""
1214
- from zeroconf import DNSCache # pylint: disable=import-outside-toplevel
1327
+ from zeroconf import DNSCache # noqa: PLC0415
1215
1328
 
1216
1329
  with (
1217
- patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True) as mock_zc,
1218
- patch("homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True),
1330
+ patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc,
1331
+ patch(
1332
+ "homeassistant.components.zeroconf.discovery.AsyncServiceBrowser",
1333
+ ) as mock_browser,
1219
1334
  ):
1335
+ asb = mock_browser.return_value
1336
+ asb.async_cancel = AsyncMock()
1220
1337
  zc = mock_zc.return_value
1221
1338
  # DNSCache has strong Cython type checks, and MagicMock does not work
1222
1339
  # so we must mock the class directly
@@ -1227,10 +1344,8 @@ def mock_zeroconf() -> Generator[MagicMock]:
1227
1344
  @pytest.fixture
1228
1345
  def mock_async_zeroconf(mock_zeroconf: MagicMock) -> Generator[MagicMock]:
1229
1346
  """Mock AsyncZeroconf."""
1230
- from zeroconf import DNSCache, Zeroconf # pylint: disable=import-outside-toplevel
1231
- from zeroconf.asyncio import ( # pylint: disable=import-outside-toplevel
1232
- AsyncZeroconf,
1233
- )
1347
+ from zeroconf import DNSCache, Zeroconf # noqa: PLC0415
1348
+ from zeroconf.asyncio import AsyncZeroconf # noqa: PLC0415
1234
1349
 
1235
1350
  with patch(
1236
1351
  "homeassistant.components.zeroconf.HaAsyncZeroconf", spec=AsyncZeroconf
@@ -1297,11 +1412,21 @@ def enable_nightly_purge() -> bool:
1297
1412
 
1298
1413
 
1299
1414
  @pytest.fixture
1300
- def enable_migrate_context_ids() -> bool:
1415
+ def enable_migrate_event_context_ids() -> bool:
1416
+ """Fixture to control enabling of recorder's context id migration.
1417
+
1418
+ To enable context id migration, tests can be marked with:
1419
+ @pytest.mark.parametrize("enable_migrate_event_context_ids", [True])
1420
+ """
1421
+ return False
1422
+
1423
+
1424
+ @pytest.fixture
1425
+ def enable_migrate_state_context_ids() -> bool:
1301
1426
  """Fixture to control enabling of recorder's context id migration.
1302
1427
 
1303
1428
  To enable context id migration, tests can be marked with:
1304
- @pytest.mark.parametrize("enable_migrate_context_ids", [True])
1429
+ @pytest.mark.parametrize("enable_migrate_state_context_ids", [True])
1305
1430
  """
1306
1431
  return False
1307
1432
 
@@ -1372,47 +1497,58 @@ def recorder_db_url(
1372
1497
  assert not hass_fixture_setup
1373
1498
 
1374
1499
  db_url = cast(str, pytestconfig.getoption("dburl"))
1500
+ drop_existing_db = pytestconfig.getoption("drop_existing_db")
1501
+
1502
+ def drop_db() -> None:
1503
+ import sqlalchemy as sa # noqa: PLC0415
1504
+ import sqlalchemy_utils # noqa: PLC0415
1505
+
1506
+ if db_url.startswith("mysql://"):
1507
+ made_url = sa.make_url(db_url)
1508
+ db = made_url.database
1509
+ engine = sa.create_engine(db_url)
1510
+ # Check for any open connections to the database before dropping it
1511
+ # to ensure that InnoDB does not deadlock.
1512
+ with engine.begin() as connection:
1513
+ query = sa.text(
1514
+ "select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()"
1515
+ )
1516
+ rows = connection.execute(query, parameters={"db": db}).fetchall()
1517
+ if rows:
1518
+ raise RuntimeError(
1519
+ f"Unable to drop database {db} because it is in use by {rows}"
1520
+ )
1521
+ engine.dispose()
1522
+ sqlalchemy_utils.drop_database(db_url)
1523
+ elif db_url.startswith("postgresql://"):
1524
+ sqlalchemy_utils.drop_database(db_url)
1525
+
1375
1526
  if db_url == "sqlite://" and persistent_database:
1376
1527
  tmp_path = tmp_path_factory.mktemp("recorder")
1377
1528
  db_url = "sqlite:///" + str(tmp_path / "pytest.db")
1378
- elif db_url.startswith("mysql://"):
1379
- # pylint: disable-next=import-outside-toplevel
1380
- import sqlalchemy_utils
1381
-
1382
- charset = "utf8mb4' COLLATE = 'utf8mb4_unicode_ci"
1383
- assert not sqlalchemy_utils.database_exists(db_url)
1384
- sqlalchemy_utils.create_database(db_url, encoding=charset)
1385
- elif db_url.startswith("postgresql://"):
1386
- # pylint: disable-next=import-outside-toplevel
1387
- import sqlalchemy_utils
1388
-
1389
- assert not sqlalchemy_utils.database_exists(db_url)
1390
- sqlalchemy_utils.create_database(db_url, encoding="utf8")
1529
+ elif db_url.startswith(("mysql://", "postgresql://")):
1530
+ import sqlalchemy_utils # noqa: PLC0415
1531
+
1532
+ if drop_existing_db and sqlalchemy_utils.database_exists(db_url):
1533
+ drop_db()
1534
+
1535
+ if sqlalchemy_utils.database_exists(db_url):
1536
+ raise RuntimeError(
1537
+ f"Database {db_url} already exists. Use --drop-existing-db "
1538
+ "to automatically drop existing database before start of test."
1539
+ )
1540
+
1541
+ sqlalchemy_utils.create_database(
1542
+ db_url,
1543
+ encoding="utf8mb4' COLLATE = 'utf8mb4_unicode_ci"
1544
+ if db_url.startswith("mysql://")
1545
+ else "utf8",
1546
+ )
1391
1547
  yield db_url
1392
1548
  if db_url == "sqlite://" and persistent_database:
1393
1549
  rmtree(tmp_path, ignore_errors=True)
1394
- elif db_url.startswith("mysql://"):
1395
- # pylint: disable-next=import-outside-toplevel
1396
- import sqlalchemy as sa
1397
-
1398
- made_url = sa.make_url(db_url)
1399
- db = made_url.database
1400
- engine = sa.create_engine(db_url)
1401
- # Check for any open connections to the database before dropping it
1402
- # to ensure that InnoDB does not deadlock.
1403
- with engine.begin() as connection:
1404
- query = sa.text(
1405
- "select id FROM information_schema.processlist WHERE db=:db and id != CONNECTION_ID()"
1406
- )
1407
- rows = connection.execute(query, parameters={"db": db}).fetchall()
1408
- if rows:
1409
- raise RuntimeError(
1410
- f"Unable to drop database {db} because it is in use by {rows}"
1411
- )
1412
- engine.dispose()
1413
- sqlalchemy_utils.drop_database(db_url)
1414
- elif db_url.startswith("postgresql://"):
1415
- sqlalchemy_utils.drop_database(db_url)
1550
+ elif db_url.startswith(("mysql://", "postgresql://")):
1551
+ drop_db()
1416
1552
 
1417
1553
 
1418
1554
  async def _async_init_recorder_component(
@@ -1424,8 +1560,7 @@ async def _async_init_recorder_component(
1424
1560
  wait_setup: bool,
1425
1561
  ) -> None:
1426
1562
  """Initialize the recorder asynchronously."""
1427
- # pylint: disable-next=import-outside-toplevel
1428
- from homeassistant.components import recorder
1563
+ from homeassistant.components import recorder # noqa: PLC0415
1429
1564
 
1430
1565
  config = dict(add_config) if add_config else {}
1431
1566
  if recorder.CONF_DB_URL not in config:
@@ -1446,7 +1581,7 @@ async def _async_init_recorder_component(
1446
1581
  assert (recorder.DOMAIN in hass.config.components) == expected_setup_result
1447
1582
  else:
1448
1583
  # Wait for recorder to connect to the database
1449
- await recorder_helper.async_wait_recorder(hass)
1584
+ await hass.data[recorder_helper.DATA_RECORDER].db_connected
1450
1585
  _LOGGER.info(
1451
1586
  "Test recorder successfully started, database location: %s",
1452
1587
  config[recorder.CONF_DB_URL],
@@ -1469,27 +1604,23 @@ async def async_test_recorder(
1469
1604
  enable_statistics: bool,
1470
1605
  enable_missing_statistics: bool,
1471
1606
  enable_schema_validation: bool,
1472
- enable_migrate_context_ids: bool,
1607
+ enable_migrate_event_context_ids: bool,
1608
+ enable_migrate_state_context_ids: bool,
1473
1609
  enable_migrate_event_type_ids: bool,
1474
1610
  enable_migrate_entity_ids: bool,
1475
1611
  enable_migrate_event_ids: bool,
1476
- ) -> AsyncGenerator[RecorderInstanceGenerator]:
1612
+ ) -> AsyncGenerator[RecorderInstanceContextManager]:
1477
1613
  """Yield context manager to setup recorder instance."""
1478
- # pylint: disable-next=import-outside-toplevel
1479
- from homeassistant.components import recorder
1614
+ from homeassistant.components import recorder # noqa: PLC0415
1615
+ from homeassistant.components.recorder import migration # noqa: PLC0415
1480
1616
 
1481
- # pylint: disable-next=import-outside-toplevel
1482
- from homeassistant.components.recorder import migration
1483
-
1484
- # pylint: disable-next=import-outside-toplevel
1485
- from .components.recorder.common import async_recorder_block_till_done
1486
-
1487
- # pylint: disable-next=import-outside-toplevel
1488
- from .patch_recorder import real_session_scope
1617
+ from .components.recorder.common import ( # noqa: PLC0415
1618
+ async_recorder_block_till_done,
1619
+ )
1620
+ from .patch_recorder import real_session_scope # noqa: PLC0415
1489
1621
 
1490
1622
  if TYPE_CHECKING:
1491
- # pylint: disable-next=import-outside-toplevel
1492
- from sqlalchemy.orm.session import Session
1623
+ from sqlalchemy.orm.session import Session # noqa: PLC0415
1493
1624
 
1494
1625
  @contextmanager
1495
1626
  def debug_session_scope(
@@ -1531,12 +1662,12 @@ async def async_test_recorder(
1531
1662
  )
1532
1663
  migrate_states_context_ids = (
1533
1664
  migration.StatesContextIDMigration.migrate_data
1534
- if enable_migrate_context_ids
1665
+ if enable_migrate_state_context_ids
1535
1666
  else None
1536
1667
  )
1537
1668
  migrate_events_context_ids = (
1538
1669
  migration.EventsContextIDMigration.migrate_data
1539
- if enable_migrate_context_ids
1670
+ if enable_migrate_event_context_ids
1540
1671
  else None
1541
1672
  )
1542
1673
  migrate_event_type_ids = (
@@ -1547,10 +1678,12 @@ async def async_test_recorder(
1547
1678
  migrate_entity_ids = (
1548
1679
  migration.EntityIDMigration.migrate_data if enable_migrate_entity_ids else None
1549
1680
  )
1550
- legacy_event_id_foreign_key_exists = (
1551
- migration.EventIDPostMigration._legacy_event_id_foreign_key_exists
1681
+ post_migrate_event_ids = (
1682
+ migration.EventIDPostMigration.needs_migrate_impl
1552
1683
  if enable_migrate_event_ids
1553
- else lambda _: None
1684
+ else lambda _1, _2, _3: migration.DataMigrationStatus(
1685
+ needs_migrate=False, migration_done=True
1686
+ )
1554
1687
  )
1555
1688
  with (
1556
1689
  patch(
@@ -1589,8 +1722,8 @@ async def async_test_recorder(
1589
1722
  autospec=True,
1590
1723
  ),
1591
1724
  patch(
1592
- "homeassistant.components.recorder.migration.EventIDPostMigration._legacy_event_id_foreign_key_exists",
1593
- side_effect=legacy_event_id_foreign_key_exists,
1725
+ "homeassistant.components.recorder.migration.EventIDPostMigration.needs_migrate_impl",
1726
+ side_effect=post_migrate_event_ids,
1594
1727
  autospec=True,
1595
1728
  ),
1596
1729
  patch(
@@ -1615,7 +1748,7 @@ async def async_test_recorder(
1615
1748
  wait_recorder: bool = True,
1616
1749
  wait_recorder_setup: bool = True,
1617
1750
  ) -> AsyncGenerator[recorder.Recorder]:
1618
- """Setup and return recorder instance.""" # noqa: D401
1751
+ """Setup and return recorder instance."""
1619
1752
  await _async_init_recorder_component(
1620
1753
  hass,
1621
1754
  config,
@@ -1639,7 +1772,7 @@ async def async_test_recorder(
1639
1772
 
1640
1773
  @pytest.fixture
1641
1774
  async def async_setup_recorder_instance(
1642
- async_test_recorder: RecorderInstanceGenerator,
1775
+ async_test_recorder: RecorderInstanceContextManager,
1643
1776
  ) -> AsyncGenerator[RecorderInstanceGenerator]:
1644
1777
  """Yield callable to setup recorder instance."""
1645
1778
 
@@ -1652,7 +1785,7 @@ async def async_setup_recorder_instance(
1652
1785
  expected_setup_result: bool = True,
1653
1786
  wait_recorder: bool = True,
1654
1787
  wait_recorder_setup: bool = True,
1655
- ) -> AsyncGenerator[recorder.Recorder]:
1788
+ ) -> recorder.Recorder:
1656
1789
  """Set up and return recorder instance."""
1657
1790
 
1658
1791
  return await stack.enter_async_context(
@@ -1671,7 +1804,7 @@ async def async_setup_recorder_instance(
1671
1804
  @pytest.fixture
1672
1805
  async def recorder_mock(
1673
1806
  recorder_config: dict[str, Any] | None,
1674
- async_test_recorder: RecorderInstanceGenerator,
1807
+ async_test_recorder: RecorderInstanceContextManager,
1675
1808
  hass: HomeAssistant,
1676
1809
  ) -> AsyncGenerator[recorder.Recorder]:
1677
1810
  """Fixture with in-memory recorder."""
@@ -1704,10 +1837,11 @@ async def mock_enable_bluetooth(
1704
1837
  await hass.async_block_till_done()
1705
1838
 
1706
1839
 
1707
- @pytest.fixture(scope="session")
1840
+ @pytest.fixture(autouse=True, scope="session")
1708
1841
  def mock_bluetooth_adapters() -> Generator[None]:
1709
1842
  """Fixture to mock bluetooth adapters."""
1710
1843
  with (
1844
+ patch("habluetooth.util.recover_adapter"),
1711
1845
  patch("bluetooth_auto_recovery.recover_adapter"),
1712
1846
  patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
1713
1847
  patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
@@ -1736,33 +1870,122 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]:
1736
1870
 
1737
1871
  # Late imports to avoid loading bleak unless we need it
1738
1872
 
1739
- # pylint: disable-next=import-outside-toplevel
1740
- from habluetooth import scanner as bluetooth_scanner
1873
+ from habluetooth import ( # noqa: PLC0415
1874
+ manager as bluetooth_manager,
1875
+ scanner as bluetooth_scanner,
1876
+ )
1741
1877
 
1742
1878
  # We need to drop the stop method from the object since we patched
1743
1879
  # out start and this fixture will expire before the stop method is called
1744
1880
  # when EVENT_HOMEASSISTANT_STOP is fired.
1745
1881
  # pylint: disable-next=c-extension-no-member
1746
1882
  bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment]
1883
+
1884
+ # Mock BlueZ management controller to successfully setup
1885
+ # This prevents the manager from operating in degraded mode
1886
+ mock_mgmt_bluetooth_ctl = Mock()
1887
+ mock_mgmt_bluetooth_ctl.setup = AsyncMock(return_value=None)
1888
+
1747
1889
  with (
1748
1890
  patch.object(
1749
1891
  bluetooth_scanner.OriginalBleakScanner, # pylint: disable=c-extension-no-member
1750
1892
  "start",
1751
1893
  ) as mock_bleak_scanner_start,
1752
1894
  patch.object(bluetooth_scanner, "HaScanner"),
1895
+ patch.object(
1896
+ bluetooth_manager, "MGMTBluetoothCtl", return_value=mock_mgmt_bluetooth_ctl
1897
+ ),
1753
1898
  ):
1754
1899
  yield mock_bleak_scanner_start
1755
1900
 
1756
1901
 
1757
1902
  @pytest.fixture
1758
- def mock_integration_frame() -> Generator[Mock]:
1759
- """Mock as if we're calling code from inside an integration."""
1903
+ def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]:
1904
+ """Fixture to inject hassio env."""
1905
+ from homeassistant.components.hassio import HassioAPIError # noqa: PLC0415
1906
+
1907
+ from .components.hassio import SUPERVISOR_TOKEN # noqa: PLC0415
1908
+
1909
+ with (
1910
+ patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}),
1911
+ patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}),
1912
+ patch(
1913
+ "homeassistant.components.hassio.HassIO.get_info",
1914
+ Mock(side_effect=HassioAPIError()),
1915
+ ),
1916
+ ):
1917
+ yield
1918
+
1919
+
1920
+ @pytest.fixture
1921
+ async def hassio_stubs(
1922
+ hassio_env: None,
1923
+ hass: HomeAssistant,
1924
+ hass_client: ClientSessionGenerator,
1925
+ aioclient_mock: AiohttpClientMocker,
1926
+ supervisor_client: AsyncMock,
1927
+ ) -> RefreshToken:
1928
+ """Create mock hassio http client."""
1929
+ from homeassistant.components.hassio import HassioAPIError # noqa: PLC0415
1930
+
1931
+ with (
1932
+ patch(
1933
+ "homeassistant.components.hassio.HassIO.update_hass_api",
1934
+ return_value={"result": "ok"},
1935
+ ) as hass_api,
1936
+ patch(
1937
+ "homeassistant.components.hassio.HassIO.update_hass_config",
1938
+ return_value={"result": "ok"},
1939
+ ),
1940
+ patch(
1941
+ "homeassistant.components.hassio.HassIO.get_info",
1942
+ side_effect=HassioAPIError(),
1943
+ ),
1944
+ patch(
1945
+ "homeassistant.components.hassio.HassIO.get_ingress_panels",
1946
+ return_value={"panels": []},
1947
+ ),
1948
+ patch(
1949
+ "homeassistant.components.hassio.issues.SupervisorIssues.setup",
1950
+ ),
1951
+ ):
1952
+ await async_setup_component(hass, "hassio", {})
1953
+
1954
+ return hass_api.call_args[0][1]
1955
+
1956
+
1957
+ @pytest.fixture
1958
+ def integration_frame_path() -> str:
1959
+ """Return the path to the integration frame.
1960
+
1961
+ Can be parametrized with
1962
+ `@pytest.mark.parametrize("integration_frame_path", ["path_to_frame"])`
1963
+
1964
+ - "custom_components/XYZ" for a custom integration
1965
+ - "homeassistant/components/XYZ" for a core integration
1966
+ - "homeassistant/XYZ" for core (no integration)
1967
+
1968
+ Defaults to core component `hue`
1969
+ """
1970
+ return "homeassistant/components/hue"
1971
+
1972
+
1973
+ @pytest.fixture
1974
+ def mock_integration_frame(integration_frame_path: str) -> Generator[Mock]:
1975
+ """Mock where we are calling code from.
1976
+
1977
+ Defaults to calling from `hue` core integration, and can be parametrized
1978
+ with `integration_frame_path`.
1979
+ """
1980
+ correct_filename = f"/home/paulus/{integration_frame_path}/light.py"
1981
+ correct_module_name = f"{integration_frame_path.replace('/', '.')}.light"
1760
1982
  correct_frame = Mock(
1761
- filename="/home/paulus/homeassistant/components/hue/light.py",
1983
+ filename=f"/home/paulus/{integration_frame_path}/light.py",
1762
1984
  lineno="23",
1763
1985
  line="self.light.is_on",
1764
1986
  )
1765
1987
  with (
1988
+ patch.dict(sys.modules, {correct_module_name: Mock(__file__=correct_filename)}),
1766
1989
  patch(
1767
1990
  "homeassistant.helpers.frame.linecache.getline",
1768
1991
  return_value=correct_frame.line,
@@ -1856,7 +2079,7 @@ def service_calls(hass: HomeAssistant) -> Generator[list[ServiceCall]]:
1856
2079
  return_response: bool = False,
1857
2080
  ) -> ServiceResponse:
1858
2081
  calls.append(
1859
- ServiceCall(domain, service, service_data, context, return_response)
2082
+ ServiceCall(hass, domain, service, service_data, context, return_response)
1860
2083
  )
1861
2084
  try:
1862
2085
  return await _original_async_call(
@@ -1892,3 +2115,32 @@ def disable_block_async_io() -> Generator[None]:
1892
2115
  blocking_call.object, blocking_call.function, blocking_call.original_func
1893
2116
  )
1894
2117
  calls.clear()
2118
+
2119
+
2120
+ # Ensure that incorrectly formatted mac addresses are rejected during
2121
+ # DhcpServiceInfo initialisation
2122
+ _real_dhcp_service_info_init = DhcpServiceInfo.__init__
2123
+
2124
+
2125
+ def _dhcp_service_info_init(self: DhcpServiceInfo, *args: Any, **kwargs: Any) -> None:
2126
+ """Override __init__ for DhcpServiceInfo.
2127
+
2128
+ Ensure that the macaddress is always in lowercase and without colons to match DHCP service.
2129
+ """
2130
+ _real_dhcp_service_info_init(self, *args, **kwargs)
2131
+ if self.macaddress != self.macaddress.lower().replace(":", ""):
2132
+ raise ValueError("macaddress is not correctly formatted")
2133
+
2134
+
2135
+ DhcpServiceInfo.__init__ = _dhcp_service_info_init
2136
+
2137
+
2138
+ @pytest.fixture(autouse=True)
2139
+ def disable_http_server() -> Generator[None]:
2140
+ """Disable automatic start of HTTP server during .
2141
+
2142
+ This prevents the HTTP server from starting in tests that setup
2143
+ integrations which depend on the HTTP component.
2144
+ """
2145
+ with patch("homeassistant.components.http.start_http_server_and_save_config"):
2146
+ yield