Skip to content

Commit 9c3e93a

Browse files
authored
Increase addon operator unit test coverage (#277)
1 parent cbddb33 commit 9c3e93a

File tree

1 file changed

+276
-0
lines changed

1 file changed

+276
-0
lines changed

capi_addons/tests/test_operator.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,286 @@
1+
import hashlib
12
import unittest
23
from unittest import mock
34

45
from capi_addons import operator
56

67

8+
# Helper to create an async iterable
9+
class AsyncIterList:
10+
def __init__(self, items):
11+
self.items = items
12+
13+
def __aiter__(self):
14+
return self._generator()
15+
16+
async def _generator(self):
17+
for item in self.items:
18+
yield item
19+
20+
21+
class MockResource:
22+
def __init__(self, items):
23+
self._items = items
24+
self.list_kwargs = None
25+
self.patch = mock.AsyncMock()
26+
27+
def list(self, **kwargs):
28+
self.list_kwargs = kwargs
29+
return AsyncIterList(self._items)
30+
31+
732
class TestOperator(unittest.IsolatedAsyncioTestCase):
33+
@mock.patch("capi_addons.operator.HelmClient")
34+
@mock.patch("capi_addons.operator.Configuration")
35+
@mock.patch("capi_addons.operator.tempfile.NamedTemporaryFile")
36+
@mock.patch("capi_addons.operator.base64.b64decode")
37+
async def test_clients_for_cluster(
38+
self, mock_b64decode, mock_tempfile, mock_config_class, mock_helm_class
39+
):
40+
# Prepare base64-decoded kubeconfig data
41+
kubeconfig_data = b"apiVersion: v1\nclusters:\n..."
42+
encoded_value = hashlib.sha256(kubeconfig_data).hexdigest() # dummy content
43+
kubeconfig_secret = mock.Mock()
44+
kubeconfig_secret.data = {"value": encoded_value}
45+
46+
# Mock base64 decoding
47+
mock_b64decode.return_value = kubeconfig_data
48+
49+
# Mock temp file
50+
mock_temp = mock.MagicMock()
51+
mock_temp.name = "/mock/kubeconfig"
52+
mock_temp.__enter__.return_value = mock_temp
53+
mock_tempfile.return_value.__enter__.return_value = mock_temp
54+
55+
# Mock EasyKube client
56+
mock_async_client = mock.AsyncMock()
57+
mock_config = mock.Mock()
58+
mock_config.async_client.return_value = mock_async_client
59+
mock_config_class.from_kubeconfig_data.return_value = mock_config
60+
61+
# Mock Helm client
62+
mock_helm_instance = mock.Mock()
63+
mock_helm_class.return_value = mock_helm_instance
64+
65+
# Patch settings
66+
operator.settings.easykube_field_manager = "test-manager"
67+
operator.settings.helm_client = mock.Mock(
68+
default_timeout=60,
69+
executable="helm",
70+
history_max_revisions=10,
71+
insecure_skip_tls_verify=True,
72+
unpack_directory="/mock/unpack",
73+
)
74+
75+
async with operator.clients_for_cluster(kubeconfig_secret) as (
76+
ek_client,
77+
helm_client,
78+
):
79+
mock_b64decode.assert_called_once()
80+
mock_config_class.from_kubeconfig_data.assert_called_once_with(
81+
kubeconfig_data, json_encoder=mock.ANY
82+
)
83+
mock_config.async_client.assert_called_once_with(
84+
default_field_manager="test-manager"
85+
)
86+
mock_helm_class.assert_called_once_with(
87+
default_timeout=60,
88+
executable="helm",
89+
history_max_revisions=10,
90+
insecure_skip_tls_verify=True,
91+
kubeconfig="/mock/kubeconfig",
92+
unpack_directory="/mock/unpack",
93+
)
94+
self.assertEqual(ek_client, mock_async_client)
95+
self.assertEqual(helm_client, mock_helm_instance)
96+
97+
mock_async_client.__aenter__.assert_called_once()
98+
mock_async_client.__aexit__.assert_called_once()
99+
100+
async def test_fetch_ref(self):
101+
ref = {"name": "my-secret"}
102+
default_namespace = "default"
103+
104+
mock_resource = mock.AsyncMock()
105+
mock_resource.fetch.return_value = {
106+
"kind": "Secret",
107+
"metadata": {"name": "my-secret"},
108+
}
109+
110+
mock_api = mock.Mock()
111+
mock_api.resource = mock.AsyncMock(return_value=mock_resource)
112+
113+
mock_ek_client = mock.Mock()
114+
mock_ek_client.api.return_value = mock_api
115+
116+
result = await operator.fetch_ref(mock_ek_client, ref, default_namespace)
117+
118+
mock_ek_client.api.assert_called_once_with("v1")
119+
mock_api.resource.assert_awaited_once_with("Secret")
120+
mock_resource.fetch.assert_awaited_once_with("my-secret", namespace="default")
121+
self.assertEqual(result["metadata"]["name"], "my-secret")
122+
123+
@mock.patch("capi_addons.operator.asyncio.sleep", new_callable=mock.AsyncMock)
124+
@mock.patch("capi_addons.operator.create_ek_client")
125+
async def test_until_deleted(self, mock_create_ek_client, mock_sleep):
126+
addon = mock.Mock()
127+
addon.api_version = "group/v1"
128+
addon.metadata.name = "my-addon"
129+
addon.metadata.namespace = "my-namespace"
130+
addon._meta.plural_name = "addons"
131+
132+
alive_obj = mock.Mock()
133+
alive_obj.metadata.get.return_value = None
134+
135+
deleting_obj = mock.Mock()
136+
deleting_obj.metadata.get.return_value = "2024-01-01T00:00:00Z"
137+
138+
mock_resource = mock.AsyncMock()
139+
mock_resource.fetch = mock.AsyncMock(side_effect=[alive_obj, deleting_obj])
140+
141+
mock_ekapi = mock.Mock()
142+
mock_ekapi.resource = mock.AsyncMock(return_value=mock_resource)
143+
144+
mock_ek_client = mock.AsyncMock()
145+
mock_ek_client.api = mock.Mock(return_value=mock_ekapi)
146+
147+
mock_create_ek_client.return_value.__aenter__.return_value = mock_ek_client
148+
149+
await operator.until_deleted(addon)
150+
151+
self.assertEqual(mock_resource.fetch.await_count, 2)
152+
mock_resource.fetch.assert_called_with("my-addon", namespace="my-namespace")
153+
154+
async def test_compute_checksum(self):
155+
data = {"key1": "value1", "key2": "value2"}
156+
expected_checksum = hashlib.sha256(b"key1=value1;key2=value2").hexdigest()
157+
result = operator.compute_checksum(data)
158+
self.assertEqual(result, expected_checksum)
159+
160+
modified_data = {"key1": "value1", "key2": "DIFFERENT"}
161+
result_modified = operator.compute_checksum(modified_data)
162+
self.assertNotEqual(result, result_modified)
163+
164+
empty_checksum = operator.compute_checksum({})
165+
expected_empty_checksum = hashlib.sha256(b"").hexdigest()
166+
self.assertEqual(empty_checksum, expected_empty_checksum)
167+
168+
@mock.patch("capi_addons.operator.registry")
169+
async def test_handle_config_event_deleted(self, mock_registry):
170+
addon_metadata = mock.Mock()
171+
addon_metadata.name = "deleted-addon"
172+
addon_metadata.namespace = "test-namespace"
173+
addon_metadata.annotations = {}
174+
175+
addon = mock.Mock()
176+
addon.metadata = addon_metadata
177+
178+
addon_obj = mock.Mock()
179+
mock_registry.get_model_instance.return_value = addon
180+
181+
# Mock registry to return one CRD
182+
crd = mock.Mock()
183+
crd.api_group = "example.group"
184+
crd.plural_name = "addons"
185+
crd.versions = {"v1": mock.Mock(storage=True)}
186+
mock_registry.__iter__.return_value = iter([crd])
187+
188+
# Use MockResource and AsyncIterList
189+
mock_resource = MockResource(items=[addon_obj])
190+
191+
mock_ekapi = mock.Mock()
192+
mock_ekapi.resource = mock.AsyncMock(return_value=mock_resource)
193+
194+
mock_ek_client = mock.Mock()
195+
mock_ek_client.api.return_value = mock_ekapi
196+
197+
# Call the method with type="DELETED"
198+
await operator.handle_config_event(
199+
ek_client=mock_ek_client,
200+
type="DELETED",
201+
name="test-config",
202+
namespace="test-namespace",
203+
body={"data": {"foo": "bar"}}, # ignored for DELETED
204+
annotation_prefix="secret.addons.stackhpc.com",
205+
)
206+
207+
self.assertIn(
208+
"test-config.secret.addons.stackhpc.com/uses",
209+
mock_resource.list_kwargs["labels"],
210+
)
211+
212+
mock_resource.patch.assert_awaited_once()
213+
214+
patched_annotations = mock_resource.patch.call_args[0][1]["metadata"][
215+
"annotations"
216+
]
217+
self.assertEqual(
218+
patched_annotations["secret.addons.stackhpc.com/test-config"], "deleted"
219+
)
220+
221+
@mock.patch("capi_addons.operator.registry")
222+
async def test_handle_config_event_not_deleted(self, mock_registry):
223+
# Prepare mock addon metadata with no matching annotation initially
224+
addon_metadata = mock.Mock()
225+
addon_metadata.name = "addon-1"
226+
addon_metadata.namespace = "test-namespace"
227+
addon_metadata.annotations = {}
228+
229+
# Mock addon object returned from registry.get_model_instance
230+
addon = mock.Mock()
231+
addon.metadata = addon_metadata
232+
233+
addon_obj = mock.Mock()
234+
mock_registry.get_model_instance.return_value = addon
235+
236+
# Mock registry to return one CRD with version storage
237+
crd = mock.Mock()
238+
crd.api_group = "example.group"
239+
crd.plural_name = "addons"
240+
crd.versions = {"v1": mock.Mock(storage=True)}
241+
mock_registry.__iter__.return_value = iter([crd])
242+
243+
mock_resource = MockResource(items=[addon_obj])
244+
245+
mock_ekapi = mock.Mock()
246+
mock_ekapi.resource = mock.AsyncMock(return_value=mock_resource)
247+
248+
mock_ek_client = mock.Mock()
249+
mock_ek_client.api.return_value = mock_ekapi
250+
251+
# Define config data and expected checksum
252+
data = {"foo": "bar", "baz": "qux"}
253+
data_str = ";".join(sorted(f"{k}={v}" for k, v in data.items())).encode()
254+
expected_checksum = hashlib.sha256(data_str).hexdigest()
255+
256+
# Call the function with type != "DELETED"
257+
await operator.handle_config_event(
258+
ek_client=mock_ek_client,
259+
type="MODIFIED",
260+
name="test-config",
261+
namespace="test-namespace",
262+
body={"data": data},
263+
annotation_prefix="secret.addons.stackhpc.com",
264+
)
265+
266+
# Assertions to check resource.list was called with correct labels and namespace
267+
self.assertEqual(
268+
mock_resource.list_kwargs["labels"],
269+
{"test-config.secret.addons.stackhpc.com/uses": operator.PRESENT},
270+
)
271+
self.assertEqual(mock_resource.list_kwargs["namespace"], "test-namespace")
272+
273+
mock_resource.patch.assert_awaited_once()
274+
275+
# Verify patched annotation value matches expected checksum
276+
patch_args = mock_resource.patch.call_args[0]
277+
patch_body = patch_args[1]
278+
annotation_key = "secret.addons.stackhpc.com/test-config"
279+
self.assertEqual(
280+
patch_body["metadata"]["annotations"][annotation_key],
281+
expected_checksum,
282+
)
283+
8284
@mock.patch.object(operator, "handle_config_event")
9285
@mock.patch.object(operator, "create_ek_client")
10286
async def test_handle_secret_event(

0 commit comments

Comments
 (0)