diff --git a/pyproject.toml b/pyproject.toml index 06d75443..1bfe80eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,9 @@ select = [ ] ignore = ["E501"] # Line too long +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # Allow assert in tests + [tool.ruff.lint.pydocstyle] convention = "google" # Accepts: "google", "numpy", or "pep257". diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..be1ef79d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +"""Shared pytest fixtures for the test suite.""" + +from __future__ import annotations + +import json + +import pytest_asyncio + +from pyoverkiz.client import OverkizClient +from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.utils import generate_local_server + + +class MockResponse: + """Simple stand-in for aiohttp responses used in tests.""" + + def __init__(self, text: str, status: int = 200, url: str = "") -> None: + """Create a mock response with text payload and optional status/url.""" + self._text = text + self.status = status + self.url = url + + async def text(self) -> str: + """Return text payload asynchronously.""" + return self._text + + async def json(self, content_type: str | None = None) -> dict: + """Return parsed JSON payload asynchronously.""" + return json.loads(self._text) + + async def __aexit__(self, exc_type, exc, tb) -> None: + """Context manager exit (noop).""" + pass + + async def __aenter__(self) -> MockResponse: + """Context manager enter returning self.""" + return self + + +@pytest_asyncio.fixture +async def client() -> OverkizClient: + """Fixture providing an OverkizClient configured for the cloud server.""" + return OverkizClient("username", "password", SUPPORTED_SERVERS["somfy_europe"]) + + +@pytest_asyncio.fixture +async def local_client() -> OverkizClient: + """Fixture providing an OverkizClient configured for a local (developer) server.""" + return OverkizClient( + "username", + "password", + generate_local_server("gateway-1234-5678-1243.local:8443"), + ) diff --git a/tests/test_client.py b/tests/test_client.py index 7b844ff0..ad0924a1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,46 +1,28 @@ """Unit tests for the high-level OverkizClient behaviour and responses.""" -# ruff: noqa: S101, ASYNC230 -# S101: Tests use assert statements +# ruff: noqa: ASYNC230 # ASYNC230: Blocking open() is acceptable for reading test fixtures from __future__ import annotations -import json -import os +from pathlib import Path from unittest.mock import patch import aiohttp import pytest -from pytest_asyncio import fixture from pyoverkiz import exceptions from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS from pyoverkiz.enums import APIType, DataType from pyoverkiz.models import Option -from pyoverkiz.utils import generate_local_server +from tests.conftest import MockResponse -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +CURRENT_DIR = Path(__file__).parent class TestOverkizClient: """Tests for the public OverkizClient behaviour (API type, devices, events, setup and diagnostics).""" - @fixture - async def client(self): - """Fixture providing an OverkizClient configured for the cloud server.""" - return OverkizClient("username", "password", SUPPORTED_SERVERS["somfy_europe"]) - - @fixture - async def local_client(self): - """Fixture providing an OverkizClient configured for a local (developer) server.""" - return OverkizClient( - "username", - "password", - generate_local_server("gateway-1234-5678-1243.local:8443"), - ) - @pytest.mark.asyncio async def test_get_api_type_cloud(self, client: OverkizClient): """Verify that a cloud-configured client reports APIType.CLOUD.""" @@ -54,9 +36,7 @@ async def test_get_api_type_local(self, local_client: OverkizClient): @pytest.mark.asyncio async def test_get_devices_basic(self, client: OverkizClient): """Ensure the client can fetch and parse the basic devices fixture.""" - with open( - os.path.join(CURRENT_DIR, "devices.json"), encoding="utf-8" - ) as raw_devices: + with open(CURRENT_DIR / "devices.json", encoding="utf-8") as raw_devices: resp = MockResponse(raw_devices.read()) with patch.object(aiohttp.ClientSession, "get", return_value=resp): @@ -76,7 +56,7 @@ async def test_fetch_events_basic( ): """Parameterised test that fetches events fixture and checks the expected count.""" with open( - os.path.join(CURRENT_DIR, "fixtures/event/" + fixture_name), + CURRENT_DIR / "fixtures" / "event" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -89,7 +69,7 @@ async def test_fetch_events_basic( async def test_fetch_events_simple_cast(self, client: OverkizClient): """Check that event state values from the cloud (strings) are cast to appropriate types.""" with open( - os.path.join(CURRENT_DIR, "fixtures/event/events.json"), encoding="utf-8" + CURRENT_DIR / "fixtures" / "event" / "events.json", encoding="utf-8" ) as raw_events: resp = MockResponse(raw_events.read()) @@ -113,7 +93,7 @@ async def test_fetch_events_simple_cast(self, client: OverkizClient): async def test_fetch_events_casting(self, client: OverkizClient, fixture_name: str): """Validate that fetched event states are cast to the expected Python types for each data type.""" with open( - os.path.join(CURRENT_DIR, "fixtures/event/" + fixture_name), + CURRENT_DIR / "fixtures" / "event" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -182,7 +162,7 @@ async def test_get_setup( ): """Ensure setup parsing yields expected device and gateway counts and device metadata.""" with open( - os.path.join(CURRENT_DIR, "fixtures/setup/" + fixture_name), + CURRENT_DIR / "fixtures" / "setup" / fixture_name, encoding="utf-8", ) as setup_mock: resp = MockResponse(setup_mock.read()) @@ -194,9 +174,9 @@ async def test_get_setup( assert len(setup.gateways) == gateway_count for device in setup.devices: - assert device.gateway_id - assert device.device_address - assert device.protocol + assert device.gateway_id is not None + assert device.device_address is not None + assert device.protocol is not None @pytest.mark.parametrize( "fixture_name", @@ -231,14 +211,14 @@ async def test_get_setup( async def test_get_diagnostic_data(self, client: OverkizClient, fixture_name: str): """Verify that diagnostic data can be fetched and is not empty.""" with open( - os.path.join(CURRENT_DIR, "fixtures/setup/" + fixture_name), + CURRENT_DIR / "fixtures" / "setup" / fixture_name, encoding="utf-8", ) as setup_mock: resp = MockResponse(setup_mock.read()) with patch.object(aiohttp.ClientSession, "get", return_value=resp): diagnostics = await client.get_diagnostic_data() - assert diagnostics + assert diagnostics is not None @pytest.mark.parametrize( "fixture_name, exception, status_code", @@ -355,7 +335,7 @@ async def test_check_response_exception_handling( with pytest.raises(exception): if fixture_name: with open( - os.path.join(CURRENT_DIR, "fixtures/exceptions/" + fixture_name), + CURRENT_DIR / "fixtures" / "exceptions" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read(), status_code) @@ -371,7 +351,7 @@ async def test_get_setup_options( ): """Check that setup options are parsed and return the expected number of Option instances.""" with open( - os.path.join(CURRENT_DIR, "fixtures/endpoints/setup-options.json"), + CURRENT_DIR / "fixtures" / "endpoints" / "setup-options.json", encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -404,7 +384,7 @@ async def test_get_setup_option( ): """Verify retrieval of a single setup option by name, including non-existent options.""" with open( - os.path.join(CURRENT_DIR, "fixtures/endpoints/" + fixture_name), + CURRENT_DIR / "fixtures" / "endpoints" / fixture_name, encoding="utf-8", ) as raw_events: resp = MockResponse(raw_events.read()) @@ -435,7 +415,7 @@ async def test_get_scenarios( ): """Ensure action groups (scenarios) are parsed correctly and contain actions and commands.""" with open( - os.path.join(CURRENT_DIR, "fixtures/action_groups/" + fixture_name), + CURRENT_DIR / "fixtures" / "action_groups" / fixture_name, encoding="utf-8", ) as action_group_mock: resp = MockResponse(action_group_mock.read()) @@ -446,40 +426,13 @@ async def test_get_scenarios( assert len(scenarios) == scenario_count for scenario in scenarios: - assert scenario.oid + assert scenario.oid is not None assert scenario.label is not None assert scenario.actions for action in scenario.actions: - assert action.device_url + assert action.device_url is not None assert action.commands for command in action.commands: - assert command.name - - -class MockResponse: - """Simple stand-in for aiohttp responses used in tests.""" - - def __init__(self, text, status=200, url=""): - """Create a mock response with text payload and optional status/url.""" - self._text = text - self.status = status - self.url = url - - async def text(self): - """Return text payload asynchronously.""" - return self._text - - # pylint: disable=unused-argument - async def json(self, content_type=None): - """Return parsed JSON payload asynchronously.""" - return json.loads(self._text) - - async def __aexit__(self, exc_type, exc, tb): - """Context manager exit (noop).""" - pass - - async def __aenter__(self): - """Context manager enter returning self.""" - return self + assert command.name is not None diff --git a/tests/test_enums.py b/tests/test_enums.py index b693231a..79abfd8b 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -1,8 +1,5 @@ """Tests for enum helper behaviour and expected values.""" -# ruff: noqa: S101 -# Tests use assert statements - from pyoverkiz.enums import ( EventName, ExecutionSubType, diff --git a/tests/test_models.py b/tests/test_models.py index 19ef17a4..5848cd1d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,5 @@ """Unit tests for models (Device, State and States helpers).""" -# ruff: noqa: S101 -# Tests use assert statements - from __future__ import annotations import humps @@ -201,7 +198,7 @@ def test_none_states(self): hump_device = humps.decamelize(RAW_DEVICES) del hump_device["states"] device = Device(**hump_device) - assert not device.states.get(STATE) + assert device.states.get(STATE) is None class TestStates: @@ -211,19 +208,19 @@ def test_empty_states(self): """An empty list yields an empty States object with no state found.""" states = States([]) assert not states - assert not states.get(STATE) + assert states.get(STATE) is None def test_none_states(self): """A None value for states should behave as empty.""" states = States(None) assert not states - assert not states.get(STATE) + assert states.get(STATE) is None def test_getter(self): """Retrieve a known state and validate its properties.""" states = States(RAW_STATES) state = states.get(STATE) - assert state + assert state is not None assert state.name == STATE assert state.type == DataType.STRING assert state.value == "alarm name" @@ -232,7 +229,7 @@ def test_getter_missing(self): """Requesting a missing state returns falsy (None).""" states = States(RAW_STATES) state = states.get("FooState") - assert not state + assert state is None class TestState: @@ -246,8 +243,7 @@ def test_int_value(self): def test_bad_int_value(self): """Accessor raises TypeError if the state type mismatches expected int.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_int + pytest.raises(TypeError, lambda: state.value_as_int) def test_float_value(self): """Float typed state returns proper float accessor.""" @@ -257,8 +253,7 @@ def test_float_value(self): def test_bad_float_value(self): """Accessor raises TypeError if the state type mismatches expected float.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_float + pytest.raises(TypeError, lambda: state.value_as_float) def test_bool_value(self): """Boolean typed state returns proper boolean accessor.""" @@ -268,8 +263,7 @@ def test_bool_value(self): def test_bad_bool_value(self): """Accessor raises TypeError if the state type mismatches expected bool.""" state = State(name="state", type=DataType.INTEGER, value=1) - with pytest.raises(TypeError): - assert state.value_as_bool + pytest.raises(TypeError, lambda: state.value_as_bool) def test_str_value(self): """String typed state returns proper string accessor.""" @@ -279,8 +273,7 @@ def test_str_value(self): def test_bad_str_value(self): """Accessor raises TypeError if the state type mismatches expected string.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_str + pytest.raises(TypeError, lambda: state.value_as_str) def test_dict_value(self): """JSON object typed state returns proper dict accessor.""" @@ -290,8 +283,7 @@ def test_dict_value(self): def test_bad_dict_value(self): """Accessor raises TypeError if the state type mismatches expected dict.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_dict + pytest.raises(TypeError, lambda: state.value_as_dict) def test_list_value(self): """JSON array typed state returns proper list accessor.""" @@ -301,5 +293,4 @@ def test_list_value(self): def test_bad_list_value(self): """Accessor raises TypeError if the state type mismatches expected list.""" state = State(name="state", type=DataType.BOOLEAN, value=False) - with pytest.raises(TypeError): - assert state.value_as_list + pytest.raises(TypeError, lambda: state.value_as_list) diff --git a/tests/test_obfuscate.py b/tests/test_obfuscate.py index 41063946..ec129460 100644 --- a/tests/test_obfuscate.py +++ b/tests/test_obfuscate.py @@ -1,8 +1,5 @@ """Tests for the obfuscation utilities used in fixtures and logging.""" -# ruff: noqa: S101 -# Tests use assert statements - import pytest from pyoverkiz.obfuscate import obfuscate_email, obfuscate_sensitive_data diff --git a/tests/test_utils.py b/tests/test_utils.py index 22738e16..114d8454 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,5 @@ """Tests for utility helper functions like server generation and gateway checks.""" -# ruff: noqa: S101 -# Tests use assert statements - import pytest from pyoverkiz.utils import generate_local_server, is_overkiz_gateway @@ -18,7 +15,7 @@ def test_generate_local_server(self): """Create a local server descriptor using the host and default values.""" local_server = generate_local_server(host=LOCAL_HOST) - assert local_server + assert local_server is not None assert ( local_server.endpoint == "https://gateway-1234-5678-1243.local:8443/enduser-mobile-web/1/enduserAPI/" @@ -36,7 +33,7 @@ def test_generate_local_server_by_ip(self): configuration_url="https://somfy.com", ) - assert local_server + assert local_server is not None assert ( local_server.endpoint == "https://192.168.1.105:8443/enduser-mobile-web/1/enduserAPI/"