From cfcb4412a8cf03c0b940b94452987a1d493e70c9 Mon Sep 17 00:00:00 2001 From: Daisuke Sato Date: Wed, 4 Mar 2026 15:31:37 +0900 Subject: [PATCH 1/2] Restructure tests into unit/e2e and add pytest support - Split tests into test/unit (mock-based) and test/e2e (AWS IoT integration) - Add pytest and pytest-cov to dev dependencies with ROS2 plugin exclusions - Fix ShadowData shared mutable state bug by initializing in __init__ - Update test README with comprehensive test catalog and run instructions Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 +- pyproject.toml | 7 + src/awsiotclient/shadow.py | 3 +- test/README.md | 158 ++++++++++++- test/e2e/__init__.py | 0 test/{ => e2e}/certs/AmazonRootCA1.pem | 0 test/{ => e2e}/common.py | 0 test/{ => e2e}/test_classic_shadow.py | 0 test/{ => e2e}/test_jobs.py | 0 test/{ => e2e}/test_mqtt.py | 0 test/{ => e2e}/test_named_shadow.py | 0 test/{ => e2e}/test_pubsub.py | 0 test/unit/__init__.py | 0 test/unit/conftest.py | 88 +++++++ test/unit/test_classic_shadow.py | 59 +++++ test/{ => unit}/test_dictdiff.py | 0 test/unit/test_jobs.py | 83 +++++++ test/unit/test_jobs_callbacks.py | 294 ++++++++++++++++++++++++ test/unit/test_mqtt.py | 129 +++++++++++ test/unit/test_named_shadow.py | 84 +++++++ test/unit/test_pubsub.py | 52 +++++ test/unit/test_shadow.py | 52 +++++ test/unit/test_shadow_callbacks.py | 304 +++++++++++++++++++++++++ 23 files changed, 1306 insertions(+), 11 deletions(-) create mode 100644 test/e2e/__init__.py rename test/{ => e2e}/certs/AmazonRootCA1.pem (100%) rename test/{ => e2e}/common.py (100%) rename test/{ => e2e}/test_classic_shadow.py (100%) rename test/{ => e2e}/test_jobs.py (100%) rename test/{ => e2e}/test_mqtt.py (100%) rename test/{ => e2e}/test_named_shadow.py (100%) rename test/{ => e2e}/test_pubsub.py (100%) create mode 100644 test/unit/__init__.py create mode 100644 test/unit/conftest.py create mode 100644 test/unit/test_classic_shadow.py rename test/{ => unit}/test_dictdiff.py (100%) create mode 100644 test/unit/test_jobs.py create mode 100644 test/unit/test_jobs_callbacks.py create mode 100644 test/unit/test_mqtt.py create mode 100644 test/unit/test_named_shadow.py create mode 100644 test/unit/test_pubsub.py create mode 100644 test/unit/test_shadow.py create mode 100644 test/unit/test_shadow_callbacks.py diff --git a/.gitignore b/.gitignore index 85a0a4c..788d27f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -!test/certs/AmazonRootCA1.pem -test/certs/* +test/e2e/certs/* +!test/e2e/certs/AmazonRootCA1.pem # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python diff --git a/pyproject.toml b/pyproject.toml index 2efc57d..3702124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,13 @@ flake8 = {version = "^6.0.0", python = ">=3.8.1"} flake8-pyproject = "^1.2.3" isort = {version = "^5.12.0", python = ">=3.8"} mypy = "^1.3.0" +pytest = "^7.0.0" +pytest-cov = {version = "^5.0.0", python = ">=3.8,<4.0.0"} + +[tool.pytest.ini_options] +testpaths = ["test"] +# Disable ROS2 (jazzy) pytest plugins that interfere when installed system-wide +addopts = "-p no:launch_testing -p no:launch_ros -p no:ament_copyright -p no:ament_xmllint -p no:ament_pep257 -p no:ament_mypy -p no:ament_lint -p no:ament_flake8" [tool.isort] profile = "black" diff --git a/src/awsiotclient/shadow.py b/src/awsiotclient/shadow.py index 8f105a8..9cdff54 100644 --- a/src/awsiotclient/shadow.py +++ b/src/awsiotclient/shadow.py @@ -106,7 +106,7 @@ class ShadowClientCommon(ABC): client: iotshadow.IotShadowClient thing_name: str property_name: Optional[str] - locked_data = ShadowData() + locked_data: ShadowData qos: mqtt.QoS publish_full_doc: bool @@ -127,6 +127,7 @@ def __init__( self.delta_func = delta_func self.desired_func = desired_func self.publish_full_doc = publish_full_doc + self.locked_data = ShadowData() def __filter_property(self, v: ShadowDocument) -> ShadowDocument: if self.property_name is None: diff --git a/test/README.md b/test/README.md index 61cf54e..f3aab8e 100644 --- a/test/README.md +++ b/test/README.md @@ -1,4 +1,153 @@ -## 準備 +## ディレクトリ構成 + +``` +test/ +├── e2e/ # AWS IoT実環境へ接続するE2Eテスト +│ ├── common.py +│ ├── test_mqtt.py +│ ├── test_pubsub.py +│ ├── test_jobs.py +│ ├── test_classic_shadow.py +│ └── test_named_shadow.py +└── unit/ # mockを使ったユニットテスト + ├── test_mqtt.py + ├── test_pubsub.py + ├── test_shadow.py + ├── test_shadow_callbacks.py + ├── test_classic_shadow.py + ├── test_named_shadow.py + ├── test_jobs.py + ├── test_jobs_callbacks.py + └── test_dictdiff.py +``` + +## テスト一覧 + +### unit + +| ファイル | テストクラス | テストケース | +| --- | --- | --- | +| test_dictdiff.py | DictdiffTestCase | test_dictdiff_no_difference | +| | | test_dictdiff_from_empty | +| | | test_dictdiff_to_empty | +| | | test_dictdiff_add_items | +| | | test_dictdiff_remove_items | +| | | test_dictdiff_update_items | +| | | test_dictdiff_update_dict | +| test_mqtt.py | TestConnectionParams | test_defaults | +| | | test_expands_home | +| | TestInit | test_mtls | +| | | test_websocket | +| | TestCallbacks | test_on_resubscribe_complete_raises_on_rejected | +| | | test_on_resubscribe_complete_succeeds | +| | TestConnectionCallbacks | test_on_connection_interrupted | +| | | test_on_connection_resumed_resubscribes_when_session_not_present | +| | | test_on_connection_resumed_skips_resubscribe_when_session_present | +| | TestWebsocketProxy | test_init_websocket_with_proxy_options | +| test_pubsub.py | TestSubscriber | test_subscribes_to_topic | +| | | test_callback_parses_json | +| | TestPublisher | test_publishes_payload | +| | | test_empty_payload_does_not_publish | +| test_shadow.py | TestDocumentTracker | test_get_set | +| | | test_update_same_value_returns_none | +| | | test_update_diff_only | +| | | test_update_full_doc | +| | TestShadowData | test_update_reported | +| | | test_update_desired | +| | | test_update_both | +| test_classic_shadow.py | TestClassicShadowClient | test_subscribes_on_init | +| | | test_publishes_get_shadow_on_init | +| | | test_update_shadow_request_publishes | +| | | test_update_noop_when_both_none | +| test_named_shadow.py | TestNamedShadowClient | test_subscribes_with_shadow_name | +| | | test_publishes_get_named_shadow_on_init | +| | | test_update_shadow_request_publishes | +| | | test_label_returns_shadow_name | +| | | test_update_noop_when_both_none | +| | | test_get_accepted_uses_full_document | +| test_jobs.py | TestJobsClient | test_subscribes_on_init | +| | | test_try_start_next_job_publishes | +| | | test_try_start_next_job_skips_when_already_working | +| | | test_job_thread_fn_calls_job_func_and_reports_succeeded | +| | | test_job_thread_fn_reports_failed_on_exception | +| test_shadow_callbacks.py | TestShadowCallbacks | test_delta_ignored_when_state_is_none | +| | | test_delta_deleted_property_resets_reported | +| | | test_delta_invalid_request_resets_desired | +| | | test_delta_unexpected_exception_is_reraised | +| | | test_get_shadow_accepted_uses_reported_state | +| | | test_get_shadow_accepted_ignores_when_delta_present | +| | | test_get_shadow_accepted_sets_default_when_property_missing | +| | | test_get_shadow_accepted_returns_early_when_reported_already_set | +| | | test_get_shadow_accepted_reraises_unexpected_errors | +| | | test_get_shadow_rejected_404_sets_default | +| | | test_get_shadow_rejected_non_404_raises | +| | | test_update_shadow_accepted_sets_desired_and_calls_callback | +| | | test_update_shadow_accepted_reraises_when_desired_callback_fails | +| | | test_update_shadow_rejected_raises | +| | | test_on_publish_update_shadow_raises_when_future_fails | +| | | test_change_both_values_publishes_update | +| | | test_change_desired_value_publishes_update | +| test_jobs_callbacks.py | TestJobsCallbacks | test_try_start_next_job_skips_when_disconnect_called | +| | | test_init_wraps_subscription_failure | +| | | test_done_working_on_job_retries_when_waiting | +| | | test_on_next_job_execution_changed_none | +| | | test_on_next_job_execution_changed_sets_wait_flag_while_working | +| | | test_on_next_job_execution_changed_starts_now_when_idle | +| | | test_on_next_job_execution_changed_wraps_unexpected_error | +| | | test_on_publish_start_next_pending_job_execution_raises | +| | | test_start_next_pending_job_accepted_spawns_thread | +| | | test_start_next_pending_job_accepted_wraps_thread_creation_error | +| | | test_start_next_pending_job_accepted_without_execution_marks_done | +| | | test_start_next_pending_job_rejected_raises | +| | | test_job_thread_user_defined_failure_sets_status_details | +| | | test_on_publish_update_job_execution_raises | +| | | test_on_update_job_execution_accepted_calls_done | +| | | test_on_update_job_execution_accepted_wraps_done_error | +| | | test_on_update_job_execution_rejected_raises | + +### e2e + +| ファイル | テストクラス | テストケース | +| --- | --- | --- | +| test_mqtt.py | TestMqtt | test_mqtt_setup | +| test_pubsub.py | TestPubSub | test_pubsub | +| test_jobs.py | TestJobs | test_jobs | +| test_classic_shadow.py | TestClassicShadow | test_classic_shadow_reported | +| | | test_classic_shadow_reported_value_delta | +| | | test_classic_shadow_desired_matches_with_reported | +| test_named_shadow.py | TestNamedShadow | test_named_shadow_reported | +| | | test_named_shadow_reported_value_delta | +| | | test_named_shadow_desired_matches_with_reported | + +## テストの実行 + +```bash +# ユニットテストのみ +PYTHONPATH=src pytest test/unit -v + +# E2Eテスト(要AWS認証情報) +source ./test/env.sh +PYTHONPATH=src pytest test/e2e -v + +# 全テスト +source ./test/env.sh +PYTHONPATH=src pytest test -v + +# ユニットテストのカバレッジ確認(推奨: pytest-cov) +PYTHONPATH=src pytest -q test/unit \ + --cov=awsiotclient \ + --cov-branch \ + --cov-report=term-missing \ + --cov-fail-under=95 + +# 補助: pytest-cov が利用できない環境では trace を使用 +# (pytest-cov は Python 3.8+ で利用可能) +PYTHONPATH=src python -m trace --count --missing --summary --module pytest -q test/unit +``` + +`awsiotclient` がグローバル環境にもインストールされている場合、`PYTHONPATH=src` を付けないと `src/` ではなく `site-packages` の実装を参照する可能性があります。 + +## E2Eテストの準備 ```bash # モノの作成 @@ -21,10 +170,3 @@ aws iot attach-policy \ --target "$(jq -r .certificateArn < ./test/certs/cert.json)" \ --policy-name ``` - -## テストの実行 - -```bash -source ./test/env.sh -python3 -m unittest -``` diff --git a/test/e2e/__init__.py b/test/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/certs/AmazonRootCA1.pem b/test/e2e/certs/AmazonRootCA1.pem similarity index 100% rename from test/certs/AmazonRootCA1.pem rename to test/e2e/certs/AmazonRootCA1.pem diff --git a/test/common.py b/test/e2e/common.py similarity index 100% rename from test/common.py rename to test/e2e/common.py diff --git a/test/test_classic_shadow.py b/test/e2e/test_classic_shadow.py similarity index 100% rename from test/test_classic_shadow.py rename to test/e2e/test_classic_shadow.py diff --git a/test/test_jobs.py b/test/e2e/test_jobs.py similarity index 100% rename from test/test_jobs.py rename to test/e2e/test_jobs.py diff --git a/test/test_mqtt.py b/test/e2e/test_mqtt.py similarity index 100% rename from test/test_mqtt.py rename to test/e2e/test_mqtt.py diff --git a/test/test_named_shadow.py b/test/e2e/test_named_shadow.py similarity index 100% rename from test/test_named_shadow.py rename to test/e2e/test_named_shadow.py diff --git a/test/test_pubsub.py b/test/e2e/test_pubsub.py similarity index 100% rename from test/test_pubsub.py rename to test/e2e/test_pubsub.py diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/conftest.py b/test/unit/conftest.py new file mode 100644 index 0000000..41cc63c --- /dev/null +++ b/test/unit/conftest.py @@ -0,0 +1,88 @@ +from concurrent.futures import Future +from unittest.mock import MagicMock + +import pytest + + +def _done_future(value=None): + future = Future() + future.set_result(value) + return future + + +@pytest.fixture +def make_done_future(): + return _done_future + + +@pytest.fixture +def make_classic_shadow_client(make_done_future): + def factory(): + mock_client = MagicMock() + for method_name in [ + "subscribe_to_shadow_delta_updated_events", + "subscribe_to_update_shadow_accepted", + "subscribe_to_update_shadow_rejected", + "subscribe_to_get_shadow_accepted", + "subscribe_to_get_shadow_rejected", + ]: + getattr(mock_client, method_name).return_value = (make_done_future(), 1) + + mock_client.publish_get_shadow.return_value = make_done_future() + mock_client.publish_update_shadow.return_value = make_done_future() + return mock_client + + return factory + + +@pytest.fixture +def make_named_shadow_client(make_done_future): + def factory(): + mock_client = MagicMock() + for method_name in [ + "subscribe_to_named_shadow_delta_updated_events", + "subscribe_to_update_named_shadow_accepted", + "subscribe_to_update_named_shadow_rejected", + "subscribe_to_get_named_shadow_accepted", + "subscribe_to_get_named_shadow_rejected", + ]: + getattr(mock_client, method_name).return_value = (make_done_future(), 1) + + mock_client.publish_get_named_shadow.return_value = make_done_future() + mock_client.publish_update_named_shadow.return_value = make_done_future() + return mock_client + + return factory + + +@pytest.fixture +def make_jobs_client(make_done_future): + def factory(): + mock_client = MagicMock() + for method_name in [ + "subscribe_to_next_job_execution_changed_events", + "subscribe_to_start_next_pending_job_execution_accepted", + "subscribe_to_start_next_pending_job_execution_rejected", + "subscribe_to_update_job_execution_accepted", + "subscribe_to_update_job_execution_rejected", + ]: + getattr(mock_client, method_name).return_value = (make_done_future(), 1) + + mock_client.publish_start_next_pending_job_execution.return_value = ( + make_done_future() + ) + mock_client.publish_update_job_execution.return_value = make_done_future() + return mock_client + + return factory + + +@pytest.fixture +def make_pubsub_connection(make_done_future): + def factory(qos): + connection = MagicMock() + connection.subscribe.return_value = (make_done_future({"qos": qos}), 1) + connection.publish.return_value = (make_done_future(None), 1) + return connection + + return factory diff --git a/test/unit/test_classic_shadow.py b/test/unit/test_classic_shadow.py new file mode 100644 index 0000000..bf437a9 --- /dev/null +++ b/test/unit/test_classic_shadow.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock, patch + +from awscrt import mqtt + +import awsiotclient.classic_shadow as classic_shadow + + +class TestClassicShadowClient: + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_subscribes_on_init(self, MockShadowClient, make_classic_shadow_client): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + classic_shadow.client(conn, "thing1", "prop1") + + mock_client.subscribe_to_shadow_delta_updated_events.assert_called_once() + mock_client.subscribe_to_update_shadow_accepted.assert_called_once() + mock_client.subscribe_to_update_shadow_rejected.assert_called_once() + mock_client.subscribe_to_get_shadow_accepted.assert_called_once() + mock_client.subscribe_to_get_shadow_rejected.assert_called_once() + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_publishes_get_shadow_on_init(self, MockShadowClient, make_classic_shadow_client): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + classic_shadow.client(conn, "thing1", "prop1") + + mock_client.publish_get_shadow.assert_called_once() + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_update_shadow_request_publishes( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + mock_client.publish_update_shadow.reset_mock() + c.change_reported_value({"temp": 25}) + + mock_client.publish_update_shadow.assert_called_once() + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_update_noop_when_both_none(self, MockShadowClient, make_classic_shadow_client): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + mock_client.publish_update_shadow.reset_mock() + c.update_shadow_request(desired=None, reported=None) + + mock_client.publish_update_shadow.assert_not_called() diff --git a/test/test_dictdiff.py b/test/unit/test_dictdiff.py similarity index 100% rename from test/test_dictdiff.py rename to test/unit/test_dictdiff.py diff --git a/test/unit/test_jobs.py b/test/unit/test_jobs.py new file mode 100644 index 0000000..f10d35b --- /dev/null +++ b/test/unit/test_jobs.py @@ -0,0 +1,83 @@ +from unittest.mock import MagicMock, patch + +from awscrt import mqtt +from awsiot import iotjobs + +import awsiotclient.jobs as jobs + + +class TestJobsClient: + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_subscribes_on_init(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + jobs.client(conn, "thing1") + + mock_client.subscribe_to_next_job_execution_changed_events.assert_called_once() + mock_client.subscribe_to_start_next_pending_job_execution_accepted.assert_called_once() + mock_client.subscribe_to_start_next_pending_job_execution_rejected.assert_called_once() + mock_client.subscribe_to_update_job_execution_accepted.assert_called_once() + mock_client.subscribe_to_update_job_execution_rejected.assert_called_once() + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_try_start_next_job_publishes(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + jobs.client(conn, "thing1") + + assert mock_client.publish_start_next_pending_job_execution.call_count == 1 + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_try_start_next_job_skips_when_already_working( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + call_count_before = mock_client.publish_start_next_pending_job_execution.call_count + c.try_start_next_job() + assert mock_client.publish_start_next_pending_job_execution.call_count == call_count_before + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_job_thread_fn_calls_job_func_and_reports_succeeded( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + received = [] + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1", job_func=lambda jid, jdoc: received.append((jid, jdoc))) + + mock_client.publish_update_job_execution.reset_mock() + c.job_thread_fn("job-123", {"command": "reboot"}) + + assert received == [("job-123", {"command": "reboot"})] + mock_client.publish_update_job_execution.assert_called_once() + request = mock_client.publish_update_job_execution.call_args[0][0] + assert request.status == iotjobs.JobStatus.SUCCEEDED + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_job_thread_fn_reports_failed_on_exception(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + def failing_func(jid, jdoc): + raise RuntimeError("something broke") + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1", job_func=failing_func) + + mock_client.publish_update_job_execution.reset_mock() + c.job_thread_fn("job-456", {}) + + mock_client.publish_update_job_execution.assert_called_once() + request = mock_client.publish_update_job_execution.call_args[0][0] + assert request.status == iotjobs.JobStatus.FAILED diff --git a/test/unit/test_jobs_callbacks.py b/test/unit/test_jobs_callbacks.py new file mode 100644 index 0000000..ff05a39 --- /dev/null +++ b/test/unit/test_jobs_callbacks.py @@ -0,0 +1,294 @@ +from concurrent.futures import Future +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from awscrt import mqtt +from awsiot import iotjobs + +import awsiotclient.jobs as jobs + + +class TestJobsCallbacks: + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_init_wraps_subscription_failure(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + failed = Future() + failed.set_exception(RuntimeError("subscribe failed")) + mock_client.subscribe_to_next_job_execution_changed_events.return_value = ( + failed, + 1, + ) + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + with pytest.raises(jobs.ExceptionAwsIotJobs): + jobs.client(conn, "thing1") + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_try_start_next_job_skips_when_disconnect_called( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + mock_client.publish_start_next_pending_job_execution.reset_mock() + c.locked_data.is_working_on_job = False + c.locked_data.disconnect_called = True + + c.try_start_next_job() + + mock_client.publish_start_next_pending_job_execution.assert_not_called() + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_done_working_on_job_retries_when_waiting(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + c.try_start_next_job = MagicMock() + c.locked_data.is_working_on_job = True + c.locked_data.is_next_job_waiting = True + + c.done_working_on_job() + + assert c.locked_data.is_working_on_job is False + c.try_start_next_job.assert_called_once() + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_next_job_execution_changed_none(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + c.on_next_job_execution_changed(SimpleNamespace(execution=None)) + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_next_job_execution_changed_sets_wait_flag_while_working( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + c.try_start_next_job = MagicMock() + c.locked_data.is_working_on_job = True + + event = SimpleNamespace( + execution=SimpleNamespace(job_id="job-1", job_document={"cmd": "reboot"}) + ) + c.on_next_job_execution_changed(event) + + assert c.locked_data.is_next_job_waiting is True + c.try_start_next_job.assert_not_called() + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_next_job_execution_changed_starts_now_when_idle( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + c.locked_data.is_working_on_job = False + c.try_start_next_job = MagicMock() + + event = SimpleNamespace( + execution=SimpleNamespace(job_id="job-2", job_document={"cmd": "sync"}) + ) + c.on_next_job_execution_changed(event) + + c.try_start_next_job.assert_called_once() + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_next_job_execution_changed_wraps_unexpected_error( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + class BrokenEvent: + @property + def execution(self): + raise RuntimeError("cannot access execution") + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + with pytest.raises(jobs.ExceptionAwsIotJobs): + c.on_next_job_execution_changed(BrokenEvent()) + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_publish_start_next_pending_job_execution_raises( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + failed = Future() + failed.set_exception(RuntimeError("publish failed")) + + with pytest.raises(jobs.ExceptionAwsIotJobs): + c.on_publish_start_next_pending_job_execution(failed) + + @patch("awsiotclient.jobs.threading.Thread") + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_start_next_pending_job_accepted_spawns_thread( + self, MockJobsClient, MockThread, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + thread_instance = MagicMock() + MockThread.return_value = thread_instance + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + response = SimpleNamespace( + execution=SimpleNamespace(job_id="job-3", job_document={"cmd": "update"}) + ) + c.on_start_next_pending_job_execution_accepted(response) + + MockThread.assert_called_once() + assert MockThread.call_args.kwargs["name"] == "job_thread" + thread_instance.start.assert_called_once() + + @patch("awsiotclient.jobs.threading.Thread") + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_start_next_pending_job_accepted_wraps_thread_creation_error( + self, MockJobsClient, MockThread, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + MockThread.side_effect = RuntimeError("thread creation failed") + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + response = SimpleNamespace( + execution=SimpleNamespace(job_id="job-err", job_document={"cmd": "update"}) + ) + with pytest.raises(jobs.ExceptionAwsIotJobs): + c.on_start_next_pending_job_execution_accepted(response) + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_start_next_pending_job_accepted_without_execution_marks_done( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + c.done_working_on_job = MagicMock() + c.on_start_next_pending_job_execution_accepted(SimpleNamespace(execution=None)) + + c.done_working_on_job.assert_called_once() + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_start_next_pending_job_rejected_raises(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + with pytest.raises(jobs.ExceptionAwsIotJobs): + c.on_start_next_pending_job_execution_rejected( + SimpleNamespace(code="Rejected", message="bad request") + ) + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_job_thread_user_defined_failure_sets_status_details( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + def fail_user_defined(*args, **kwargs): + raise jobs.ExceptionAwsIotJobsUserDefinedFailure("manual failure") + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1", job_func=fail_user_defined) + + mock_client.publish_update_job_execution.reset_mock() + c.job_thread_fn("job-4", {"cmd": "noop"}) + + mock_client.publish_update_job_execution.assert_called_once() + request = mock_client.publish_update_job_execution.call_args[0][0] + assert request.status == iotjobs.JobStatus.FAILED + assert request.status_details["failure_type"] == "user_defined" + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_publish_update_job_execution_raises(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + failed = Future() + failed.set_exception(RuntimeError("publish failed")) + + with pytest.raises(jobs.ExceptionAwsIotJobs): + c.on_publish_update_job_execution(failed) + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_update_job_execution_accepted_calls_done( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + c.done_working_on_job = MagicMock() + c.on_update_job_execution_accepted(SimpleNamespace()) + + c.done_working_on_job.assert_called_once() + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_update_job_execution_accepted_wraps_done_error( + self, MockJobsClient, make_jobs_client + ): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + c.done_working_on_job = MagicMock(side_effect=RuntimeError("done failed")) + with pytest.raises(jobs.ExceptionAwsIotJobs): + c.on_update_job_execution_accepted(SimpleNamespace()) + + @patch("awsiotclient.jobs.iotjobs.IotJobsClient") + def test_on_update_job_execution_rejected_raises(self, MockJobsClient, make_jobs_client): + mock_client = make_jobs_client() + MockJobsClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = jobs.client(conn, "thing1") + + with pytest.raises(jobs.ExceptionAwsIotJobs): + c.on_update_job_execution_rejected( + SimpleNamespace(code="Rejected", message="forbidden") + ) diff --git a/test/unit/test_mqtt.py b/test/unit/test_mqtt.py new file mode 100644 index 0000000..992ac13 --- /dev/null +++ b/test/unit/test_mqtt.py @@ -0,0 +1,129 @@ +from os.path import expanduser +from unittest.mock import MagicMock, patch + +import pytest + +from awscrt import mqtt + +from awsiotclient.mqtt import ( + ConnectionParams, + ExceptionAwsIotMqtt, + init, + on_connection_interrupted, + on_connection_resumed, + on_resubscribe_complete, +) + + +class TestConnectionParams: + def test_defaults(self): + p = ConnectionParams() + assert p.endpoint == "" + assert p.signing_region == "ap-northeast-1" + assert p.use_websocket is False + assert p.proxy_host is None + assert p.proxy_port == 8080 + + def test_expands_home(self): + p = ConnectionParams(root_ca="~/root.pem", cert="~/cert.pem", key="~/key.pem") + assert p.root_ca == expanduser("~/root.pem") + assert p.cert == expanduser("~/cert.pem") + assert p.key == expanduser("~/key.pem") + + +class TestInit: + @patch("awsiotclient.mqtt.mqtt_connection_builder") + def test_mtls(self, mock_builder): + mock_builder.mtls_from_path.return_value = MagicMock() + params = ConnectionParams(endpoint="example.com", cert="/cert", key="/key", root_ca="/ca") + result = init(params) + + mock_builder.mtls_from_path.assert_called_once() + assert result == mock_builder.mtls_from_path.return_value + + @patch("awsiotclient.mqtt.mqtt_connection_builder") + @patch("awsiotclient.mqtt.auth") + def test_websocket(self, mock_auth, mock_builder): + mock_builder.websockets_with_default_aws_signing.return_value = MagicMock() + params = ConnectionParams(endpoint="example.com", use_websocket=True, root_ca="/ca") + result = init(params) + + mock_builder.websockets_with_default_aws_signing.assert_called_once() + assert result == mock_builder.websockets_with_default_aws_signing.return_value + + +class TestCallbacks: + def test_on_resubscribe_complete_raises_on_rejected(self, make_done_future): + future = make_done_future({"topics": [("my/topic", None)]}) + + with pytest.raises(ExceptionAwsIotMqtt): + on_resubscribe_complete(future) + + def test_on_resubscribe_complete_succeeds(self, make_done_future): + future = make_done_future({"topics": [("my/topic", mqtt.QoS.AT_LEAST_ONCE)]}) + + on_resubscribe_complete(future) # should not raise + + +class TestConnectionCallbacks: + def test_on_connection_interrupted(self): + conn = MagicMock(spec=mqtt.Connection) + error = RuntimeError("network error") + + on_connection_interrupted(conn, error) + + def test_on_connection_resumed_resubscribes_when_session_not_present(self): + conn = MagicMock(spec=mqtt.Connection) + resubscribe_future = MagicMock() + conn.resubscribe_existing_topics.return_value = (resubscribe_future, 1) + + on_connection_resumed( + conn, + mqtt.ConnectReturnCode.ACCEPTED, + session_present=False, + ) + + conn.resubscribe_existing_topics.assert_called_once() + resubscribe_future.add_done_callback.assert_called_once_with( + on_resubscribe_complete + ) + + def test_on_connection_resumed_skips_resubscribe_when_session_present(self): + conn = MagicMock(spec=mqtt.Connection) + + on_connection_resumed( + conn, + mqtt.ConnectReturnCode.ACCEPTED, + session_present=True, + ) + + conn.resubscribe_existing_topics.assert_not_called() + + +class TestWebsocketProxy: + @patch("awsiotclient.mqtt.http.HttpProxyOptions") + @patch("awsiotclient.mqtt.mqtt_connection_builder") + @patch("awsiotclient.mqtt.auth") + def test_init_websocket_with_proxy_options(self, mock_auth, mock_builder, MockProxy): + proxy_options = MagicMock() + MockProxy.return_value = proxy_options + mock_builder.websockets_with_default_aws_signing.return_value = MagicMock() + + params = ConnectionParams( + endpoint="example.com", + use_websocket=True, + root_ca="/ca", + proxy_host="proxy.local", + proxy_port=3128, + ) + result = init(params) + + MockProxy.assert_called_once_with(host_name="proxy.local", port=3128) + mock_builder.websockets_with_default_aws_signing.assert_called_once() + assert ( + mock_builder.websockets_with_default_aws_signing.call_args.kwargs[ + "websocket_proxy_options" + ] + == proxy_options + ) + assert result == mock_builder.websockets_with_default_aws_signing.return_value diff --git a/test/unit/test_named_shadow.py b/test/unit/test_named_shadow.py new file mode 100644 index 0000000..25cf2e1 --- /dev/null +++ b/test/unit/test_named_shadow.py @@ -0,0 +1,84 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from awscrt import mqtt + +import awsiotclient.named_shadow as named_shadow + + +class TestNamedShadowClient: + @patch("awsiotclient.named_shadow.iotshadow.IotShadowClient") + def test_subscribes_with_shadow_name(self, MockShadowClient, make_named_shadow_client): + mock_client = make_named_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + named_shadow.client(conn, "thing1", "my-shadow") + + mock_client.subscribe_to_named_shadow_delta_updated_events.assert_called_once() + mock_client.subscribe_to_update_named_shadow_accepted.assert_called_once() + mock_client.subscribe_to_get_named_shadow_accepted.assert_called_once() + + @patch("awsiotclient.named_shadow.iotshadow.IotShadowClient") + def test_publishes_get_named_shadow_on_init( + self, MockShadowClient, make_named_shadow_client + ): + mock_client = make_named_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + named_shadow.client(conn, "thing1", "my-shadow") + + mock_client.publish_get_named_shadow.assert_called_once() + + @patch("awsiotclient.named_shadow.iotshadow.IotShadowClient") + def test_update_shadow_request_publishes( + self, MockShadowClient, make_named_shadow_client + ): + mock_client = make_named_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = named_shadow.client(conn, "thing1", "my-shadow") + + mock_client.publish_update_named_shadow.reset_mock() + c.change_reported_value({"temp": 25}) + + mock_client.publish_update_named_shadow.assert_called_once() + + @patch("awsiotclient.named_shadow.iotshadow.IotShadowClient") + def test_label_returns_shadow_name(self, MockShadowClient, make_named_shadow_client): + mock_client = make_named_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = named_shadow.client(conn, "thing1", "my-shadow") + + assert c.label() == "my-shadow" + + @patch("awsiotclient.named_shadow.iotshadow.IotShadowClient") + def test_update_noop_when_both_none(self, MockShadowClient, make_named_shadow_client): + mock_client = make_named_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = named_shadow.client(conn, "thing1", "my-shadow") + + mock_client.publish_update_named_shadow.reset_mock() + result = c.update_shadow_request(desired=None, reported=None) + + mock_client.publish_update_named_shadow.assert_not_called() + assert result.result() is None + + @patch("awsiotclient.named_shadow.iotshadow.IotShadowClient") + def test_get_accepted_uses_full_document(self, MockShadowClient, make_named_shadow_client): + mock_client = make_named_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = named_shadow.client(conn, "thing1", "my-shadow") + + response = SimpleNamespace(state=SimpleNamespace(delta=None, reported={"temp": 27})) + c.on_get_shadow_accepted(response) + + assert c.locked_data.get_reported_value() == {"temp": 27} diff --git a/test/unit/test_pubsub.py b/test/unit/test_pubsub.py new file mode 100644 index 0000000..32af213 --- /dev/null +++ b/test/unit/test_pubsub.py @@ -0,0 +1,52 @@ +import json + +from awscrt import mqtt + +from awsiotclient import pubsub + + +class TestSubscriber: + def test_subscribes_to_topic(self, make_pubsub_connection): + conn = make_pubsub_connection(mqtt.QoS.AT_LEAST_ONCE) + pubsub.Subscriber(conn, "my/topic") + + conn.subscribe.assert_called_once() + call_kwargs = conn.subscribe.call_args + assert call_kwargs.kwargs["topic"] == "my/topic" + assert call_kwargs.kwargs["qos"] == mqtt.QoS.AT_LEAST_ONCE + + def test_callback_parses_json(self, make_pubsub_connection): + conn = make_pubsub_connection(mqtt.QoS.AT_LEAST_ONCE) + received = [] + + def callback(topic, payload): + received.append((topic, payload)) + + sub = pubsub.Subscriber(conn, "my/topic", callback=callback) + + # Simulate message received + sub.on_message_received("my/topic", json.dumps({"key": "value"}).encode()) + + assert len(received) == 1 + assert received[0] == ("my/topic", {"key": "value"}) + + +class TestPublisher: + def test_publishes_payload(self, make_pubsub_connection): + conn = make_pubsub_connection(mqtt.QoS.AT_LEAST_ONCE) + pub = pubsub.Publisher(conn, "my/topic") + pub.publish({"foo": "bar"}) + + conn.publish.assert_called_once() + call_kwargs = conn.publish.call_args + assert call_kwargs.kwargs["topic"] == "my/topic" + assert json.loads(call_kwargs.kwargs["payload"]) == {"foo": "bar"} + assert call_kwargs.kwargs["qos"] == mqtt.QoS.AT_LEAST_ONCE + + def test_empty_payload_does_not_publish(self, make_pubsub_connection): + conn = make_pubsub_connection(mqtt.QoS.AT_LEAST_ONCE) + pub = pubsub.Publisher(conn, "my/topic") + result = pub.publish(None) + + conn.publish.assert_not_called() + assert result.result() is None diff --git a/test/unit/test_shadow.py b/test/unit/test_shadow.py new file mode 100644 index 0000000..0d54925 --- /dev/null +++ b/test/unit/test_shadow.py @@ -0,0 +1,52 @@ +from awsiotclient.shadow import DocumentTracker, ShadowData + + +class TestDocumentTracker: + def test_get_set(self): + t = DocumentTracker() + assert t.get() is None + t.set({"key": "value"}) + assert t.get() == {"key": "value"} + + def test_update_same_value_returns_none(self): + t = DocumentTracker() + t.set({"a": 1}) + result = t.update({"a": 1}, diff_only=False) + assert result is None + + def test_update_diff_only(self): + t = DocumentTracker() + t.set({"a": 1, "b": 2}) + result = t.update({"a": 1, "b": 3, "c": 4}, diff_only=True) + # dictdiff should return only changed/added keys + assert result == {"b": 3, "c": 4} + assert t.get() == {"a": 1, "b": 3, "c": 4} + + def test_update_full_doc(self): + t = DocumentTracker() + t.set({"a": 1}) + result = t.update({"a": 2, "b": 3}, diff_only=False) + assert result == {"a": 2, "b": 3} + assert t.get() == {"a": 2, "b": 3} + + +class TestShadowData: + def test_update_reported(self): + sd = ShadowData() + result = sd.update_reported_value({"temp": 25}, publish_full_doc=True) + assert result == {"temp": 25} + assert sd.get_reported_value() == {"temp": 25} + + def test_update_desired(self): + sd = ShadowData() + result = sd.update_desired_value({"mode": "on"}, publish_full_doc=True) + assert result == {"mode": "on"} + assert sd.get_desired_value() == {"mode": "on"} + + def test_update_both(self): + sd = ShadowData() + desired, reported = sd.update_both_values( + {"mode": "on"}, {"temp": 25}, publish_full_doc=True + ) + assert desired == {"mode": "on"} + assert reported == {"temp": 25} diff --git a/test/unit/test_shadow_callbacks.py b/test/unit/test_shadow_callbacks.py new file mode 100644 index 0000000..0d9a734 --- /dev/null +++ b/test/unit/test_shadow_callbacks.py @@ -0,0 +1,304 @@ +from concurrent.futures import Future +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from awscrt import mqtt + +import awsiotclient.classic_shadow as classic_shadow +from awsiotclient import ExceptionAwsIotClient +from awsiotclient.shadow import ExceptionAwsIotShadowInvalidDelta + + +class TestShadowCallbacks: + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_delta_ignored_when_state_is_none( + self, MockShadowClient, make_classic_shadow_client, make_done_future + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + c.change_reported_value = MagicMock(return_value=make_done_future()) + c.on_shadow_delta_updated(SimpleNamespace(state=None)) + + c.change_reported_value.assert_not_called() + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_delta_deleted_property_resets_reported( + self, MockShadowClient, make_classic_shadow_client, make_done_future + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + c.change_reported_value = MagicMock(return_value=make_done_future()) + c.on_shadow_delta_updated(SimpleNamespace(state={"other": "value"})) + + c.change_reported_value.assert_called_once_with(None) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_delta_invalid_request_resets_desired( + self, MockShadowClient, make_classic_shadow_client, make_done_future + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + + def invalid_delta(*args, **kwargs): + raise ExceptionAwsIotShadowInvalidDelta("invalid") + + c = classic_shadow.client(conn, "thing1", "prop1", delta_func=invalid_delta) + + c.change_desired_value = MagicMock(return_value=make_done_future()) + c.on_shadow_delta_updated(SimpleNamespace(state={"prop1": 123})) + + c.change_desired_value.assert_called_once_with(None) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_delta_unexpected_exception_is_reraised( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + + def failing_delta(*args, **kwargs): + raise RuntimeError("unexpected delta error") + + c = classic_shadow.client(conn, "thing1", "prop1", delta_func=failing_delta) + + with pytest.raises(RuntimeError): + c.on_shadow_delta_updated(SimpleNamespace(state={"prop1": 123})) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_get_shadow_accepted_uses_reported_state( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + response = SimpleNamespace( + state=SimpleNamespace(delta=None, reported={"prop1": "on"}) + ) + c.on_get_shadow_accepted(response) + + assert c.locked_data.get_reported_value() == "on" + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_get_shadow_accepted_ignores_when_delta_present( + self, MockShadowClient, make_classic_shadow_client, make_done_future + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + c.change_reported_value = MagicMock(return_value=make_done_future()) + response = SimpleNamespace( + state=SimpleNamespace(delta={"prop1": "pending"}, reported={"prop1": "on"}) + ) + c.on_get_shadow_accepted(response) + + c.change_reported_value.assert_not_called() + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_get_shadow_accepted_sets_default_when_property_missing( + self, MockShadowClient, make_classic_shadow_client, make_done_future + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + c.change_reported_value = MagicMock(return_value=make_done_future()) + response = SimpleNamespace(state=SimpleNamespace(delta=None, reported={"other": 1})) + c.on_get_shadow_accepted(response) + + c.change_reported_value.assert_called_once_with(None) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_get_shadow_accepted_returns_early_when_reported_already_set( + self, MockShadowClient, make_classic_shadow_client, make_done_future + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + c.locked_data.set_reported_value("already-set") + + c.change_reported_value = MagicMock(return_value=make_done_future()) + response = SimpleNamespace(state=SimpleNamespace(delta=None, reported={"prop1": "new"})) + c.on_get_shadow_accepted(response) + + c.change_reported_value.assert_not_called() + assert c.locked_data.get_reported_value() == "already-set" + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_get_shadow_accepted_reraises_unexpected_errors( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + class ExplodingMapping: + def get(self, key): + raise RuntimeError(f"forced failure: {key}") + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + bad_response = SimpleNamespace( + state=SimpleNamespace(delta=ExplodingMapping(), reported=None) + ) + with pytest.raises(RuntimeError, match="forced failure"): + c.on_get_shadow_accepted(bad_response) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_get_shadow_rejected_404_sets_default( + self, MockShadowClient, make_classic_shadow_client, make_done_future + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + c.change_reported_value = MagicMock(return_value=make_done_future()) + c.on_get_shadow_rejected(SimpleNamespace(code=404, message="not found")) + + c.change_reported_value.assert_called_once_with(None) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_get_shadow_rejected_non_404_raises( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + with pytest.raises(ExceptionAwsIotClient): + c.on_get_shadow_rejected(SimpleNamespace(code=500, message="error")) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_update_shadow_accepted_sets_desired_and_calls_callback( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + desired_func = MagicMock() + c = classic_shadow.client( + conn, + "thing1", + "prop1", + desired_func=desired_func, + ) + + response = SimpleNamespace( + state=SimpleNamespace(reported={"prop1": 1}, desired={"prop1": 2}) + ) + c.on_update_shadow_accepted(response) + + assert c.locked_data.get_desired_value() == {"prop1": 2} + desired_func.assert_called_once_with("thing1", "prop1", {"prop1": 2}) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_update_shadow_accepted_reraises_when_desired_callback_fails( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + + def failing_desired(*args, **kwargs): + raise RuntimeError("desired callback failed") + + c = classic_shadow.client( + conn, + "thing1", + "prop1", + desired_func=failing_desired, + ) + + response = SimpleNamespace( + state=SimpleNamespace(reported={"prop1": 1}, desired={"prop1": 2}) + ) + with pytest.raises(RuntimeError): + c.on_update_shadow_accepted(response) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_update_shadow_rejected_raises(self, MockShadowClient, make_classic_shadow_client): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + with pytest.raises(ExceptionAwsIotClient): + c.on_update_shadow_rejected(SimpleNamespace(code=409, message="conflict")) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_on_publish_update_shadow_raises_when_future_fails( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + failed = Future() + failed.set_exception(RuntimeError("publish failed")) + + with pytest.raises(RuntimeError): + c.on_publish_update_shadow(failed) + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_change_both_values_publishes_update( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + mock_client.publish_update_shadow.reset_mock() + c.change_both_values(desired_value={"mode": "on"}, reported_value={"temp": 25}) + + mock_client.publish_update_shadow.assert_called_once() + + @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") + def test_change_desired_value_publishes_update( + self, MockShadowClient, make_classic_shadow_client + ): + mock_client = make_classic_shadow_client() + MockShadowClient.return_value = mock_client + + conn = MagicMock(spec=mqtt.Connection) + c = classic_shadow.client(conn, "thing1", "prop1") + + mock_client.publish_update_shadow.reset_mock() + c.change_desired_value({"mode": "eco"}) + + mock_client.publish_update_shadow.assert_called_once() + request = mock_client.publish_update_shadow.call_args[0][0] + assert request.state.desired == {"prop1": {"mode": "eco"}} From f808428709f8be9b5710e12cb06ef84095a8f50a Mon Sep 17 00:00:00 2001 From: Daisuke Sato Date: Wed, 4 Mar 2026 15:32:09 +0900 Subject: [PATCH 2/2] Add GitHub Actions CI workflow for unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run unit tests across Python 3.8–3.14 matrix using poetry via pipx. poetry.lock is not committed as this is a library supporting multiple Python versions. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ README.md | 6 ++++++ 2 files changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b2ffba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Install dependencies + run: | + pipx install poetry + poetry install + - name: Unit tests + run: poetry run pytest test/unit -v --cov=awsiotclient --cov-branch --cov-report=term-missing diff --git a/README.md b/README.md index 1c10dcf..48fa21a 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,12 @@ job_client = jobs.client( # ``` +## Development + +### Why `poetry.lock` is not committed + +This is a library, not an application. Users resolve dependencies via `pyproject.toml` constraints, so a lock file provides no benefit to them. Additionally, we support Python 3.8–3.14, and a single lock file cannot cover all versions. CI runs `poetry lock` on each build to test against the latest compatible dependencies. + ## License This library is licensed under the Apache 2.0 License.