"""Unit tests for the update_stack() coroutine. Requirements: 3.1, 3.2, 3.4, 4.1, 4.3, 4.4 """ from __future__ import annotations import asyncio from unittest.mock import MagicMock, patch import pytest from botocore.exceptions import ClientError from cfn_updater.config import BASE_RETRY_DELAY, TEMPLATE_URL from cfn_updater.updater import update_stack def _make_client_error(code: str, message: str = "error") -> ClientError: """Helper to create a botocore ClientError.""" return ClientError( {"Error": {"Code": code, "Message": message}}, "UpdateStack", ) def _mock_cfn_client( parameters: list[dict] | None = None, update_side_effect=None, describe_side_effect=None, ) -> MagicMock: """Build a mock CFN client with configurable describe/update behavior.""" client = MagicMock() if describe_side_effect is not None: client.describe_stacks.side_effect = describe_side_effect else: params = parameters or [] client.describe_stacks.return_value = { "Stacks": [ { "StackName": "CL-AppPipe-test", "Parameters": params, } ] } if update_side_effect is not None: client.update_stack.side_effect = update_side_effect else: client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/test"} return client class TestUpdateStackSuccess: """Req 3.1, 3.2: Successful update with parameter preservation.""" def test_successful_update_returns_succeeded(self) -> None: params = [ {"ParameterKey": "Param1", "ParameterValue": "val1"}, {"ParameterKey": "Param2", "ParameterValue": "val2"}, ] client = _mock_cfn_client(parameters=params) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) assert result.stack_name == "CL-AppPipe-test" assert result.status == "succeeded" assert result.error is None assert result.duration_seconds > 0 def test_update_passes_use_previous_value_for_all_params(self) -> None: params = [ {"ParameterKey": "KeyA", "ParameterValue": "a"}, {"ParameterKey": "KeyB", "ParameterValue": "b"}, {"ParameterKey": "KeyC", "ParameterValue": "c"}, ] client = _mock_cfn_client(parameters=params) asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) call_kwargs = client.update_stack.call_args.kwargs assert call_kwargs["TemplateURL"] == TEMPLATE_URL assert call_kwargs["Parameters"] == [ {"ParameterKey": "KeyA", "UsePreviousValue": True}, {"ParameterKey": "KeyB", "UsePreviousValue": True}, {"ParameterKey": "KeyC", "UsePreviousValue": True}, ] def test_update_with_no_existing_params(self) -> None: client = _mock_cfn_client(parameters=[]) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) assert result.status == "succeeded" call_kwargs = client.update_stack.call_args.kwargs assert call_kwargs["Parameters"] == [] class TestUpdateStackNoUpdates: """Req 3.4: 'No updates are to be performed' → no-update-needed.""" def test_no_updates_response_returns_no_update_needed(self) -> None: client = _mock_cfn_client( update_side_effect=_make_client_error( "ValidationError", "No updates are to be performed", ) ) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) assert result.status == "no-update-needed" assert result.error is None class TestUpdateStackThrottling: """Req 4.3: Throttling triggers exponential backoff retries.""" @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_throttling_retries_then_succeeds(self, mock_sleep) -> None: """Two throttling errors then success on third attempt.""" client = _mock_cfn_client( update_side_effect=[ _make_client_error("Throttling", "Rate exceeded"), _make_client_error("Throttling", "Rate exceeded"), {"StackId": "arn:aws:cfn:us-east-1:123:stack/test"}, ] ) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL, max_retries=3)) assert result.status == "succeeded" assert client.update_stack.call_count == 3 # Verify exponential backoff delays assert mock_sleep.call_count == 2 assert mock_sleep.call_args_list[0].args[0] == BASE_RETRY_DELAY * (2 ** 0) # 1s assert mock_sleep.call_args_list[1].args[0] == BASE_RETRY_DELAY * (2 ** 1) # 2s @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_request_limit_exceeded_retries(self, mock_sleep) -> None: """RequestLimitExceeded also triggers retry.""" client = _mock_cfn_client( update_side_effect=[ _make_client_error("RequestLimitExceeded", "Too many requests"), {"StackId": "arn:aws:cfn:us-east-1:123:stack/test"}, ] ) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL, max_retries=3)) assert result.status == "succeeded" assert client.update_stack.call_count == 2 @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_throttling_exhausts_retries_returns_failed(self, mock_sleep) -> None: """Req 4.4: All retries exhausted → failed.""" client = _mock_cfn_client( update_side_effect=[ _make_client_error("Throttling", "Rate exceeded"), _make_client_error("Throttling", "Rate exceeded"), _make_client_error("Throttling", "Rate exceeded"), _make_client_error("Throttling", "Rate exceeded"), ] ) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL, max_retries=3)) assert result.status == "failed" assert "Rate exceeded" in result.error assert client.update_stack.call_count == 4 # initial + 3 retries class TestUpdateStackErrors: """Req 4.1: Failures are recorded, not raised.""" def test_non_retryable_client_error_returns_failed(self) -> None: client = _mock_cfn_client( update_side_effect=_make_client_error( "ValidationError", "Stack is in UPDATE_IN_PROGRESS state and can not be updated", ) ) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) assert result.status == "failed" assert "UPDATE_IN_PROGRESS" in result.error def test_describe_stacks_error_returns_failed(self) -> None: client = _mock_cfn_client( describe_side_effect=_make_client_error( "ValidationError", "Stack with id CL-AppPipe-gone does not exist", ) ) result = asyncio.run(update_stack(client, "CL-AppPipe-gone", TEMPLATE_URL)) assert result.status == "failed" assert "does not exist" in result.error def test_unexpected_exception_returns_failed(self) -> None: client = _mock_cfn_client( describe_side_effect=ConnectionError("Network unreachable") ) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) assert result.status == "failed" assert "Network unreachable" in result.error def test_empty_stacks_response_returns_failed(self) -> None: """describe_stacks returns empty Stacks list.""" client = MagicMock() client.describe_stacks.return_value = {"Stacks": []} result = asyncio.run(update_stack(client, "CL-AppPipe-ghost", TEMPLATE_URL)) assert result.status == "failed" assert "not found" in result.error class TestUpdateStackDuration: """Duration is always recorded.""" def test_duration_is_positive_on_success(self) -> None: client = _mock_cfn_client() result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) assert result.duration_seconds >= 0 def test_duration_is_positive_on_failure(self) -> None: client = _mock_cfn_client( update_side_effect=_make_client_error("InternalError", "boom") ) result = asyncio.run(update_stack(client, "CL-AppPipe-test", TEMPLATE_URL)) assert result.duration_seconds >= 0 # --------------------------------------------------------------------------- # Property-Based Tests (hypothesis) # --------------------------------------------------------------------------- import string from hypothesis import given, settings, strategies as st from cfn_updater.config import MAX_RETRIES, TEMPLATE_URL as CFG_TEMPLATE_URL from cfn_updater.models import DiscoveredStack from cfn_updater.updater import update_all_stacks # Reusable strategies ------------------------------------------------------- # Parameter keys: non-empty ASCII identifiers (CloudFormation keys are strings) _param_key = st.text( alphabet=string.ascii_letters + string.digits, min_size=1, max_size=30, ) # A list of unique parameter keys (1-20 keys) _param_keys = st.lists(_param_key, min_size=1, max_size=20, unique=True) # Stack name strategy _stack_name = st.text( alphabet=string.ascii_letters + string.digits + "-", min_size=1, max_size=40, ).map(lambda s: f"CL-AppPipe-{s}") # Feature: one-click-cfn-stack-updater, Property 4: Update call preserves existing parameters and uses correct template URL class TestPropertyParameterPreservation: """ **Validates: Requirements 3.1, 3.2** For any stack with any set of parameters, UpdateStack includes all parameter keys with UsePreviousValue=True and TemplateURL equals the configured TEMPLATE_URL. """ @settings(max_examples=100) @given(param_keys=_param_keys) def test_update_preserves_all_parameters_and_template_url(self, param_keys: list[str]) -> None: # Build mock describe_stacks response with the generated parameter keys params = [{"ParameterKey": k, "ParameterValue": "v"} for k in param_keys] client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "CL-AppPipe-test", "Parameters": params}] } client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/test"} result = asyncio.run(update_stack(client, "CL-AppPipe-test", CFG_TEMPLATE_URL)) assert result.status == "succeeded" call_kwargs = client.update_stack.call_args.kwargs # TemplateURL must equal the configured constant assert call_kwargs["TemplateURL"] == CFG_TEMPLATE_URL # Every generated key must appear with UsePreviousValue=True sent_params = call_kwargs["Parameters"] sent_keys = {p["ParameterKey"] for p in sent_params} assert sent_keys == set(param_keys) for p in sent_params: assert p["UsePreviousValue"] is True # Feature: one-click-cfn-stack-updater, Property 5: "No updates" response maps to no-update-needed status class TestPropertyNoUpdatesHandling: """ **Validates: Requirements 3.4** For any stack returning "No updates are to be performed", result status is no-update-needed. """ @settings(max_examples=100) @given(stack_name=_stack_name) def test_no_updates_response_maps_to_no_update_needed(self, stack_name: str) -> None: client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": stack_name, "Parameters": []}] } client.update_stack.side_effect = _make_client_error( "ValidationError", "No updates are to be performed" ) result = asyncio.run(update_stack(client, stack_name, CFG_TEMPLATE_URL)) assert result.stack_name == stack_name assert result.status == "no-update-needed" assert result.error is None # Feature: one-click-cfn-stack-updater, Property 2: All updatable stacks are attempted class TestPropertyAllUpdatableStacksAttempted: """ **Validates: Requirements 2.2** For any set of discovered stacks, exactly one result per updatable stack; no drops, no duplicates. """ @settings(max_examples=100) @given( stacks=st.lists( st.tuples( _stack_name, st.booleans(), # updatable flag ), min_size=1, max_size=20, unique_by=lambda t: t[0], ) ) @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_one_result_per_stack_no_drops_no_duplicates( self, mock_sleep, stacks: list[tuple[str, bool]] ) -> None: discovered = [ DiscoveredStack(name=name, status="CREATE_COMPLETE" if updatable else "DELETE_COMPLETE", updatable=updatable) for name, updatable in stacks ] client = MagicMock() # describe_stacks returns empty params for any stack client.describe_stacks.return_value = { "Stacks": [{"StackName": "x", "Parameters": []}] } client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"} results = asyncio.run(update_all_stacks(client, discovered, CFG_TEMPLATE_URL, concurrency=5)) # Exactly one result per input stack assert len(results) == len(discovered) # Result names match input names (same order) result_names = [r.stack_name for r in results] input_names = [s.name for s in discovered] assert result_names == input_names # Updatable stacks should not be skipped for stack, result in zip(discovered, results): if stack.updatable: assert result.status != "skipped" else: assert result.status == "skipped" # Feature: one-click-cfn-stack-updater, Property 3: Concurrency limit invariant class TestPropertyConcurrencyLimitInvariant: """ **Validates: Requirements 2.3, 3.3** For any positive concurrency limit and stack list, concurrent in-progress updates never exceed the limit. """ @settings(max_examples=100) @given( concurrency=st.integers(min_value=1, max_value=10), num_stacks=st.integers(min_value=1, max_value=30), ) @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_concurrent_updates_never_exceed_limit( self, mock_sleep, concurrency: int, num_stacks: int ) -> None: discovered = [ DiscoveredStack(name=f"CL-AppPipe-s{i}", status="CREATE_COMPLETE", updatable=True) for i in range(num_stacks) ] # Use threading primitives since the synchronous mock side_effect # runs on the event loop thread but we need atomic counter updates. import threading peak_concurrent = 0 current_concurrent = 0 counter_lock = threading.Lock() event_for_yield: list[asyncio.Event] = [] original_update_return = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"} client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "x", "Parameters": []}] } def _tracking_update(**kwargs): """Synchronous side_effect that tracks concurrency via describe_stacks calls.""" nonlocal peak_concurrent, current_concurrent with counter_lock: current_concurrent += 1 if current_concurrent > peak_concurrent: peak_concurrent = current_concurrent result = original_update_return with counter_lock: current_concurrent -= 1 return result client.update_stack.side_effect = _tracking_update results = asyncio.run( update_all_stacks(client, discovered, CFG_TEMPLATE_URL, concurrency=concurrency) ) assert len(results) == num_stacks assert peak_concurrent <= concurrency # Feature: one-click-cfn-stack-updater, Property 7: Fault isolation — failures do not block remaining stacks class TestPropertyFaultIsolation: """ **Validates: Requirements 4.1, 4.4** For any N updatable stacks where K fail, results are produced for all N stacks. """ @settings(max_examples=100) @given( stacks_and_failures=st.lists( st.tuples( _stack_name, st.booleans(), # True = will fail ), min_size=1, max_size=20, unique_by=lambda t: t[0], ) ) @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_all_stacks_get_results_even_when_some_fail( self, mock_sleep, stacks_and_failures: list[tuple[str, bool]] ) -> None: discovered = [ DiscoveredStack(name=name, status="CREATE_COMPLETE", updatable=True) for name, _ in stacks_and_failures ] # Build a set of stack names that should fail failing_names = {name for name, should_fail in stacks_and_failures if should_fail} client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "x", "Parameters": []}] } def _update_side_effect(**kwargs): stack_name = kwargs.get("StackName", "") if stack_name in failing_names: raise _make_client_error("InternalError", f"Simulated failure for {stack_name}") return {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"} client.update_stack.side_effect = _update_side_effect results = asyncio.run(update_all_stacks(client, discovered, CFG_TEMPLATE_URL, concurrency=5)) # Every input stack must have a result assert len(results) == len(discovered) result_names = {r.stack_name for r in results} input_names = {s.name for s in discovered} assert result_names == input_names # Feature: one-click-cfn-stack-updater, Property 8: Throttling triggers exponential backoff retries class TestPropertyExponentialBackoff: """ **Validates: Requirements 4.3** For any stack receiving throttling errors, retries up to MAX_RETRIES times. Delay between attempt i and i+1 is at least BASE_RETRY_DELAY * 2^i seconds. """ @settings(max_examples=100) @given( num_throttles=st.integers(min_value=1, max_value=MAX_RETRIES), ) @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_throttling_retries_with_correct_backoff_delays( self, mock_sleep, num_throttles: int ) -> None: # Build side effects: num_throttles throttling errors then success side_effects: list = [ _make_client_error("Throttling", "Rate exceeded") for _ in range(num_throttles) ] side_effects.append({"StackId": "arn:aws:cfn:us-east-1:123:stack/test"}) client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "CL-AppPipe-test", "Parameters": []}] } client.update_stack.side_effect = side_effects result = asyncio.run( update_stack(client, "CL-AppPipe-test", CFG_TEMPLATE_URL, max_retries=MAX_RETRIES) ) assert result.status == "succeeded" # update_stack called num_throttles + 1 times (retries + final success) assert client.update_stack.call_count == num_throttles + 1 # asyncio.sleep called once per throttle assert mock_sleep.call_count == num_throttles # Verify exponential backoff: delay_i >= BASE_RETRY_DELAY * 2^i for i, call in enumerate(mock_sleep.call_args_list): expected_delay = BASE_RETRY_DELAY * (2 ** i) actual_delay = call.args[0] assert actual_delay >= expected_delay, ( f"Attempt {i}: expected delay >= {expected_delay}, got {actual_delay}" ) # --------------------------------------------------------------------------- # Unit Tests for update_all_stacks() # Requirements: 3.1, 3.2, 3.4, 4.1, 4.2, 4.3, 4.4 # --------------------------------------------------------------------------- class TestUpdateAllStacksSuccess: """Req 2.2, 3.1: All updatable stacks are updated successfully.""" @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_all_updatable_stacks_succeed(self, mock_sleep) -> None: stacks = [ DiscoveredStack(name="CL-AppPipe-aaa", status="CREATE_COMPLETE", updatable=True), DiscoveredStack(name="CL-AppPipe-bbb", status="UPDATE_COMPLETE", updatable=True), DiscoveredStack(name="CL-AppPipe-ccc", status="CREATE_COMPLETE", updatable=True), ] client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "x", "Parameters": [{"ParameterKey": "P1", "ParameterValue": "v1"}]}] } client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"} results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=5)) assert len(results) == 3 assert all(r.status == "succeeded" for r in results) assert [r.stack_name for r in results] == ["CL-AppPipe-aaa", "CL-AppPipe-bbb", "CL-AppPipe-ccc"] assert client.update_stack.call_count == 3 class TestUpdateAllStacksMixed: """Req 4.2: Non-updatable stacks are skipped, updatable stacks are updated.""" @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_non_updatable_stacks_skipped(self, mock_sleep) -> None: stacks = [ DiscoveredStack(name="CL-AppPipe-ok1", status="CREATE_COMPLETE", updatable=True), DiscoveredStack(name="CL-AppPipe-rb", status="ROLLBACK_COMPLETE", updatable=False), DiscoveredStack(name="CL-AppPipe-ok2", status="UPDATE_COMPLETE", updatable=True), DiscoveredStack(name="CL-AppPipe-del", status="DELETE_COMPLETE", updatable=False), ] client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "x", "Parameters": []}] } client.update_stack.return_value = {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"} results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=5)) assert len(results) == 4 assert results[0].status == "succeeded" assert results[0].stack_name == "CL-AppPipe-ok1" assert results[1].status == "skipped" assert results[1].stack_name == "CL-AppPipe-rb" assert results[2].status == "succeeded" assert results[2].stack_name == "CL-AppPipe-ok2" assert results[3].status == "skipped" assert results[3].stack_name == "CL-AppPipe-del" # Only updatable stacks trigger update_stack calls assert client.update_stack.call_count == 2 class TestUpdateAllStacksFaultIsolation: """Req 4.1, 4.4: Failures in some stacks don't block others.""" @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_some_stacks_fail_others_succeed(self, mock_sleep) -> None: stacks = [ DiscoveredStack(name="CL-AppPipe-good1", status="CREATE_COMPLETE", updatable=True), DiscoveredStack(name="CL-AppPipe-bad", status="CREATE_COMPLETE", updatable=True), DiscoveredStack(name="CL-AppPipe-good2", status="CREATE_COMPLETE", updatable=True), ] client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "x", "Parameters": []}] } def _update_side_effect(**kwargs): if kwargs.get("StackName") == "CL-AppPipe-bad": raise _make_client_error("ValidationError", "Stack is in UPDATE_IN_PROGRESS state") return {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"} client.update_stack.side_effect = _update_side_effect results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=5)) assert len(results) == 3 assert results[0].status == "succeeded" assert results[1].status == "failed" assert "UPDATE_IN_PROGRESS" in results[1].error assert results[2].status == "succeeded" class TestUpdateAllStacksSequential: """Req 2.3: Concurrency=1 means sequential execution.""" @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_concurrency_one_runs_sequentially(self, mock_sleep) -> None: stacks = [ DiscoveredStack(name=f"CL-AppPipe-s{i}", status="CREATE_COMPLETE", updatable=True) for i in range(4) ] call_order: list[str] = [] client = MagicMock() client.describe_stacks.return_value = { "Stacks": [{"StackName": "x", "Parameters": []}] } def _tracking_update(**kwargs): call_order.append(kwargs["StackName"]) return {"StackId": "arn:aws:cfn:us-east-1:123:stack/x"} client.update_stack.side_effect = _tracking_update results = asyncio.run(update_all_stacks(client, stacks, TEMPLATE_URL, concurrency=1)) assert len(results) == 4 assert all(r.status == "succeeded" for r in results) # All stacks were called assert len(call_order) == 4 class TestUpdateAllStacksEmpty: """Req 1.4: Empty stack list produces no results and no errors.""" @patch("cfn_updater.updater.asyncio.sleep", return_value=None) def test_empty_stack_list(self, mock_sleep) -> None: client = MagicMock() results = asyncio.run(update_all_stacks(client, [], TEMPLATE_URL, concurrency=5)) assert results == [] client.describe_stacks.assert_not_called() client.update_stack.assert_not_called()