1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445
|
"""Tests for BSBLAN API validation methods."""
from __future__ import annotations
import asyncio
import contextlib
# file deepcode ignore W0212: this is a testfile
# pylint: disable=protected-access
import json
from typing import TYPE_CHECKING, Any, NoReturn, cast
from unittest.mock import AsyncMock
import aiohttp
import pytest
from bsblan import BSBLAN
from bsblan.bsblan import BSBLANConfig
from bsblan.constants import (
API_DATA_NOT_INITIALIZED_ERROR_MSG,
API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG,
API_VERSIONS,
APIConfig,
)
from bsblan.exceptions import BSBLANError
from bsblan.utility import APIValidator
if TYPE_CHECKING:
from aresponses import ResponsesMockServer
@pytest.mark.asyncio
async def test_validate_api_section_success(aresponses: ResponsesMockServer) -> None:
"""Test successful API section validation."""
# Mock the response for parameter validation
aresponses.add(
"example.com",
"/JQ",
"POST",
aresponses.Response(
status=200,
headers={"Content-Type": "application/json"},
text=json.dumps(
{
"device": {
"5870": {
"name": "Device Parameter",
"value": 123,
"unit": "°C",
},
}
}
),
),
)
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
# Initialize API validator and data
bsblan._api_version = "v3"
api_data_device_section = {
"5870": {
"name": "Device Parameter",
"min": 0,
"max": 100,
"unit": "°C",
}
}
bsblan._api_data = {"device": api_data_device_section} # type: ignore[assignment]
bsblan._api_validator = APIValidator(bsblan._api_data)
# Test validation
await bsblan._validate_api_section("device")
# Verify validation status
assert bsblan._api_validator.is_section_validated("device")
@pytest.mark.asyncio
async def test_validate_api_section_no_validator() -> None:
"""Test API section validation with no validator initialized."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
# Ensure validator is None
bsblan._api_validator = None # type: ignore[assignment]
with pytest.raises(BSBLANError, match=API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG):
await bsblan._validate_api_section("device")
@pytest.mark.asyncio
async def test_validate_api_section_no_api_data() -> None:
"""Test API section validation with no API data initialized."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
# Initialize validator but not API data
bsblan._api_validator = APIValidator({})
bsblan._api_data = None
with pytest.raises(BSBLANError, match=API_DATA_NOT_INITIALIZED_ERROR_MSG):
await bsblan._validate_api_section("device")
@pytest.mark.asyncio
async def test_validate_api_section_invalid_section() -> None:
"""Test API section validation with invalid section."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
# Initialize validator and API data without the requested section
bsblan._api_validator = APIValidator({})
bsblan._api_data = {"heating": {}} # type: ignore[assignment]
with pytest.raises(
BSBLANError, match="Section 'invalid_section' not found in API data"
):
await bsblan._validate_api_section("invalid_section") # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_validate_api_section_validation_error(
aresponses: ResponsesMockServer,
) -> None:
"""Test API section validation with validation error."""
# Mock the response for parameter validation
aresponses.add(
"example.com",
"/JQ",
"POST",
aresponses.Response(
status=200,
headers={"Content-Type": "application/json"},
text=json.dumps(
{
"device": {
"5870": {"name": "Different Name", "value": 123, "unit": "°C"},
}
}
),
),
)
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
# Set up for test
bsblan._api_version = "v3"
api_data_device_section_error = {
"5870": {
"name": "Device Parameter",
"min": 0,
"max": 100,
"unit": "°C",
}
}
bsblan._api_data = {"device": api_data_device_section_error} # type: ignore[assignment]
original_validate = APIValidator.validate_section
# Initialize bsblan._api_validator with the full _api_data
bsblan._api_validator = APIValidator(bsblan._api_data)
def mock_validate(
_self: APIValidator,
_section: str,
_response: dict[str, Any],
_include: list[str] | None = None,
) -> NoReturn:
error_message = "Validation error"
raise BSBLANError(error_message)
APIValidator.validate_section = mock_validate # type: ignore[method-assign, assignment]
try:
# _api_validator is already set on bsblan
async def mock_extract_params(*_args: Any) -> dict[str, Any]:
# Not using the parameters
return {"string_par": "5870", "list": ["Device Parameter"]}
bsblan._extract_params_summary = mock_extract_params # type: ignore[assignment, method-assign]
# Handle the exception because we expect it
with contextlib.suppress(BSBLANError):
await bsblan._validate_api_section("device")
assert not bsblan._api_validator.is_section_validated("device")
finally:
APIValidator.validate_section = original_validate
@pytest.mark.asyncio
async def test_validate_section_already_validated(monkeypatch: Any) -> None:
"""Test section validation returns None when already validated (line 160)."""
async with aiohttp.ClientSession() as session:
config = BSBLANConfig(host="example.com")
client = BSBLAN(config, session=session)
client._api_version = "v1"
# Deep copy to avoid modifying the shared constant
source_config = API_VERSIONS["v1"]
client._api_data = cast(
"APIConfig",
{
section: cast("dict[str, str]", params).copy()
for section, params in source_config.items()
},
)
client._api_validator = APIValidator(client._api_data)
# Mock request
request_mock: AsyncMock = AsyncMock(
return_value={"710": {"name": "Target", "value": "20", "unit": "°C"}}
)
monkeypatch.setattr(client, "_request", request_mock)
# First validation should succeed
response_data = await client._validate_api_section("heating")
assert response_data is not None
# Second call should return None (already validated)
response_data = await client._validate_api_section("heating")
assert response_data is None
@pytest.mark.asyncio
async def test_validation_error_resets_section(monkeypatch: Any) -> None:
"""Test that validation errors reset the section (line 212)."""
async with aiohttp.ClientSession() as session:
config = BSBLANConfig(host="example.com")
client = BSBLAN(config, session=session)
client._api_version = "v1"
# Copy each section dictionary to avoid modifying the shared constant
source_config = API_VERSIONS["v1"]
client._api_data = cast(
"APIConfig",
{
section: cast("dict[str, str]", params).copy()
for section, params in source_config.items()
},
)
client._api_validator = APIValidator(client._api_data)
# Mock request to raise an error
request_mock: AsyncMock = AsyncMock(side_effect=BSBLANError("Test error"))
monkeypatch.setattr(client, "_request", request_mock)
# This should raise BSBLANError and reset validation
with pytest.raises(BSBLANError, match="Test error"):
await client._validate_api_section("heating")
@pytest.mark.asyncio
async def test_setup_api_validator_api_data_already_exists() -> None:
"""Test _setup_api_validator when _api_data already exists."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
bsblan._api_version = "v3"
# Pre-set api_data
existing_data = {"heating": {"700": "operating_mode"}}
bsblan._api_data = existing_data # type: ignore[assignment]
await bsblan._setup_api_validator()
# _api_data should remain unchanged (not overwritten)
assert bsblan._api_data is existing_data
assert bsblan._api_validator is not None
@pytest.mark.asyncio
async def test_setup_api_validator_initializes_api_data() -> None:
"""Test _setup_api_validator initializes _api_data when None."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
bsblan._api_version = "v3"
# Ensure _api_data is None
bsblan._api_data = None
await bsblan._setup_api_validator()
# _api_data should be initialized from API config
assert bsblan._api_data is not None
assert "heating" in bsblan._api_data
assert bsblan._api_validator is not None
@pytest.mark.asyncio
async def test_ensure_section_validated_double_check_after_lock() -> None:
"""Test double-check locking in _ensure_section_validated."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
bsblan._api_version = "v3"
bsblan._api_data = {"heating": {"700": "operating_mode"}} # type: ignore[assignment]
bsblan._api_validator = APIValidator(bsblan._api_data)
# Track validation calls
validation_count = 0
async def mock_validate(
section: str, _include: list[str] | None = None
) -> dict[str, Any]:
nonlocal validation_count
validation_count += 1
# Mark section as validated
bsblan._api_validator.validated_sections.add(section)
return {}
bsblan._validate_api_section = mock_validate # type: ignore[method-assign]
# Create the lock first
bsblan._section_locks["heating"] = asyncio.Lock()
# First call validates
await bsblan._ensure_section_validated("heating")
assert validation_count == 1
# Second call should skip (fast path - no lock needed)
await bsblan._ensure_section_validated("heating")
assert validation_count == 1 # Still 1, not called again
@pytest.mark.asyncio
async def test_ensure_section_validated_concurrent_double_check() -> None:
"""Test that concurrent calls don't duplicate validation."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
bsblan._api_version = "v3"
bsblan._api_data = {"heating": {"700": "operating_mode"}} # type: ignore[assignment]
bsblan._api_validator = APIValidator(bsblan._api_data)
validation_count = 0
validation_started = asyncio.Event()
async def slow_validate(
section: str, _include: list[str] | None = None
) -> dict[str, Any]:
nonlocal validation_count
validation_count += 1
validation_started.set()
# Simulate slow validation
await asyncio.sleep(0.1)
bsblan._api_validator.validated_sections.add(section)
return {}
bsblan._validate_api_section = slow_validate # type: ignore[method-assign]
# Start two concurrent validations
task1 = asyncio.create_task(bsblan._ensure_section_validated("heating"))
# Wait for first validation to start
await validation_started.wait()
task2 = asyncio.create_task(bsblan._ensure_section_validated("heating"))
await asyncio.gather(task1, task2)
# Only one validation should have occurred due to double-check locking
assert validation_count == 1
@pytest.mark.asyncio
async def test_validate_api_section_hot_water_cache() -> None:
"""Test that hot_water section validation populates the cache."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
bsblan._api_version = "v3"
bsblan._api_data = {
"heating": {},
"sensor": {},
"staticValues": {},
"device": {},
"hot_water": {"1600": "operating_mode", "1610": "nominal_setpoint"},
}
bsblan._api_validator = APIValidator(bsblan._api_data)
# Mock the request
bsblan._request = AsyncMock( # type: ignore[method-assign]
return_value={
"1600": {"value": "1", "unit": ""},
"1610": {"value": "55", "unit": "°C"},
}
)
# Validate hot_water section
await bsblan._validate_api_section("hot_water")
# Cache should be populated
assert len(bsblan._hot_water_param_cache) > 0
@pytest.mark.asyncio
async def test_ensure_section_validated_heating_extracts_temp_unit() -> None:
"""Test that heating section validation extracts temperature unit."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
bsblan._api_version = "v3"
bsblan._api_data = {"heating": {"710": "target_temperature"}} # type: ignore[assignment]
bsblan._api_validator = APIValidator(bsblan._api_data)
# Mock _validate_api_section to return response with temp unit
async def mock_validate(
section: str, _include: list[str] | None = None
) -> dict[str, Any]:
bsblan._api_validator.validated_sections.add(section)
if section == "heating":
return {"710": {"value": "20.0", "unit": "°F"}}
return {}
bsblan._validate_api_section = mock_validate # type: ignore[method-assign]
await bsblan._ensure_section_validated("heating")
# Temperature unit should be extracted from heating response
assert bsblan._temperature_unit == "°F"
@pytest.mark.asyncio
async def test_setup_api_validator_skips_api_data_init_when_exists() -> None:
"""Test that _setup_api_validator doesn't override existing _api_data."""
async with aiohttp.ClientSession() as session:
bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session)
bsblan._api_version = "v3"
# Pre-set custom api_data
custom_data: dict[str, Any] = {"heating": {"custom": "value"}}
bsblan._api_data = custom_data # type: ignore[assignment]
# Track if _copy_api_config is called
copy_called = False
original_copy = bsblan._copy_api_config
def mock_copy() -> Any:
nonlocal copy_called
copy_called = True
return original_copy()
bsblan._copy_api_config = mock_copy # type: ignore[method-assign]
await bsblan._setup_api_validator()
# _copy_api_config should NOT be called since _api_data exists
assert not copy_called
# Original data should be preserved
assert bsblan._api_data is custom_data
|