Skip to content

Commit 5fa6129

Browse files
authored
Merge pull request #33 from escapecloud/task/add_cloud_utils_tests
Add unit tests for AWS and Azure cloud utility functions
2 parents 716dc55 + 92f1f16 commit 5fa6129

2 files changed

Lines changed: 484 additions & 0 deletions

File tree

‎tests/test_utils_aws.py‎

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# tests/test_utils_aws.py
2+
import os
3+
import tempfile
4+
import unittest
5+
from datetime import date, datetime, timezone
6+
from unittest.mock import MagicMock, patch
7+
8+
import botocore.exceptions
9+
10+
from core.utils_aws import (
11+
aws_api_call_with_retry,
12+
convert_datetime,
13+
get_missing_months_aws,
14+
)
15+
16+
17+
class ConvertDatetimeTests(unittest.TestCase):
18+
def test_converts_datetime_in_flat_dict(self):
19+
dt = datetime(2026, 1, 15, 12, 30, 0)
20+
result = convert_datetime({"created": dt, "name": "test"})
21+
self.assertEqual(result["created"], "2026-01-15T12:30:00")
22+
self.assertEqual(result["name"], "test")
23+
24+
def test_converts_datetime_in_list(self):
25+
dt = datetime(2026, 3, 1)
26+
result = convert_datetime([dt, "keep"])
27+
self.assertEqual(result[0], "2026-03-01T00:00:00")
28+
self.assertEqual(result[1], "keep")
29+
30+
def test_converts_nested_datetime(self):
31+
dt = datetime(2026, 6, 15)
32+
result = convert_datetime({"items": [{"ts": dt}]})
33+
self.assertEqual(result["items"][0]["ts"], "2026-06-15T00:00:00")
34+
35+
def test_leaves_non_datetime_values_unchanged(self):
36+
data = {"count": 5, "name": "ec2", "tags": ["a", "b"]}
37+
result = convert_datetime(data)
38+
self.assertEqual(result, {"count": 5, "name": "ec2", "tags": ["a", "b"]})
39+
40+
def test_handles_empty_structures(self):
41+
self.assertEqual(convert_datetime({}), {})
42+
self.assertEqual(convert_datetime([]), [])
43+
self.assertIsNone(convert_datetime(None))
44+
45+
46+
class GetMissingMonthsAwsTests(unittest.TestCase):
47+
@patch("core.utils_aws.datetime")
48+
def test_returns_missing_months(self, mock_dt):
49+
mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc)
50+
mock_dt.strptime = datetime.strptime
51+
52+
processed = {"2026-06-01", "2026-05-01", "2026-04-01"}
53+
missing = get_missing_months_aws(processed, 6)
54+
55+
# Should be missing: 2026-03, 2026-02, 2026-01
56+
self.assertEqual(len(missing), 3)
57+
self.assertIn(date(2026, 3, 1), missing)
58+
self.assertIn(date(2026, 2, 1), missing)
59+
self.assertIn(date(2026, 1, 1), missing)
60+
61+
@patch("core.utils_aws.datetime")
62+
def test_returns_empty_when_all_present(self, mock_dt):
63+
mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc)
64+
mock_dt.strptime = datetime.strptime
65+
66+
processed = {
67+
"2026-06-01",
68+
"2026-05-01",
69+
"2026-04-01",
70+
"2026-03-01",
71+
"2026-02-01",
72+
"2026-01-01",
73+
}
74+
missing = get_missing_months_aws(processed, 6)
75+
self.assertEqual(missing, [])
76+
77+
@patch("core.utils_aws.datetime")
78+
def test_returns_all_when_none_processed(self, mock_dt):
79+
mock_dt.now.return_value = datetime(2026, 6, 15, tzinfo=timezone.utc)
80+
mock_dt.strptime = datetime.strptime
81+
82+
missing = get_missing_months_aws(set(), 6)
83+
self.assertEqual(len(missing), 6)
84+
85+
86+
class AwsApiCallWithRetryTests(unittest.TestCase):
87+
def test_successful_call_returns_result(self):
88+
mock_client = MagicMock()
89+
mock_client.describe_instances.return_value = {"Reservations": []}
90+
91+
api_call = aws_api_call_with_retry(
92+
mock_client, "describe_instances", {}, max_retries=3, retry_delay=0
93+
)
94+
result = api_call()
95+
96+
self.assertEqual(result, {"Reservations": []})
97+
mock_client.describe_instances.assert_called_once()
98+
99+
def test_passes_parameters_to_function(self):
100+
mock_client = MagicMock()
101+
mock_client.list_buckets.return_value = {"Buckets": []}
102+
103+
params = {"MaxItems": 10}
104+
api_call = aws_api_call_with_retry(
105+
mock_client, "list_buckets", params, max_retries=1, retry_delay=0
106+
)
107+
api_call()
108+
109+
mock_client.list_buckets.assert_called_once_with(MaxItems=10)
110+
111+
def test_passes_kwargs_from_caller(self):
112+
mock_client = MagicMock()
113+
mock_client.describe_instances.return_value = {"Reservations": []}
114+
115+
api_call = aws_api_call_with_retry(
116+
mock_client, "describe_instances", {}, max_retries=1, retry_delay=0
117+
)
118+
api_call(NextToken="abc123")
119+
120+
mock_client.describe_instances.assert_called_once_with(NextToken="abc123")
121+
122+
@patch("core.utils_aws.time.sleep")
123+
def test_retries_on_throttling(self, mock_sleep):
124+
mock_client = MagicMock()
125+
throttle_error = botocore.exceptions.ClientError(
126+
{"Error": {"Code": "Throttling", "Message": "Rate exceeded"}},
127+
"DescribeInstances",
128+
)
129+
mock_client.describe_instances.side_effect = [
130+
throttle_error,
131+
{"Reservations": ["instance-1"]},
132+
]
133+
134+
api_call = aws_api_call_with_retry(
135+
mock_client, "describe_instances", {}, max_retries=3, retry_delay=1
136+
)
137+
result = api_call()
138+
139+
self.assertEqual(result, {"Reservations": ["instance-1"]})
140+
self.assertEqual(mock_client.describe_instances.call_count, 2)
141+
mock_sleep.assert_called_once()
142+
143+
def test_raises_non_throttling_client_error_immediately(self):
144+
mock_client = MagicMock()
145+
access_denied = botocore.exceptions.ClientError(
146+
{"Error": {"Code": "AccessDenied", "Message": "Not authorized"}},
147+
"DescribeInstances",
148+
)
149+
mock_client.describe_instances.side_effect = access_denied
150+
151+
api_call = aws_api_call_with_retry(
152+
mock_client, "describe_instances", {}, max_retries=3, retry_delay=0
153+
)
154+
155+
with self.assertRaises(botocore.exceptions.ClientError):
156+
api_call()
157+
158+
mock_client.describe_instances.assert_called_once()
159+
160+
@patch("core.utils_aws.time.sleep")
161+
def test_raises_after_max_retries_exhausted(self, mock_sleep):
162+
mock_client = MagicMock()
163+
throttle_error = botocore.exceptions.ClientError(
164+
{"Error": {"Code": "Throttling", "Message": "Rate exceeded"}},
165+
"DescribeInstances",
166+
)
167+
mock_client.describe_instances.side_effect = throttle_error
168+
169+
api_call = aws_api_call_with_retry(
170+
mock_client, "describe_instances", {}, max_retries=2, retry_delay=0
171+
)
172+
173+
with self.assertRaises(Exception) as ctx:
174+
api_call()
175+
176+
self.assertIn("Failed to call", str(ctx.exception))
177+
self.assertEqual(mock_client.describe_instances.call_count, 2)
178+
179+
@patch("core.utils_aws.time.sleep")
180+
def test_retries_on_botocore_error(self, mock_sleep):
181+
mock_client = MagicMock()
182+
mock_client.describe_instances.side_effect = [
183+
botocore.exceptions.BotoCoreError(),
184+
{"Reservations": []},
185+
]
186+
187+
api_call = aws_api_call_with_retry(
188+
mock_client, "describe_instances", {}, max_retries=3, retry_delay=1
189+
)
190+
result = api_call()
191+
192+
self.assertEqual(result, {"Reservations": []})
193+
self.assertEqual(mock_client.describe_instances.call_count, 2)
194+
195+
196+
class BuildAwsCostInventoryErrorTests(unittest.TestCase):
197+
@patch("core.utils_aws.connect")
198+
@patch("core.utils_aws.boto3.Session")
199+
def test_sqlite_error_is_logged_but_not_reraised(
200+
self, mock_session_cls, mock_connect
201+
):
202+
"""sqlite3.Error is caught and logged but NOT re-raised in current code."""
203+
import sqlite3
204+
205+
mock_session = MagicMock()
206+
mock_session_cls.return_value = mock_session
207+
mock_ce = MagicMock()
208+
mock_session.client.return_value = mock_ce
209+
mock_ce.get_cost_and_usage.return_value = {
210+
"ResultsByTime": [
211+
{
212+
"TimePeriod": {"Start": "2026-01-01"},
213+
"Groups": [
214+
{
215+
"Metrics": {
216+
"UnblendedCost": {"Amount": "10.0", "Unit": "USD"}
217+
}
218+
}
219+
],
220+
}
221+
]
222+
}
223+
224+
mock_conn = MagicMock()
225+
mock_connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
226+
mock_connect.return_value.__exit__ = MagicMock(return_value=False)
227+
mock_cursor = MagicMock()
228+
mock_conn.cursor.return_value = mock_cursor
229+
mock_cursor.execute.side_effect = sqlite3.Error("disk I/O error")
230+
231+
from core.utils_aws import build_aws_cost_inventory
232+
233+
with tempfile.TemporaryDirectory() as tmp:
234+
report_path = os.path.join(tmp, "report")
235+
raw_data_path = os.path.join(tmp, "raw")
236+
os.makedirs(os.path.join(report_path, "data"), exist_ok=True)
237+
os.makedirs(raw_data_path, exist_ok=True)
238+
239+
# sqlite3.Error is caught but NOT re-raised in current code
240+
# (this documents the current behavior)
241+
try:
242+
build_aws_cost_inventory(
243+
2,
244+
{"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"},
245+
report_path,
246+
raw_data_path,
247+
)
248+
except sqlite3.Error:
249+
pass # Expected: current code catches but does not re-raise sqlite3.Error
250+
251+
252+
class BuildAwsResourceInventoryErrorTests(unittest.TestCase):
253+
@patch("core.utils_aws.load_data")
254+
@patch("core.utils_aws.boto3.Session")
255+
def test_outer_exception_is_logged_silently(self, mock_session_cls, mock_load_data):
256+
"""build_aws_resource_inventory catches all outer exceptions silently."""
257+
mock_load_data.side_effect = RuntimeError("DB unavailable")
258+
259+
from core.utils_aws import build_aws_resource_inventory
260+
261+
with tempfile.TemporaryDirectory() as tmp:
262+
report_path = os.path.join(tmp, "report")
263+
raw_data_path = os.path.join(tmp, "raw")
264+
os.makedirs(os.path.join(report_path, "data"), exist_ok=True)
265+
os.makedirs(raw_data_path, exist_ok=True)
266+
267+
# Should not raise -- outer except swallows everything
268+
build_aws_resource_inventory(
269+
2,
270+
{"accessKey": "AK", "secretKey": "SK", "region": "us-east-1"},
271+
report_path,
272+
raw_data_path,
273+
)
274+
275+
276+
if __name__ == "__main__":
277+
unittest.main()

0 commit comments

Comments
 (0)