Skip to content

Commit

Permalink
Refactor plugins/module_utils/controller.py for better testing (#249)
Browse files Browse the repository at this point in the history
alinabuzachis authored Aug 15, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent a3e8837 commit 34cf98d
Showing 5 changed files with 387 additions and 2 deletions.
1 change: 1 addition & 0 deletions .config/dictionary.txt
Original file line number Diff line number Diff line change
@@ -59,3 +59,4 @@ refspec
Alina
Buzachis
alinabuzachis
hdrs
51 changes: 50 additions & 1 deletion plugins/module_utils/controller.py
Original file line number Diff line number Diff line change
@@ -142,8 +142,20 @@ def create_if_needed(
existing_item,
new_item,
endpoint,
on_create=None,
item_type="unknown",
):
# This will exit from the module on its own
# If the method successfully creates an item and on_create param is
# defined,
# the on_create parameter will be called as a method passing in
# this object and the json from the response
# This will return one of two things:
# 1. None if the existing_item is already defined (so no create
# needs to happen)
# 2. The response from EDA from calling the post on the endpoint.
# It's up to you to process the response and exit from the module
# Note: common error codes from the EDA API can cause the module to fail
response = None
if not endpoint:
msg = f"Unable to create new {item_type}, missing endpoint"
@@ -177,6 +189,15 @@ def create_if_needed(
msg = f"Unable to create {item_type} {item_name}: {response.status}"
raise EDAError(msg)

# If we have an on_create method and we actually changed something we can call on_create
if on_create is not None and self.result["changed"]:
on_create(self, response["json"])
else:
if response is not None:
last_data = response["json"]
return last_data
return

def _encrypted_changed_warning(self, field, old, warning=False):
if not warning:
return
@@ -248,7 +269,18 @@ def update_if_needed(
new_item,
endpoint,
item_type,
on_update=None,
):
# This will exit from the module on its own
# If the method successfully updates an item and on_update param is
# defined,
# the on_update parameter will be called as a method passing in this
# object and the json from the response
# This will return one of two things:
# 1. None if the existing_item does not need to be updated
# 2. The response from EDA from patching to the endpoint. It's up
# to you to process the response and exit from the module.
# Note: common error codes from the EDA API can cause the module to fail
response = None
if existing_item is None:
raise RuntimeError(
@@ -292,27 +324,44 @@ def update_if_needed(
raise EDAError(response.json["__all__"])
msg = f"Unable to update {item_type} {item_name}"
raise EDAError(msg)
return self.result

# If we change something and have an on_change call it
if on_update is not None and self.result["changed"]:
if response is None:
last_data = existing_item
else:
last_data = response["json"]
on_update(self, last_data)
else:
if response is None:
last_data = existing_item
else:
last_data = response["json"]
return last_data

def create_or_update_if_needed(
self,
existing_item,
new_item,
endpoint=None,
item_type="unknown",
on_create=None,
on_update=None,
):
if existing_item:
return self.update_if_needed(
existing_item,
new_item,
endpoint,
item_type=item_type,
on_update=on_update,
)
return self.create_if_needed(
existing_item,
new_item,
endpoint,
item_type=item_type,
on_create=on_create,
)

def delete_if_needed(self, existing_item, endpoint, on_delete=None):
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -32,7 +32,6 @@ include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
ensure_newline_before_comments = true
line_length = 120

[tool.mypy]
python_version = "3.9"
131 changes: 131 additions & 0 deletions tests/unit/plugins/module_utils/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-

# Copyright: Contributors to the Ansible project
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)

from __future__ import absolute_import, division, print_function

__metaclass__ = type

import json
from unittest.mock import Mock, patch
from urllib.error import HTTPError, URLError

import pytest
from ansible_collections.ansible.eda.plugins.module_utils.client import ( # type: ignore
Client,
)
from ansible_collections.ansible.eda.plugins.module_utils.errors import ( # type: ignore
AuthError,
EDAHTTPError,
)

ENDPOINT = "/api/test_endpoint"
QUERY_PARAMS = {"param": "value"}
ID = 1
DATA = {"key": "value"}
JSON_DATA = '{"key": "value"}'


@pytest.fixture
def mock_response():
response = Mock()
response.status = 200
response.read.return_value = JSON_DATA.encode("utf-8")
response.headers = {"content-type": "application/json"}
return response


@pytest.fixture
def mock_error_response():
response = Mock()
response.status = 401
response.read.return_value = b"Unauthorized"
response.headers = {}
return response


@pytest.fixture
def mock_http_error():
return HTTPError(
url="http://example.com", code=401, msg="Unauthorized", hdrs={}, fp=None
)


@pytest.fixture
def mock_url_error():
return URLError("URL error")


@pytest.fixture
def client():
with patch(
"ansible_collections.ansible.eda.plugins.module_utils.client.Request"
) as mock_request:
mock_request_instance = Mock()
mock_request.return_value = mock_request_instance
client_instance = Client(
host="http://mocked-url.com",
username="mocked_user",
password="mocked_pass",
timeout=10,
validate_certs=True,
)
yield client_instance, mock_request_instance


@pytest.mark.parametrize(
"method, status_code, expected_response, exception_type, exception_message, headers, data",
[
("get", 200, DATA, None, None, {}, None),
("post", 201, DATA, None, None, {"Content-Type": "application/json"}, DATA),
("patch", 200, DATA, None, None, {"Content-Type": "application/json"}, DATA),
("delete", 204, {}, None, None, {}, None),
(
"post",
401,
None,
AuthError,
"Failed to authenticate with the instance: 401 Unauthorized",
{"Content-Type": "application/json"},
DATA,
),
("get", 404, DATA, None, None, {}, None),
("get", None, None, EDAHTTPError, "URL error", {}, None),
],
)
def test_client_methods(
method,
status_code,
expected_response,
exception_type,
exception_message,
headers,
data,
client,
mock_response,
mock_http_error,
mock_url_error,
):
client_instance, mock_request_instance = client
mock_request_instance.open = Mock()

if exception_type:
if exception_type == AuthError:
mock_request_instance.open.side_effect = mock_http_error
with pytest.raises(exception_type, match=exception_message):
getattr(client_instance, method)(ENDPOINT, data=data, headers=headers)
elif exception_type == EDAHTTPError:
mock_request_instance.open.side_effect = mock_url_error
with pytest.raises(exception_type, match=exception_message):
getattr(client_instance, method)(ENDPOINT, data=data, headers=headers)
else:
mock_response.status = status_code
mock_response.read.return_value = json.dumps(expected_response).encode("utf-8")
mock_request_instance.open.return_value = mock_response

response = getattr(client_instance, method)(
ENDPOINT, data=data, headers=headers
)
assert response.status == status_code
assert response.json == expected_response
205 changes: 205 additions & 0 deletions tests/unit/plugins/module_utils/test_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-

# Copyright: Contributors to the Ansible project
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)

from __future__ import absolute_import, division, print_function

__metaclass__ = type

from unittest.mock import MagicMock, Mock

import pytest
from ansible_collections.ansible.eda.plugins.module_utils.controller import ( # type: ignore
Controller,
)
from ansible_collections.ansible.eda.plugins.module_utils.errors import ( # type: ignore
EDAError,
)

ENDPOINT = "test_endpoint"


@pytest.fixture
def mock_client():
return Mock()


@pytest.fixture
def mock_module():
module = Mock()
module.params = {"update_secrets": True}
module.check_mode = False
return module


@pytest.fixture
def controller(mock_client, mock_module):
return Controller(client=mock_client, module=mock_module)


@pytest.mark.parametrize(
"existing_item, new_item, mock_response, expected_result, expected_calls",
[
# create_if_needed without existing item
(
None,
{"name": "Test"},
Mock(status=201, json={"id": 1}),
{"changed": True, "id": 1},
1, # Expected number of post calls
),
# create_if_needed with existing item
(
{"id": 1, "url": "http://test.com/api/item/1"},
{"name": "Test"},
None,
None,
0, # Expected number of post calls
),
],
)
def test_create_if_needed(
mock_client,
controller,
existing_item,
new_item,
mock_response,
expected_result,
expected_calls,
):
if mock_response:
mock_client.post.return_value = mock_response
result = controller.create_if_needed(existing_item, new_item, ENDPOINT)
assert result == expected_result
assert mock_client.post.call_count == expected_calls
if expected_calls > 0:
mock_client.post.assert_called_with(ENDPOINT, **{"data": new_item})


@pytest.mark.parametrize(
"existing_item, mock_response, expected_result, expected_calls",
[
# delete_if_needed with an existing item
(
{"id": 1, "name": "test_item"},
Mock(status=204, json={}),
{"changed": True, "id": 1},
1, # Expected number of delete calls
),
# delete_if_needed without an existing item
(
None,
None,
{"changed": False},
0, # Expected number of delete calls
),
],
)
def test_delete_if_needed(
mock_client,
controller,
existing_item,
mock_response,
expected_result,
expected_calls,
):
if mock_response:
mock_client.delete.return_value = mock_response

result = controller.delete_if_needed(existing_item, ENDPOINT)
assert result == expected_result
assert mock_client.delete.call_count == expected_calls
if expected_calls > 0:
mock_client.delete.assert_called_with(ENDPOINT, **{"id": existing_item["id"]})


def test_update_if_needed_with_existing_item(mock_client, controller):
existing_item = {"id": 1, "name": "Test1"}
new_item = {"name": "Test2"}
response = Mock(status=200, json={"id": 1, "name": "Test2"})
mock_client.patch.return_value = response
result = controller.update_if_needed(
existing_item, new_item, ENDPOINT, "resource type"
)
mock_client.patch.assert_called_with(ENDPOINT, **{"data": new_item, "id": 1})
assert result["changed"] is True
assert result["id"] == 1


def test_get_endpoint(mock_client, controller):
response = Mock(status=200, json={"count": 1, "results": [{"id": 1}]})
mock_client.get.return_value = response
result = controller.get_endpoint(ENDPOINT)
mock_client.get.assert_called_with(ENDPOINT)
assert result == response


def test_post_endpoint(mock_client, controller):
response = Mock(status=201, json={"id": 1})
mock_client.post.return_value = response
result = controller.post_endpoint(ENDPOINT)
mock_client.post.assert_called_with(ENDPOINT)
assert result == response


def test_patch_endpoint_check_mode(controller):
controller.module.check_mode = True
result = controller.patch_endpoint(ENDPOINT)
assert result["changed"] is True


def test_get_name_field_from_endpoint():
assert Controller.get_name_field_from_endpoint("users") == "username"
assert Controller.get_name_field_from_endpoint("unknown") == "name"


@pytest.mark.parametrize(
"item, expected_name, should_raise",
[
({"name": "test_item"}, "test_item", False),
({"username": "test_user"}, "test_user", False),
({}, None, True),
],
)
def test_get_item_name(controller, item, expected_name, should_raise):
if should_raise:
with pytest.raises(EDAError):
controller.get_item_name(item)
else:
assert controller.get_item_name(item) == expected_name


def test_has_encrypted_values():
assert Controller.has_encrypted_values({"key": "$encrypted$"}) is True
assert Controller.has_encrypted_values({"key": "value"}) is False


def test_fail_wanted_one(mock_client, controller):
response = MagicMock()
response.json.return_value = {"count": 2, "results": [{"id": 1}, {"id": 2}]}
mock_client.build_url.return_value.geturl.return_value = "http://example.com/api"
mock_client.host = "http://example.com"
with pytest.raises(EDAError, match="expected 1"):
controller.fail_wanted_one(response, "endpoint", {})


def test_fields_could_be_same():
assert (
Controller.fields_could_be_same({"key": "$encrypted$"}, {"key": "value"})
is True
)
assert (
Controller.fields_could_be_same({"key1": "value1"}, {"key2": "value2"}) is False
)


@pytest.mark.parametrize(
"old, new, expected",
[
({"key": "$encrypted$"}, {"key": "value"}, True),
({"key": "value"}, {"key": "value"}, False),
],
)
def test_objects_could_be_different(controller, old, new, expected):
assert controller.objects_could_be_different(old, new) is expected

0 comments on commit 34cf98d

Please sign in to comment.