Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand Down
53 changes: 53 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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"),
)
89 changes: 21 additions & 68 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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):
Expand All @@ -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())
Expand All @@ -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())

Expand All @@ -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())
Expand Down Expand Up @@ -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())
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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())
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand All @@ -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
3 changes: 0 additions & 3 deletions tests/test_enums.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
31 changes: 11 additions & 20 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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:
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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)
3 changes: 0 additions & 3 deletions tests/test_obfuscate.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading