From 8475b08cf60e86054931bd592c42bb96c8ec20f7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 19 Apr 2026 19:10:17 +0000 Subject: [PATCH 1/4] Enhance error handling and testing for Overkiz API - Update `get_current_execution` method to return None for non-existent executions. - Add new exceptions: NoSuchDeviceError and NoSuchActionGroupError. - Introduce error handling probes in `test_error_handling.py` for various invalid inputs. - Add test cases for empty responses and specific error scenarios in `test_client.py`. - Create JSON fixtures for various error responses to improve testing coverage. --- pyoverkiz/client.py | 11 +- pyoverkiz/exceptions.py | 8 + pyoverkiz/models.py | 26 +- pyoverkiz/response_handler.py | 4 + scripts/test_error_handling.py | 658 ++++++++++++++++++ tests/fixtures/endpoints/device-states.json | 17 + tests/fixtures/endpoints/events-register.json | 1 + tests/fixtures/endpoints/exec-apply.json | 1 + .../endpoints/exec-current-empty-list.json | 1 + .../endpoints/exec-current-empty-object.json | 1 + tests/fixtures/endpoints/exec-schedule.json | 1 + .../endpoints/history-executions.json | 52 ++ tests/fixtures/endpoints/setup-places.json | 25 + .../cloud/action-group-setup-not-found.json | 1 + .../cloud/no-such-action-group.json | 1 + .../cloud/no-such-controllable.json | 1 + .../exceptions/cloud/no-such-ui-profile.json | 1 + ...e-access-denied-device-setup-mismatch.json | 1 + ...ce-access-denied-gateway-not-in-setup.json | 1 + tests/fixtures/exec/current-single.json | 26 + tests/test_client.py | 288 +++++++- 21 files changed, 1112 insertions(+), 14 deletions(-) create mode 100644 scripts/test_error_handling.py create mode 100644 tests/fixtures/endpoints/device-states.json create mode 100644 tests/fixtures/endpoints/events-register.json create mode 100644 tests/fixtures/endpoints/exec-apply.json create mode 100644 tests/fixtures/endpoints/exec-current-empty-list.json create mode 100644 tests/fixtures/endpoints/exec-current-empty-object.json create mode 100644 tests/fixtures/endpoints/exec-schedule.json create mode 100644 tests/fixtures/endpoints/history-executions.json create mode 100644 tests/fixtures/endpoints/setup-places.json create mode 100644 tests/fixtures/exceptions/cloud/action-group-setup-not-found.json create mode 100644 tests/fixtures/exceptions/cloud/no-such-action-group.json create mode 100644 tests/fixtures/exceptions/cloud/no-such-controllable.json create mode 100644 tests/fixtures/exceptions/cloud/no-such-ui-profile.json create mode 100644 tests/fixtures/exceptions/cloud/resource-access-denied-device-setup-mismatch.json create mode 100644 tests/fixtures/exceptions/cloud/resource-access-denied-gateway-not-in-setup.json create mode 100644 tests/fixtures/exec/current-single.json diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 47ea116e..1577d19a 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -447,9 +447,16 @@ async def unregister_event_listener(self) -> None: self.event_listener_id = None @retry_on_auth_error - async def get_current_execution(self, exec_id: str) -> Execution: - """Get a currently running execution by its exec_id.""" + async def get_current_execution(self, exec_id: str) -> Execution | None: + """Get a currently running execution by its exec_id. + + Returns None if the execution does not exist. + """ response = await self._get(f"exec/current/{exec_id}") + + if not response or not isinstance(response, dict): + return None + return Execution(**decamelize(response)) @retry_on_auth_error diff --git a/pyoverkiz/exceptions.py b/pyoverkiz/exceptions.py index 77df2062..54dd98a1 100644 --- a/pyoverkiz/exceptions.py +++ b/pyoverkiz/exceptions.py @@ -97,6 +97,14 @@ class UnknownUserError(BaseOverkizError): """Raised when an unknown user is provided.""" +class NoSuchDeviceError(BaseOverkizError): + """Raised when the requested device does not exist.""" + + +class NoSuchActionGroupError(BaseOverkizError): + """Raised when the requested action group does not exist.""" + + class UnknownObjectError(BaseOverkizError): """Raised when an unknown object is provided.""" diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index b25fca50..2f4339db 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -805,8 +805,11 @@ class Execution: id: str description: str owner: str = field(repr=obfuscate_email) - state: str + state: ExecutionState action_group: ActionGroup + start_time: int | None = None + execution_type: ExecutionType | None = None + execution_sub_type: ExecutionSubType | None = None def __init__( self, @@ -815,14 +818,22 @@ def __init__( owner: str, state: str, action_group: dict[str, Any], + start_time: int | None = None, + execution_type: str | None = None, + execution_sub_type: str | None = None, **_: Any, ): """Initialize Execution object from API fields.""" self.id = id self.description = description self.owner = owner - self.state = state + self.state = ExecutionState(state) self.action_group = ActionGroup(**action_group) + self.start_time = start_time + self.execution_type = ExecutionType(execution_type) if execution_type else None + self.execution_sub_type = ( + ExecutionSubType(execution_sub_type) if execution_sub_type else None + ) @define(init=False, kw_only=True) @@ -858,7 +869,7 @@ class ActionGroup: is composed of one or more commands to be executed on that device. """ - id: str = field(repr=obfuscate_id) + id: str | None = field(default=None, repr=obfuscate_id) creation_time: int | None = None last_update_time: int | None = None label: str = field(repr=obfuscate_string) @@ -869,7 +880,7 @@ class ActionGroup: notification_text: str | None = None notification_title: str | None = None actions: list[Action] - oid: str = field(repr=obfuscate_id) + oid: str | None = field(default=None, repr=obfuscate_id) def __init__( self, @@ -888,10 +899,7 @@ def __init__( **_: Any, ) -> None: """Initialize ActionGroup from API data and convert nested actions.""" - if oid is None and id is None: - raise ValueError("Either 'oid' or 'id' must be provided") - - self.id = cast(str, oid or id) + self.id = oid or id self.creation_time = creation_time self.last_update_time = last_update_time self.label = ( @@ -904,7 +912,7 @@ def __init__( self.notification_text = notification_text self.notification_title = notification_title self.actions = [Action(**action) for action in actions] - self.oid = cast(str, oid or id) + self.oid = oid or id @define(init=False, kw_only=True) diff --git a/pyoverkiz/response_handler.py b/pyoverkiz/response_handler.py index 97e2582b..3210087b 100644 --- a/pyoverkiz/response_handler.py +++ b/pyoverkiz/response_handler.py @@ -22,6 +22,8 @@ MissingAPIKeyError, MissingAuthorizationTokenError, NoRegisteredEventListenerError, + NoSuchActionGroupError, + NoSuchDeviceError, NoSuchResourceError, NoSuchTokenError, NotAuthenticatedError, @@ -46,6 +48,8 @@ ("INVALID_FIELD_VALUE", None, ActionGroupSetupNotFoundError), ("INVALID_API_CALL", None, NoSuchResourceError), ("EXEC_QUEUE_FULL", None, ExecutionQueueFullError), + ("NO_SUCH_DEVICE", None, NoSuchDeviceError), + ("NO_SUCH_ACTION_GROUP", None, NoSuchActionGroupError), # --- errorCode + message substring --- ("AUTHENTICATION_ERROR", "Too many requests", TooManyRequestsError), ("AUTHENTICATION_ERROR", "Bad credentials", BadCredentialsError), diff --git a/scripts/test_error_handling.py b/scripts/test_error_handling.py new file mode 100644 index 00000000..ca21f5e2 --- /dev/null +++ b/scripts/test_error_handling.py @@ -0,0 +1,658 @@ +#!/usr/bin/env python3 +"""Probe real Overkiz cloud and local API endpoints with invalid inputs. + +Tests that every error response is caught as a specific pyoverkiz exception +rather than crashing with an unhandled error, KeyError, etc. + +Usage: + # Cloud only (needs username/password): + python scripts/test_error_handling.py \ + --server somfy_europe \ + --username you@example.com \ + --password secret + + # Local only (needs host + token): + python scripts/test_error_handling.py \ + --local-host gateway-1234-5678-9012.local:8443 \ + --local-token YOUR_TOKEN + + # Both at once: + python scripts/test_error_handling.py \ + --server somfy_europe \ + --username you@example.com \ + --password secret \ + --local-host gateway-1234-5678-9012.local:8443 \ + --local-token YOUR_TOKEN + +Each probe is independent — a failure in one does not stop the rest. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import traceback +from dataclasses import dataclass, field + +import aiohttp +from aiohttp import ClientSession, TraceConfig + +from pyoverkiz.auth.credentials import ( + LocalTokenCredentials, + UsernamePasswordCredentials, +) +from pyoverkiz.client import OverkizClient +from pyoverkiz.enums import APIType, Server +from pyoverkiz.exceptions import BaseOverkizError +from pyoverkiz.models import Action, Command, Execution +from pyoverkiz.utils import create_local_server_config + +FAKE_DEVICE_URL = "io://0000-0000-0000/12345678" +FAKE_EXEC_ID = "00000000-0000-0000-0000-000000000000" +FAKE_OID = "00000000-0000-0000-0000-000000000000" +FAKE_OPTION = "nonExistentOption-0000-0000-0000" +FAKE_CONTROLLABLE = "io:NonExistentDeviceControllable" +FAKE_PROFILE = "NonExistentProfile" + + +# ── HTTP tracing ────────────────────────────────────────────────────────────── + +# Collects full request/response details per-probe so they can be printed +# alongside the result. Each entry is a dict with method, url, status, +# request/response headers, and response body. + +_current_traces: list[dict] = [] + + +async def _on_request_start( + session: ClientSession, + trace_ctx: object, + params: aiohttp.TraceRequestStartParams, +) -> None: + _current_traces.append( + { + "method": params.method, + "url": str(params.url), + "req_headers": dict(params.headers), + } + ) + + +async def _on_request_end( + session: ClientSession, + trace_ctx: object, + params: aiohttp.TraceRequestEndParams, +) -> None: + # .text() caches the body internally, so the caller can still read it. + body = await params.response.text() + entry = { + "status": params.response.status, + "resp_headers": dict(params.response.headers), + "resp_body": body, + } + if _current_traces: + _current_traces[-1].update(entry) + else: + _current_traces.append(entry) + + +def _build_trace_config() -> TraceConfig: + tc = TraceConfig() + tc.on_request_start.append(_on_request_start) + tc.on_request_end.append(_on_request_end) + return tc + + +def _format_trace(trace: dict) -> str: + lines: list[str] = [] + method = trace.get("method", "?") + url = trace.get("url", "?") + status = trace.get("status", "?") + lines.append(f" >>> {method} {url}") + + req_headers = trace.get("req_headers", {}) + for k, v in req_headers.items(): + if k.lower() in ("authorization", "cookie"): + v = v[:20] + "..." if len(v) > 20 else v + lines.append(f" {k}: {v}") + + lines.append(f" <<< {status}") + + resp_headers = trace.get("resp_headers", {}) + for k, v in resp_headers.items(): + lines.append(f" {k}: {v}") + + body = trace.get("resp_body", "") + if body: + # Truncate very long bodies but keep enough to see the error + if len(body) > 500: + body = body[:500] + f"... ({len(body)} bytes total)" + lines.append(f" Body: {body}") + + return "\n".join(lines) + + +# ── Probe runner ────────────────────────────────────────────────────────────── + + +@dataclass +class ProbeResult: + name: str + api: str + passed: bool + kind: str = "error" + exception_type: str = "" + detail: str = "" + traces: list[dict] = field(default_factory=list) + + +@dataclass +class ProbeRunner: + results: list[ProbeResult] = field(default_factory=list) + quiet: bool = False + + async def probe(self, name: str, api: str, coro): + """Probe that expects a BaseOverkizError to be raised.""" + _current_traces.clear() + try: + await coro + self.results.append( + ProbeResult( + name=name, + api=api, + passed=False, + kind="error", + detail="Expected an error but call succeeded", + traces=list(_current_traces), + ) + ) + except BaseOverkizError as exc: + self.results.append( + ProbeResult( + name=name, + api=api, + passed=True, + kind="error", + exception_type=type(exc).__name__, + detail=str(exc)[:200], + traces=list(_current_traces), + ) + ) + except Exception as exc: + self.results.append( + ProbeResult( + name=name, + api=api, + passed=False, + kind="error", + exception_type=type(exc).__name__, + detail=str(exc)[:200], + traces=list(_current_traces), + ) + ) + + async def probe_graceful(self, name: str, api: str, coro, check=None): + """Probe that expects no exception — the client should handle the + response gracefully (return None, recover via retry, etc.). + """ + _current_traces.clear() + try: + result = await coro + detail = ( + f"Returned: {result!r}"[:200] + if result is not None + else "Returned: None" + ) + ok = check(result) if check else True + self.results.append( + ProbeResult( + name=name, + api=api, + passed=ok, + kind="graceful", + detail=detail if ok else f"Check failed — {detail}", + traces=list(_current_traces), + ) + ) + except Exception as exc: + self.results.append( + ProbeResult( + name=name, + api=api, + passed=False, + kind="graceful", + exception_type=type(exc).__name__, + detail=f"Expected graceful handling but got: {exc!s}"[:200], + traces=list(_current_traces), + ) + ) + + async def probe_command(self, name: str, api: str, coro): + """Probe that expects a successful command execution returning an exec_id.""" + _current_traces.clear() + try: + result = await coro + ok = isinstance(result, str) and len(result) > 0 + self.results.append( + ProbeResult( + name=name, + api=api, + passed=ok, + kind="command", + detail=f"exec_id: {result}" + if ok + else f"Unexpected result: {result!r}", + traces=list(_current_traces), + ) + ) + except Exception as exc: + self.results.append( + ProbeResult( + name=name, + api=api, + passed=False, + kind="command", + exception_type=type(exc).__name__, + detail=str(exc)[:200], + traces=list(_current_traces), + ) + ) + + def print_report(self): + passed = sum(1 for r in self.results if r.passed) + failed = sum(1 for r in self.results if not r.passed) + + kind_labels = { + "error": "Error probes (expect BaseOverkizError)", + "graceful": "Graceful handling probes (expect no crash)", + "command": "Command probes (expect exec_id)", + } + + print("\n" + "=" * 80) + print(f" PROBE RESULTS: {passed} passed, {failed} failed") + print("=" * 80) + + for kind, label in kind_labels.items(): + kind_results = [r for r in self.results if r.kind == kind] + if not kind_results: + continue + + print(f"\n --- {label} ---") + for r in kind_results: + status = "PASS" if r.passed else "FAIL" + icon = "+" if r.passed else "-" + exc_info = f" -> {r.exception_type}" if r.exception_type else "" + print(f"\n [{icon}] {status} [{r.api:5s}] {r.name}{exc_info}") + if r.detail: + print(f" {r.detail}") + + if not self.quiet and r.traces: + for trace in r.traces: + print(_format_trace(trace)) + elif not self.quiet and not r.traces: + print(" (no HTTP trace captured)") + + print("\n" + "=" * 80) + if failed: + print(f"\n {failed} probe(s) FAILED!") + else: + print("\n All probes passed.") + print() + + +async def probe_client( + client: OverkizClient, + api_label: str, + runner: ProbeRunner, + rts_device_url: str | None = None, +): + """Run all probes against a single client instance. + + Args: + rts_device_url: URL of a real RTS device to test commands on. RTS covers + are fire-and-forget (no state feedback), so sending close/open/my/stop + is safe even on real hardware — the worst that happens is a shutter moves. + """ + # --- Sanity checks (run first to verify connectivity) --- + + try: + devices = await client.get_devices(refresh=True) + print( + f" [*] Sanity check [{api_label}]: get_devices returned {len(devices)} devices" + ) + except Exception as exc: + print(f" [!] Sanity check [{api_label}]: get_devices FAILED: {exc}") + + try: + executions = await client.get_current_executions() + print( + f" [*] Sanity check [{api_label}]: get_current_executions returned {len(executions)} items" + ) + except Exception as exc: + print(f" [!] Sanity check [{api_label}]: get_current_executions FAILED: {exc}") + + is_local = client.server_config.api_type == APIType.LOCAL + + # --- Error probes: expect BaseOverkizError --- + + await runner.probe( + "get_state(fake device URL)", + api_label, + client.get_state(FAKE_DEVICE_URL), + ) + + await runner.probe( + "get_device_definition(fake device URL)", + api_label, + client.get_device_definition(FAKE_DEVICE_URL), + ) + + await runner.probe( + "get_setup_option_parameter(fake option, fake param)", + api_label, + client.get_setup_option_parameter(FAKE_OPTION, "fakeParam"), + ) + + await runner.probe( + "get_reference_controllable(fake name)", + api_label, + client.get_reference_controllable(FAKE_CONTROLLABLE), + ) + + await runner.probe( + "get_reference_ui_profile(fake profile)", + api_label, + client.get_reference_ui_profile(FAKE_PROFILE), + ) + + await runner.probe( + "refresh_device_states(fake device URL)", + api_label, + client.refresh_device_states(FAKE_DEVICE_URL), + ) + + await runner.probe( + "schedule_persisted_action_group(fake OID)", + api_label, + client.schedule_persisted_action_group(FAKE_OID, 9999999999), + ) + + # Local gateway returns error for fake options; cloud returns empty {} + if is_local: + await runner.probe( + "get_setup_option(fake option)", + api_label, + client.get_setup_option(FAKE_OPTION), + ) + else: + await runner.probe_graceful( + "get_setup_option(fake option)", + api_label, + client.get_setup_option(FAKE_OPTION), + check=lambda result: result is None, + ) + + # Cloud rejects fake devices/OIDs; local gateway accepts them + if is_local: + await runner.probe_command( + "execute_action_group(fake device + bad command)", + api_label, + client.execute_action_group( + [ + Action( + device_url=FAKE_DEVICE_URL, + commands=[Command(name="totallyFakeCommand", parameters=[42])], + ) + ] + ), + ) + + await runner.probe_command( + "execute_persisted_action_group(fake OID)", + api_label, + client.execute_persisted_action_group(FAKE_OID), + ) + else: + await runner.probe( + "execute_action_group(fake device + bad command)", + api_label, + client.execute_action_group( + [ + Action( + device_url=FAKE_DEVICE_URL, + commands=[Command(name="totallyFakeCommand", parameters=[42])], + ) + ] + ), + ) + + await runner.probe( + "execute_persisted_action_group(fake OID)", + api_label, + client.execute_persisted_action_group(FAKE_OID), + ) + + # --- Graceful handling probes: expect no crash --- + + await runner.probe_graceful( + "get_current_execution(fake exec_id)", + api_label, + client.get_current_execution(FAKE_EXEC_ID), + check=lambda result: result is None, + ) + + await runner.probe_graceful( + "cancel_execution(fake exec_id)", + api_label, + client.cancel_execution(FAKE_EXEC_ID), + ) + + saved_listener = client.event_listener_id + + client.event_listener_id = "totally-fake-listener-id" + await runner.probe_graceful( + "fetch_events(fake listener id) -> retry recovery", + api_label, + client.fetch_events(), + check=lambda result: isinstance(result, list), + ) + + client.event_listener_id = saved_listener + + # Execution history is only available on cloud + if not is_local: + await runner.probe_graceful( + "get_execution_history()", + api_label, + client.get_execution_history(), + check=lambda result: isinstance(result, list) and len(result) > 0, + ) + + # --- RTS command probes: execute real commands on RTS covers --- + + if not rts_device_url: + rts_device_url = _find_rts_device(client) + + if rts_device_url: + print(f" [*] Using RTS device: {rts_device_url}\n") + else: + print(" [*] No RTS device found, skipping command probes\n") + + if rts_device_url: + last_exec_id: str | None = None + + for cmd_name in ("close", "my", "stop"): + _current_traces.clear() + try: + exec_id = await client.execute_action_group( + [ + Action( + device_url=rts_device_url, + commands=[Command(name=cmd_name)], + ) + ] + ) + ok = isinstance(exec_id, str) and len(exec_id) > 0 + runner.results.append( + ProbeResult( + name=f"execute_action_group(RTS {cmd_name})", + api=api_label, + passed=ok, + kind="command", + detail=f"exec_id: {exec_id}" + if ok + else f"Unexpected: {exec_id!r}", + traces=list(_current_traces), + ) + ) + if ok: + last_exec_id = exec_id + except Exception as exc: + runner.results.append( + ProbeResult( + name=f"execute_action_group(RTS {cmd_name})", + api=api_label, + passed=False, + kind="command", + exception_type=type(exc).__name__, + detail=str(exc)[:200], + traces=list(_current_traces), + ) + ) + + if last_exec_id: + + def _check_execution(result): + if result is None: + return True + if not isinstance(result, Execution): + return False + return result.action_group is not None + + await runner.probe_graceful( + f"get_current_execution({last_exec_id[:8]}...)", + api_label, + client.get_current_execution(last_exec_id), + check=_check_execution, + ) + + await runner.probe_graceful( + "get_current_executions()", + api_label, + client.get_current_executions(), + check=lambda result: isinstance(result, list), + ) + + +def _find_rts_device(client: OverkizClient) -> str | None: + """Return the URL of the first RTS device, if any.""" + for device in client.devices: + if device.device_url.startswith("rts://"): + return device.device_url + return None + + +async def run_cloud(args: argparse.Namespace, runner: ProbeRunner): + server = Server(args.server) + credentials = UsernamePasswordCredentials(args.username, args.password) + + trace_config = _build_trace_config() + session = ClientSession( + headers={"User-Agent": "python-overkiz-api"}, + trace_configs=[trace_config], + ) + + async with OverkizClient( + server=server, credentials=credentials, session=session + ) as client: + print(f"\nLogging in to cloud ({args.server})...") + await client.login() + print("Logged in. Running probes...\n") + await probe_client(client, "cloud", runner, rts_device_url=args.rts_device) + + +async def run_local(args: argparse.Namespace, runner: ProbeRunner): + server_config = create_local_server_config(host=args.local_host) + credentials = LocalTokenCredentials(token=args.local_token) + + trace_config = _build_trace_config() + session = ClientSession( + headers={"User-Agent": "python-overkiz-api"}, + trace_configs=[trace_config], + ) + + async with OverkizClient( + server=server_config, credentials=credentials, session=session + ) as client: + print(f"\nConnecting to local API ({args.local_host})...") + await client.login() + print("Connected. Running probes...\n") + await probe_client(client, "local", runner, rts_device_url=args.rts_device) + + +async def main(): + parser = argparse.ArgumentParser( + description="Probe Overkiz endpoints for error handling", + epilog="Credentials can also be set via env vars: OVERKIZ_SERVER, OVERKIZ_USERNAME, " + "OVERKIZ_PASSWORD, OVERKIZ_LOCAL_HOST, OVERKIZ_LOCAL_TOKEN", + ) + parser.add_argument( + "--server", type=str, help="Cloud server key (e.g. somfy_europe)" + ) + parser.add_argument("--username", type=str, help="Cloud username") + parser.add_argument("--password", type=str, help="Cloud password") + parser.add_argument("--local-host", type=str, help="Local gateway host:port") + parser.add_argument("--local-token", type=str, help="Local API token") + parser.add_argument( + "--rts-device", + type=str, + help="RTS device URL to test commands on (e.g. rts://2025-8464-6867/16756006). " + "Auto-detected from setup if not specified.", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Only show the summary table, suppress HTTP traces", + ) + args = parser.parse_args() + + # Fall back to env vars for credentials (avoids shell quoting issues with passwords) + args.server = args.server or os.environ.get("OVERKIZ_SERVER") + args.username = args.username or os.environ.get("OVERKIZ_USERNAME") + args.password = args.password or os.environ.get("OVERKIZ_PASSWORD") + args.local_host = args.local_host or os.environ.get("OVERKIZ_LOCAL_HOST") + args.local_token = args.local_token or os.environ.get("OVERKIZ_LOCAL_TOKEN") + args.rts_device = args.rts_device or os.environ.get("OVERKIZ_RTS_DEVICE") + + has_cloud = args.server and args.username and args.password + has_local = args.local_host and args.local_token + + if not has_cloud and not has_local: + parser.error( + "Provide --server/--username/--password for cloud, " + "and/or --local-host/--local-token for local." + ) + + runner = ProbeRunner(quiet=args.quiet) + + if has_cloud: + try: + await run_cloud(args, runner) + except Exception: + print(f"\nCloud session failed:\n{traceback.format_exc()}") + + if has_local: + try: + await run_local(args, runner) + except Exception: + print(f"\nLocal session failed:\n{traceback.format_exc()}") + + runner.print_report() + sys.exit(0 if all(r.passed for r in runner.results) else 1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/endpoints/device-states.json b/tests/fixtures/endpoints/device-states.json new file mode 100644 index 00000000..7967a36f --- /dev/null +++ b/tests/fixtures/endpoints/device-states.json @@ -0,0 +1,17 @@ +[ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:ClosureState", + "type": 1, + "value": 0 + }, + { + "name": "core:OpenClosedState", + "type": 3, + "value": "open" + } +] diff --git a/tests/fixtures/endpoints/events-register.json b/tests/fixtures/endpoints/events-register.json new file mode 100644 index 00000000..c9570251 --- /dev/null +++ b/tests/fixtures/endpoints/events-register.json @@ -0,0 +1 @@ +{"id": "a70f6d96-0a19-0483-72d9-ac5f6bd7da26"} diff --git a/tests/fixtures/endpoints/exec-apply.json b/tests/fixtures/endpoints/exec-apply.json new file mode 100644 index 00000000..7d2340b4 --- /dev/null +++ b/tests/fixtures/endpoints/exec-apply.json @@ -0,0 +1 @@ +{"execId": "ee7a5676-c68f-43a3-956d-6f5efc745954"} diff --git a/tests/fixtures/endpoints/exec-current-empty-list.json b/tests/fixtures/endpoints/exec-current-empty-list.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/tests/fixtures/endpoints/exec-current-empty-list.json @@ -0,0 +1 @@ +[] diff --git a/tests/fixtures/endpoints/exec-current-empty-object.json b/tests/fixtures/endpoints/exec-current-empty-object.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/fixtures/endpoints/exec-current-empty-object.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/endpoints/exec-schedule.json b/tests/fixtures/endpoints/exec-schedule.json new file mode 100644 index 00000000..fa7e8c3f --- /dev/null +++ b/tests/fixtures/endpoints/exec-schedule.json @@ -0,0 +1 @@ +{"triggerId": "abc12345-def6-7890-abcd-ef1234567890"} diff --git a/tests/fixtures/endpoints/history-executions.json b/tests/fixtures/endpoints/history-executions.json new file mode 100644 index 00000000..fe1cf113 --- /dev/null +++ b/tests/fixtures/endpoints/history-executions.json @@ -0,0 +1,52 @@ +[ + { + "id": "699dd967-0a19-0481-7a62-99b990a2feb8", + "eventTime": 1767003511145, + "owner": "email@email.nl", + "source": "modem", + "endTime": 1767003514000, + "effectiveStartTime": 1767003511500, + "duration": 2855, + "label": "close - RTS Roller Shutter", + "type": "ACTUATOR", + "state": "COMPLETED", + "failureType": "NO_FAILURE", + "commands": [ + { + "deviceURL": "rts://2025-8464-6867/16756006", + "command": "close", + "parameters": [], + "rank": 0, + "dynamic": false, + "state": "COMPLETED", + "failureType": "NO_FAILURE" + } + ], + "executionType": "Immediate execution", + "executionSubType": "MANUAL_CONTROL" + }, + { + "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", + "eventTime": 1767002400000, + "owner": "email@email.nl", + "source": "modem", + "duration": 0, + "label": "open - RTS Roller Shutter", + "type": "ACTUATOR", + "state": "FAILED", + "failureType": "CMDCANCELLED", + "commands": [ + { + "deviceURL": "rts://2025-8464-6867/16756006", + "command": "open", + "parameters": [], + "rank": 0, + "dynamic": false, + "state": "FAILED", + "failureType": "CMDCANCELLED" + } + ], + "executionType": "Immediate execution", + "executionSubType": "MANUAL_CONTROL" + } +] diff --git a/tests/fixtures/endpoints/setup-places.json b/tests/fixtures/endpoints/setup-places.json new file mode 100644 index 00000000..910d740e --- /dev/null +++ b/tests/fixtures/endpoints/setup-places.json @@ -0,0 +1,25 @@ +{ + "creationTime": 1650000000000, + "lastUpdateTime": 1767003511145, + "label": "My House", + "type": 0, + "oid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "subPlaces": [ + { + "creationTime": 1650000100000, + "lastUpdateTime": 1767003511145, + "label": "Living Room", + "type": 1, + "oid": "11111111-2222-3333-4444-555555555555", + "subPlaces": [] + }, + { + "creationTime": 1650000200000, + "lastUpdateTime": null, + "label": "Bedroom", + "type": 1, + "oid": "66666666-7777-8888-9999-aaaaaaaaaaaa", + "subPlaces": [] + } + ] +} diff --git a/tests/fixtures/exceptions/cloud/action-group-setup-not-found.json b/tests/fixtures/exceptions/cloud/action-group-setup-not-found.json new file mode 100644 index 00000000..26fd3f50 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/action-group-setup-not-found.json @@ -0,0 +1 @@ +{"errorCode":"INVALID_FIELD_VALUE","error":"Unable to determine action group setup (no setup for gateway #0000-0000-0000)"} diff --git a/tests/fixtures/exceptions/cloud/no-such-action-group.json b/tests/fixtures/exceptions/cloud/no-such-action-group.json new file mode 100644 index 00000000..7e62bb93 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/no-such-action-group.json @@ -0,0 +1 @@ +{"errorCode":"NO_SUCH_ACTION_GROUP","error":"No such action group : 00000000-0000-0000-0000-000000000000"} diff --git a/tests/fixtures/exceptions/cloud/no-such-controllable.json b/tests/fixtures/exceptions/cloud/no-such-controllable.json new file mode 100644 index 00000000..865dee17 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/no-such-controllable.json @@ -0,0 +1 @@ +{"errorCode":"NO_SUCH_RESSOURCE","error":"No such controllable : io:NonExistentDeviceControllable"} diff --git a/tests/fixtures/exceptions/cloud/no-such-ui-profile.json b/tests/fixtures/exceptions/cloud/no-such-ui-profile.json new file mode 100644 index 00000000..1885868c --- /dev/null +++ b/tests/fixtures/exceptions/cloud/no-such-ui-profile.json @@ -0,0 +1 @@ +{"errorCode":"NO_SUCH_RESSOURCE","error":"No such core UI profile or form-factor : NonExistentProfile"} diff --git a/tests/fixtures/exceptions/cloud/resource-access-denied-device-setup-mismatch.json b/tests/fixtures/exceptions/cloud/resource-access-denied-device-setup-mismatch.json new file mode 100644 index 00000000..64c941d1 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/resource-access-denied-device-setup-mismatch.json @@ -0,0 +1 @@ +{"errorCode":"RESOURCE_ACCESS_DENIED","error":"Security exception : Device setup mismatch"} diff --git a/tests/fixtures/exceptions/cloud/resource-access-denied-gateway-not-in-setup.json b/tests/fixtures/exceptions/cloud/resource-access-denied-gateway-not-in-setup.json new file mode 100644 index 00000000..d376e3c3 --- /dev/null +++ b/tests/fixtures/exceptions/cloud/resource-access-denied-gateway-not-in-setup.json @@ -0,0 +1 @@ +{"errorCode":"RESOURCE_ACCESS_DENIED","error":"Security exception : Gateway #0000-0000-0000 does not belong to setup 15eaf55a-8af9-483b-ae4a-ffd4254fd762"} diff --git a/tests/fixtures/exec/current-single.json b/tests/fixtures/exec/current-single.json new file mode 100644 index 00000000..ac59f605 --- /dev/null +++ b/tests/fixtures/exec/current-single.json @@ -0,0 +1,26 @@ +{ + "startTime": 1767003511145, + "owner": "email@email.nl", + "actionGroup": { + "label": "Execution via Home Assistant", + "shortcut": false, + "notificationTypeMask": 0, + "notificationCondition": "NEVER", + "actions": [ + { + "deviceURL": "rts://1234-5678-1234/12345678", + "commands": [ + { + "type": 1, + "name": "close" + } + ] + } + ] + }, + "description": "Execution : Execution via Home Assistant", + "id": "699dd967-0a19-0481-7a62-99b990a2feb8", + "state": "TRANSMITTED", + "executionType": "Immediate execution", + "executionSubType": "MANUAL_CONTROL" +} diff --git a/tests/test_client.py b/tests/test_client.py index d0740a14..63cfa264 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,8 +19,23 @@ UsernamePasswordCredentials, ) from pyoverkiz.client import OverkizClient -from pyoverkiz.enums import APIType, DataType, Server -from pyoverkiz.models import Option +from pyoverkiz.enums import ( + APIType, + DataType, + ExecutionState, + ExecutionSubType, + ExecutionType, + Server, +) +from pyoverkiz.models import ( + Action, + Command, + Execution, + HistoryExecution, + Option, + Place, + State, +) from pyoverkiz.response_handler import check_response from pyoverkiz.utils import create_local_server_config @@ -460,6 +475,36 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): exceptions.ResourceAccessDeniedError, 400, ), + ( + "cloud/resource-access-denied-device-setup-mismatch.json", + exceptions.ResourceAccessDeniedError, + 400, + ), + ( + "cloud/resource-access-denied-gateway-not-in-setup.json", + exceptions.ResourceAccessDeniedError, + 400, + ), + ( + "cloud/no-such-action-group.json", + exceptions.NoSuchActionGroupError, + 404, + ), + ( + "cloud/action-group-setup-not-found.json", + exceptions.ActionGroupSetupNotFoundError, + 400, + ), + ( + "cloud/no-such-controllable.json", + exceptions.OverkizError, + 400, + ), + ( + "cloud/no-such-ui-profile.json", + exceptions.OverkizError, + 400, + ), ( "local/400-bad-parameters.json", exceptions.OverkizError, @@ -503,7 +548,7 @@ async def test_get_diagnostic_data_without_masking(self, client: OverkizClient): ), ( "local/400-no-such-device.json", - exceptions.OverkizError, + exceptions.NoSuchDeviceError, 400, ), ( @@ -667,6 +712,31 @@ async def test_get_setup_option( else: assert isinstance(option, instance) + @pytest.mark.parametrize( + "fixture_name", + [ + "exec-current-empty-object.json", + "exec-current-empty-list.json", + ], + ) + @pytest.mark.asyncio + async def test_get_current_execution_returns_none_for_empty_response( + self, + client: OverkizClient, + fixture_name: str, + ): + """Cloud returns {} and local returns [] for non-existent exec_ids.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / fixture_name).open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + result = await client.get_current_execution( + "00000000-0000-0000-0000-000000000000" + ) + assert result is None + @pytest.mark.parametrize( ("fixture_name", "scenario_count"), [ @@ -706,6 +776,218 @@ async def test_get_action_groups( for command in action.commands: assert command.name + @pytest.mark.asyncio + async def test_get_current_execution_returns_execution(self, client: OverkizClient): + """Verify a running execution is parsed into an Execution model.""" + with (CURRENT_DIR / "fixtures" / "exec" / "current-single.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + result = await client.get_current_execution( + "699dd967-0a19-0481-7a62-99b990a2feb8" + ) + assert isinstance(result, Execution) + assert result.id == "699dd967-0a19-0481-7a62-99b990a2feb8" + assert result.state == ExecutionState.TRANSMITTED + assert result.start_time == 1767003511145 + assert result.execution_type == ExecutionType.IMMEDIATE_EXECUTION + assert result.execution_sub_type == ExecutionSubType.MANUAL_CONTROL + assert result.action_group.oid is None + assert ( + result.action_group.actions[0].device_url + == "rts://1234-5678-1234/12345678" + ) + + @pytest.mark.asyncio + async def test_get_current_executions(self, client: OverkizClient): + """Verify parsing a list of running executions with RTS device commands.""" + with (CURRENT_DIR / "fixtures" / "exec" / "current-tahoma-switch.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + executions = await client.get_current_executions() + assert len(executions) == 1 + assert isinstance(executions[0], Execution) + assert executions[0].state == ExecutionState.TRANSMITTED + assert len(executions[0].action_group.actions) == 2 + assert executions[0].action_group.actions[0].commands[0].name == "close" + assert executions[0].action_group.actions[1].commands[0].name == "identify" + + @pytest.mark.asyncio + async def test_get_execution_history(self, client: OverkizClient): + """Verify execution history parsing including completed and failed executions.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "history-executions.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + history = await client.get_execution_history() + assert len(history) == 2 + + completed = history[0] + assert isinstance(completed, HistoryExecution) + assert completed.state.value == "COMPLETED" + assert completed.failure_type == "NO_FAILURE" + assert completed.commands[0].command == "close" + assert completed.commands[0].device_url == "rts://2025-8464-6867/16756006" + + failed = history[1] + assert failed.state.value == "FAILED" + assert failed.failure_type == "CMDCANCELLED" + assert failed.commands[0].command == "open" + + @pytest.mark.asyncio + async def test_get_state(self, client: OverkizClient): + """Verify device state retrieval and parsing.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "device-states.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + states = await client.get_state("io://1234-5678-1234/12345678") + assert len(states) == 3 + assert all(isinstance(s, State) for s in states) + assert states[0].name == "core:StatusState" + assert states[0].value == "available" + assert states[1].name == "core:ClosureState" + assert states[1].value == 0 + assert states[2].name == "core:OpenClosedState" + assert states[2].value == "open" + + @pytest.mark.asyncio + async def test_get_places(self, client: OverkizClient): + """Verify hierarchical place structure is parsed recursively.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "setup-places.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + places = await client.get_places() + assert isinstance(places, Place) + assert places.label == "My House" + assert len(places.sub_places) == 2 + assert places.sub_places[0].label == "Living Room" + assert places.sub_places[1].label == "Bedroom" + assert places.sub_places[1].last_update_time is None + + @pytest.mark.asyncio + async def test_execute_action_group_rts_close(self, client: OverkizClient): + """Verify executing a close command on an RTS cover.""" + action = Action( + "rts://2025-8464-6867/16756006", + [Command(name="close", parameters=None, type=1)], + ) + resp = MockResponse('{"execId": "ee7a5676-c68f-43a3-956d-6f5efc745954"}') + + with patch.object(aiohttp.ClientSession, "post") as mock_post: + mock_post.return_value = resp + exec_id = await client.execute_action_group([action]) + + assert exec_id == "ee7a5676-c68f-43a3-956d-6f5efc745954" + _, kwargs = mock_post.call_args + sent_json = kwargs.get("json") + assert ( + sent_json["actions"][0]["deviceURL"] == "rts://2025-8464-6867/16756006" + ) + assert sent_json["actions"][0]["commands"][0]["name"] == "close" + + @pytest.mark.asyncio + async def test_execute_action_group_multiple_rts_devices( + self, client: OverkizClient + ): + """Verify executing commands on multiple RTS devices in a single action group.""" + actions = [ + Action( + "rts://2025-8464-6867/16756006", + [Command(name="close", parameters=None, type=1)], + ), + Action( + "rts://2025-8464-6867/16756007", + [Command(name="open", parameters=None, type=1)], + ), + ] + resp = MockResponse('{"execId": "aaa-bbb-ccc"}') + + with patch.object(aiohttp.ClientSession, "post") as mock_post: + mock_post.return_value = resp + exec_id = await client.execute_action_group(actions) + + assert exec_id == "aaa-bbb-ccc" + _, kwargs = mock_post.call_args + sent_json = kwargs.get("json") + assert len(sent_json["actions"]) == 2 + assert sent_json["actions"][0]["commands"][0]["name"] == "close" + assert sent_json["actions"][1]["commands"][0]["name"] == "open" + + @pytest.mark.asyncio + async def test_execute_persisted_action_group(self, client: OverkizClient): + """Verify executing a persisted action group by OID.""" + resp = MockResponse('{"execId": "ee7a5676-c68f-43a3-956d-6f5efc745954"}') + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + exec_id = await client.execute_persisted_action_group( + "12345678-abcd-efgh-ijkl-123456789012" + ) + assert exec_id == "ee7a5676-c68f-43a3-956d-6f5efc745954" + + @pytest.mark.asyncio + async def test_schedule_persisted_action_group(self, client: OverkizClient): + """Verify scheduling a persisted action group.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "exec-schedule.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + trigger_id = await client.schedule_persisted_action_group( + "12345678-abcd-efgh-ijkl-123456789012", 1767003511145 + ) + assert trigger_id == "abc12345-def6-7890-abcd-ef1234567890" + + @pytest.mark.asyncio + async def test_cancel_execution(self, client: OverkizClient): + """Verify cancel_execution sends DELETE and does not raise on 204.""" + resp = MockResponse("", status=204) + + with patch.object(aiohttp.ClientSession, "delete", return_value=resp): + await client.cancel_execution("699dd967-0a19-0481-7a62-99b990a2feb8") + + @pytest.mark.asyncio + async def test_register_event_listener(self, client: OverkizClient): + """Verify event listener registration returns and stores the listener ID.""" + with (CURRENT_DIR / "fixtures" / "endpoints" / "events-register.json").open( + encoding="utf-8", + ) as f: + resp = MockResponse(f.read()) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + listener_id = await client.register_event_listener() + assert listener_id == "a70f6d96-0a19-0483-72d9-ac5f6bd7da26" + assert client.event_listener_id == listener_id + + @pytest.mark.asyncio + async def test_refresh_states(self, client: OverkizClient): + """Verify refresh_states sends POST and does not raise on 204.""" + resp = MockResponse("", status=204) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + await client.refresh_states() + + @pytest.mark.asyncio + async def test_refresh_device_states(self, client: OverkizClient): + """Verify refresh_device_states sends POST for a specific device.""" + resp = MockResponse("", status=204) + + with patch.object(aiohttp.ClientSession, "post", return_value=resp): + await client.refresh_device_states("rts://2025-8464-6867/16756006") + class MockResponse: """Simple stand-in for aiohttp responses used in tests.""" From c7a0876637f74932812f227667af1ada87b675aa Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 19 Apr 2026 19:16:54 +0000 Subject: [PATCH 2/4] Add local API specific tests for error handling in OverkizClient --- tests/test_client.py | 157 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 63cfa264..1a958ea0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -988,6 +988,163 @@ async def test_refresh_device_states(self, client: OverkizClient): with patch.object(aiohttp.ClientSession, "post", return_value=resp): await client.refresh_device_states("rts://2025-8464-6867/16756006") + # --- Local API specific tests --- + # The local gateway (KizOs) behaves differently from the cloud API + # in several cases. These tests verify the client raises proper errors + # instead of crashing when called via the local API. + + @pytest.mark.asyncio + async def test_local_get_current_execution_empty_list( + self, local_client: OverkizClient + ): + """Local gateway returns [] for non-existent exec_id (cloud returns {}).""" + resp = MockResponse("[]") + + with patch.object(aiohttp.ClientSession, "get", return_value=resp): + result = await local_client.get_current_execution( + "00000000-0000-0000-0000-000000000000" + ) + assert result is None + + @pytest.mark.asyncio + async def test_local_get_state_no_such_device(self, local_client: OverkizClient): + """Local gateway raises NoSuchDeviceError for unknown device URLs.""" + resp = MockResponse( + '{"error":"No such device : \\"io://0000-0000-0000/12345678\\"","errorCode":"NO_SUCH_DEVICE"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.NoSuchDeviceError), + ): + await local_client.get_state("io://0000-0000-0000/12345678") + + @pytest.mark.asyncio + async def test_local_get_device_definition_no_such_device( + self, local_client: OverkizClient + ): + """Local gateway raises NoSuchDeviceError for unknown device definition lookups.""" + resp = MockResponse( + '{"error":"No such device : \\"io://0000-0000-0000/12345678\\"","errorCode":"NO_SUCH_DEVICE"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.NoSuchDeviceError), + ): + await local_client.get_device_definition("io://0000-0000-0000/12345678") + + @pytest.mark.asyncio + async def test_local_get_setup_option_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError for non-existent options (cloud returns {}).""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.get_setup_option("nonExistentOption") + + @pytest.mark.asyncio + async def test_local_refresh_device_states_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError for unknown device refresh.""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "post", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.refresh_device_states("io://0000-0000-0000/12345678") + + @pytest.mark.asyncio + async def test_local_get_reference_controllable_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError for unknown controllable names.""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "get", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.get_reference_controllable("io:NonExistentControllable") + + @pytest.mark.asyncio + async def test_local_cancel_execution_succeeds_on_unknown_id( + self, local_client: OverkizClient + ): + """Local gateway returns 200 with [] for cancel on unknown exec_id (idempotent).""" + resp = MockResponse("[]", status=200) + + with patch.object(aiohttp.ClientSession, "delete", return_value=resp): + await local_client.cancel_execution("00000000-0000-0000-0000-000000000000") + + @pytest.mark.asyncio + async def test_local_execute_action_group_rts_close( + self, local_client: OverkizClient + ): + """Verify executing an RTS command via the local API.""" + action = Action( + "rts://2025-8464-6867/16756006", + [Command(name="close")], + ) + resp = MockResponse('{"execId": "45e52d27-3c08-4fd5-87f2-03d650b67f4b"}') + + with patch.object(aiohttp.ClientSession, "post") as mock_post: + mock_post.return_value = resp + exec_id = await local_client.execute_action_group([action]) + + assert exec_id == "45e52d27-3c08-4fd5-87f2-03d650b67f4b" + + @pytest.mark.asyncio + async def test_local_no_registered_event_listener( + self, local_client: OverkizClient + ): + """Local gateway raises NoRegisteredEventListenerError for unregistered fetch.""" + resp = MockResponse( + '{"error":"\\"No registered event listener.\\"","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "post", return_value=resp), + pytest.raises(exceptions.NoRegisteredEventListenerError), + ): + await check_response(resp) + + @pytest.mark.asyncio + async def test_local_schedule_persisted_action_group_unknown_object( + self, local_client: OverkizClient + ): + """Local gateway raises UnknownObjectError when scheduling a non-existent action group.""" + resp = MockResponse( + '{"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"}', + status=400, + ) + + with ( + patch.object(aiohttp.ClientSession, "post", return_value=resp), + pytest.raises(exceptions.UnknownObjectError), + ): + await local_client.schedule_persisted_action_group( + "00000000-0000-0000-0000-000000000000", 9999999999 + ) + class MockResponse: """Simple stand-in for aiohttp responses used in tests.""" From 1acd40640ac38698ff89e95e975ed379951a9c03 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 19 Apr 2026 19:41:36 +0000 Subject: [PATCH 3/4] Remove test_error_handling.py script from branch --- scripts/test_error_handling.py | 658 --------------------------------- 1 file changed, 658 deletions(-) delete mode 100644 scripts/test_error_handling.py diff --git a/scripts/test_error_handling.py b/scripts/test_error_handling.py deleted file mode 100644 index ca21f5e2..00000000 --- a/scripts/test_error_handling.py +++ /dev/null @@ -1,658 +0,0 @@ -#!/usr/bin/env python3 -"""Probe real Overkiz cloud and local API endpoints with invalid inputs. - -Tests that every error response is caught as a specific pyoverkiz exception -rather than crashing with an unhandled error, KeyError, etc. - -Usage: - # Cloud only (needs username/password): - python scripts/test_error_handling.py \ - --server somfy_europe \ - --username you@example.com \ - --password secret - - # Local only (needs host + token): - python scripts/test_error_handling.py \ - --local-host gateway-1234-5678-9012.local:8443 \ - --local-token YOUR_TOKEN - - # Both at once: - python scripts/test_error_handling.py \ - --server somfy_europe \ - --username you@example.com \ - --password secret \ - --local-host gateway-1234-5678-9012.local:8443 \ - --local-token YOUR_TOKEN - -Each probe is independent — a failure in one does not stop the rest. -""" - -from __future__ import annotations - -import argparse -import asyncio -import os -import sys -import traceback -from dataclasses import dataclass, field - -import aiohttp -from aiohttp import ClientSession, TraceConfig - -from pyoverkiz.auth.credentials import ( - LocalTokenCredentials, - UsernamePasswordCredentials, -) -from pyoverkiz.client import OverkizClient -from pyoverkiz.enums import APIType, Server -from pyoverkiz.exceptions import BaseOverkizError -from pyoverkiz.models import Action, Command, Execution -from pyoverkiz.utils import create_local_server_config - -FAKE_DEVICE_URL = "io://0000-0000-0000/12345678" -FAKE_EXEC_ID = "00000000-0000-0000-0000-000000000000" -FAKE_OID = "00000000-0000-0000-0000-000000000000" -FAKE_OPTION = "nonExistentOption-0000-0000-0000" -FAKE_CONTROLLABLE = "io:NonExistentDeviceControllable" -FAKE_PROFILE = "NonExistentProfile" - - -# ── HTTP tracing ────────────────────────────────────────────────────────────── - -# Collects full request/response details per-probe so they can be printed -# alongside the result. Each entry is a dict with method, url, status, -# request/response headers, and response body. - -_current_traces: list[dict] = [] - - -async def _on_request_start( - session: ClientSession, - trace_ctx: object, - params: aiohttp.TraceRequestStartParams, -) -> None: - _current_traces.append( - { - "method": params.method, - "url": str(params.url), - "req_headers": dict(params.headers), - } - ) - - -async def _on_request_end( - session: ClientSession, - trace_ctx: object, - params: aiohttp.TraceRequestEndParams, -) -> None: - # .text() caches the body internally, so the caller can still read it. - body = await params.response.text() - entry = { - "status": params.response.status, - "resp_headers": dict(params.response.headers), - "resp_body": body, - } - if _current_traces: - _current_traces[-1].update(entry) - else: - _current_traces.append(entry) - - -def _build_trace_config() -> TraceConfig: - tc = TraceConfig() - tc.on_request_start.append(_on_request_start) - tc.on_request_end.append(_on_request_end) - return tc - - -def _format_trace(trace: dict) -> str: - lines: list[str] = [] - method = trace.get("method", "?") - url = trace.get("url", "?") - status = trace.get("status", "?") - lines.append(f" >>> {method} {url}") - - req_headers = trace.get("req_headers", {}) - for k, v in req_headers.items(): - if k.lower() in ("authorization", "cookie"): - v = v[:20] + "..." if len(v) > 20 else v - lines.append(f" {k}: {v}") - - lines.append(f" <<< {status}") - - resp_headers = trace.get("resp_headers", {}) - for k, v in resp_headers.items(): - lines.append(f" {k}: {v}") - - body = trace.get("resp_body", "") - if body: - # Truncate very long bodies but keep enough to see the error - if len(body) > 500: - body = body[:500] + f"... ({len(body)} bytes total)" - lines.append(f" Body: {body}") - - return "\n".join(lines) - - -# ── Probe runner ────────────────────────────────────────────────────────────── - - -@dataclass -class ProbeResult: - name: str - api: str - passed: bool - kind: str = "error" - exception_type: str = "" - detail: str = "" - traces: list[dict] = field(default_factory=list) - - -@dataclass -class ProbeRunner: - results: list[ProbeResult] = field(default_factory=list) - quiet: bool = False - - async def probe(self, name: str, api: str, coro): - """Probe that expects a BaseOverkizError to be raised.""" - _current_traces.clear() - try: - await coro - self.results.append( - ProbeResult( - name=name, - api=api, - passed=False, - kind="error", - detail="Expected an error but call succeeded", - traces=list(_current_traces), - ) - ) - except BaseOverkizError as exc: - self.results.append( - ProbeResult( - name=name, - api=api, - passed=True, - kind="error", - exception_type=type(exc).__name__, - detail=str(exc)[:200], - traces=list(_current_traces), - ) - ) - except Exception as exc: - self.results.append( - ProbeResult( - name=name, - api=api, - passed=False, - kind="error", - exception_type=type(exc).__name__, - detail=str(exc)[:200], - traces=list(_current_traces), - ) - ) - - async def probe_graceful(self, name: str, api: str, coro, check=None): - """Probe that expects no exception — the client should handle the - response gracefully (return None, recover via retry, etc.). - """ - _current_traces.clear() - try: - result = await coro - detail = ( - f"Returned: {result!r}"[:200] - if result is not None - else "Returned: None" - ) - ok = check(result) if check else True - self.results.append( - ProbeResult( - name=name, - api=api, - passed=ok, - kind="graceful", - detail=detail if ok else f"Check failed — {detail}", - traces=list(_current_traces), - ) - ) - except Exception as exc: - self.results.append( - ProbeResult( - name=name, - api=api, - passed=False, - kind="graceful", - exception_type=type(exc).__name__, - detail=f"Expected graceful handling but got: {exc!s}"[:200], - traces=list(_current_traces), - ) - ) - - async def probe_command(self, name: str, api: str, coro): - """Probe that expects a successful command execution returning an exec_id.""" - _current_traces.clear() - try: - result = await coro - ok = isinstance(result, str) and len(result) > 0 - self.results.append( - ProbeResult( - name=name, - api=api, - passed=ok, - kind="command", - detail=f"exec_id: {result}" - if ok - else f"Unexpected result: {result!r}", - traces=list(_current_traces), - ) - ) - except Exception as exc: - self.results.append( - ProbeResult( - name=name, - api=api, - passed=False, - kind="command", - exception_type=type(exc).__name__, - detail=str(exc)[:200], - traces=list(_current_traces), - ) - ) - - def print_report(self): - passed = sum(1 for r in self.results if r.passed) - failed = sum(1 for r in self.results if not r.passed) - - kind_labels = { - "error": "Error probes (expect BaseOverkizError)", - "graceful": "Graceful handling probes (expect no crash)", - "command": "Command probes (expect exec_id)", - } - - print("\n" + "=" * 80) - print(f" PROBE RESULTS: {passed} passed, {failed} failed") - print("=" * 80) - - for kind, label in kind_labels.items(): - kind_results = [r for r in self.results if r.kind == kind] - if not kind_results: - continue - - print(f"\n --- {label} ---") - for r in kind_results: - status = "PASS" if r.passed else "FAIL" - icon = "+" if r.passed else "-" - exc_info = f" -> {r.exception_type}" if r.exception_type else "" - print(f"\n [{icon}] {status} [{r.api:5s}] {r.name}{exc_info}") - if r.detail: - print(f" {r.detail}") - - if not self.quiet and r.traces: - for trace in r.traces: - print(_format_trace(trace)) - elif not self.quiet and not r.traces: - print(" (no HTTP trace captured)") - - print("\n" + "=" * 80) - if failed: - print(f"\n {failed} probe(s) FAILED!") - else: - print("\n All probes passed.") - print() - - -async def probe_client( - client: OverkizClient, - api_label: str, - runner: ProbeRunner, - rts_device_url: str | None = None, -): - """Run all probes against a single client instance. - - Args: - rts_device_url: URL of a real RTS device to test commands on. RTS covers - are fire-and-forget (no state feedback), so sending close/open/my/stop - is safe even on real hardware — the worst that happens is a shutter moves. - """ - # --- Sanity checks (run first to verify connectivity) --- - - try: - devices = await client.get_devices(refresh=True) - print( - f" [*] Sanity check [{api_label}]: get_devices returned {len(devices)} devices" - ) - except Exception as exc: - print(f" [!] Sanity check [{api_label}]: get_devices FAILED: {exc}") - - try: - executions = await client.get_current_executions() - print( - f" [*] Sanity check [{api_label}]: get_current_executions returned {len(executions)} items" - ) - except Exception as exc: - print(f" [!] Sanity check [{api_label}]: get_current_executions FAILED: {exc}") - - is_local = client.server_config.api_type == APIType.LOCAL - - # --- Error probes: expect BaseOverkizError --- - - await runner.probe( - "get_state(fake device URL)", - api_label, - client.get_state(FAKE_DEVICE_URL), - ) - - await runner.probe( - "get_device_definition(fake device URL)", - api_label, - client.get_device_definition(FAKE_DEVICE_URL), - ) - - await runner.probe( - "get_setup_option_parameter(fake option, fake param)", - api_label, - client.get_setup_option_parameter(FAKE_OPTION, "fakeParam"), - ) - - await runner.probe( - "get_reference_controllable(fake name)", - api_label, - client.get_reference_controllable(FAKE_CONTROLLABLE), - ) - - await runner.probe( - "get_reference_ui_profile(fake profile)", - api_label, - client.get_reference_ui_profile(FAKE_PROFILE), - ) - - await runner.probe( - "refresh_device_states(fake device URL)", - api_label, - client.refresh_device_states(FAKE_DEVICE_URL), - ) - - await runner.probe( - "schedule_persisted_action_group(fake OID)", - api_label, - client.schedule_persisted_action_group(FAKE_OID, 9999999999), - ) - - # Local gateway returns error for fake options; cloud returns empty {} - if is_local: - await runner.probe( - "get_setup_option(fake option)", - api_label, - client.get_setup_option(FAKE_OPTION), - ) - else: - await runner.probe_graceful( - "get_setup_option(fake option)", - api_label, - client.get_setup_option(FAKE_OPTION), - check=lambda result: result is None, - ) - - # Cloud rejects fake devices/OIDs; local gateway accepts them - if is_local: - await runner.probe_command( - "execute_action_group(fake device + bad command)", - api_label, - client.execute_action_group( - [ - Action( - device_url=FAKE_DEVICE_URL, - commands=[Command(name="totallyFakeCommand", parameters=[42])], - ) - ] - ), - ) - - await runner.probe_command( - "execute_persisted_action_group(fake OID)", - api_label, - client.execute_persisted_action_group(FAKE_OID), - ) - else: - await runner.probe( - "execute_action_group(fake device + bad command)", - api_label, - client.execute_action_group( - [ - Action( - device_url=FAKE_DEVICE_URL, - commands=[Command(name="totallyFakeCommand", parameters=[42])], - ) - ] - ), - ) - - await runner.probe( - "execute_persisted_action_group(fake OID)", - api_label, - client.execute_persisted_action_group(FAKE_OID), - ) - - # --- Graceful handling probes: expect no crash --- - - await runner.probe_graceful( - "get_current_execution(fake exec_id)", - api_label, - client.get_current_execution(FAKE_EXEC_ID), - check=lambda result: result is None, - ) - - await runner.probe_graceful( - "cancel_execution(fake exec_id)", - api_label, - client.cancel_execution(FAKE_EXEC_ID), - ) - - saved_listener = client.event_listener_id - - client.event_listener_id = "totally-fake-listener-id" - await runner.probe_graceful( - "fetch_events(fake listener id) -> retry recovery", - api_label, - client.fetch_events(), - check=lambda result: isinstance(result, list), - ) - - client.event_listener_id = saved_listener - - # Execution history is only available on cloud - if not is_local: - await runner.probe_graceful( - "get_execution_history()", - api_label, - client.get_execution_history(), - check=lambda result: isinstance(result, list) and len(result) > 0, - ) - - # --- RTS command probes: execute real commands on RTS covers --- - - if not rts_device_url: - rts_device_url = _find_rts_device(client) - - if rts_device_url: - print(f" [*] Using RTS device: {rts_device_url}\n") - else: - print(" [*] No RTS device found, skipping command probes\n") - - if rts_device_url: - last_exec_id: str | None = None - - for cmd_name in ("close", "my", "stop"): - _current_traces.clear() - try: - exec_id = await client.execute_action_group( - [ - Action( - device_url=rts_device_url, - commands=[Command(name=cmd_name)], - ) - ] - ) - ok = isinstance(exec_id, str) and len(exec_id) > 0 - runner.results.append( - ProbeResult( - name=f"execute_action_group(RTS {cmd_name})", - api=api_label, - passed=ok, - kind="command", - detail=f"exec_id: {exec_id}" - if ok - else f"Unexpected: {exec_id!r}", - traces=list(_current_traces), - ) - ) - if ok: - last_exec_id = exec_id - except Exception as exc: - runner.results.append( - ProbeResult( - name=f"execute_action_group(RTS {cmd_name})", - api=api_label, - passed=False, - kind="command", - exception_type=type(exc).__name__, - detail=str(exc)[:200], - traces=list(_current_traces), - ) - ) - - if last_exec_id: - - def _check_execution(result): - if result is None: - return True - if not isinstance(result, Execution): - return False - return result.action_group is not None - - await runner.probe_graceful( - f"get_current_execution({last_exec_id[:8]}...)", - api_label, - client.get_current_execution(last_exec_id), - check=_check_execution, - ) - - await runner.probe_graceful( - "get_current_executions()", - api_label, - client.get_current_executions(), - check=lambda result: isinstance(result, list), - ) - - -def _find_rts_device(client: OverkizClient) -> str | None: - """Return the URL of the first RTS device, if any.""" - for device in client.devices: - if device.device_url.startswith("rts://"): - return device.device_url - return None - - -async def run_cloud(args: argparse.Namespace, runner: ProbeRunner): - server = Server(args.server) - credentials = UsernamePasswordCredentials(args.username, args.password) - - trace_config = _build_trace_config() - session = ClientSession( - headers={"User-Agent": "python-overkiz-api"}, - trace_configs=[trace_config], - ) - - async with OverkizClient( - server=server, credentials=credentials, session=session - ) as client: - print(f"\nLogging in to cloud ({args.server})...") - await client.login() - print("Logged in. Running probes...\n") - await probe_client(client, "cloud", runner, rts_device_url=args.rts_device) - - -async def run_local(args: argparse.Namespace, runner: ProbeRunner): - server_config = create_local_server_config(host=args.local_host) - credentials = LocalTokenCredentials(token=args.local_token) - - trace_config = _build_trace_config() - session = ClientSession( - headers={"User-Agent": "python-overkiz-api"}, - trace_configs=[trace_config], - ) - - async with OverkizClient( - server=server_config, credentials=credentials, session=session - ) as client: - print(f"\nConnecting to local API ({args.local_host})...") - await client.login() - print("Connected. Running probes...\n") - await probe_client(client, "local", runner, rts_device_url=args.rts_device) - - -async def main(): - parser = argparse.ArgumentParser( - description="Probe Overkiz endpoints for error handling", - epilog="Credentials can also be set via env vars: OVERKIZ_SERVER, OVERKIZ_USERNAME, " - "OVERKIZ_PASSWORD, OVERKIZ_LOCAL_HOST, OVERKIZ_LOCAL_TOKEN", - ) - parser.add_argument( - "--server", type=str, help="Cloud server key (e.g. somfy_europe)" - ) - parser.add_argument("--username", type=str, help="Cloud username") - parser.add_argument("--password", type=str, help="Cloud password") - parser.add_argument("--local-host", type=str, help="Local gateway host:port") - parser.add_argument("--local-token", type=str, help="Local API token") - parser.add_argument( - "--rts-device", - type=str, - help="RTS device URL to test commands on (e.g. rts://2025-8464-6867/16756006). " - "Auto-detected from setup if not specified.", - ) - parser.add_argument( - "-q", - "--quiet", - action="store_true", - help="Only show the summary table, suppress HTTP traces", - ) - args = parser.parse_args() - - # Fall back to env vars for credentials (avoids shell quoting issues with passwords) - args.server = args.server or os.environ.get("OVERKIZ_SERVER") - args.username = args.username or os.environ.get("OVERKIZ_USERNAME") - args.password = args.password or os.environ.get("OVERKIZ_PASSWORD") - args.local_host = args.local_host or os.environ.get("OVERKIZ_LOCAL_HOST") - args.local_token = args.local_token or os.environ.get("OVERKIZ_LOCAL_TOKEN") - args.rts_device = args.rts_device or os.environ.get("OVERKIZ_RTS_DEVICE") - - has_cloud = args.server and args.username and args.password - has_local = args.local_host and args.local_token - - if not has_cloud and not has_local: - parser.error( - "Provide --server/--username/--password for cloud, " - "and/or --local-host/--local-token for local." - ) - - runner = ProbeRunner(quiet=args.quiet) - - if has_cloud: - try: - await run_cloud(args, runner) - except Exception: - print(f"\nCloud session failed:\n{traceback.format_exc()}") - - if has_local: - try: - await run_local(args, runner) - except Exception: - print(f"\nLocal session failed:\n{traceback.format_exc()}") - - runner.print_report() - sys.exit(0 if all(r.passed for r in runner.results) else 1) - - -if __name__ == "__main__": - asyncio.run(main()) From ecd4f859daf32783d8f44398d92176ed72486d9a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 19 Apr 2026 19:46:03 +0000 Subject: [PATCH 4/4] Remove unnecessary patch in test_local_no_registered_event_listener --- tests/test_client.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 1a958ea0..17bbbb9b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1121,10 +1121,7 @@ async def test_local_no_registered_event_listener( status=400, ) - with ( - patch.object(aiohttp.ClientSession, "post", return_value=resp), - pytest.raises(exceptions.NoRegisteredEventListenerError), - ): + with pytest.raises(exceptions.NoRegisteredEventListenerError): await check_response(resp) @pytest.mark.asyncio