"""Tests for the report generator module. Property-based test: Property 9 (report aggregation and exit code correctness) Unit tests: Requirements 5.1, 5.2, 5.3, 5.4 """ from __future__ import annotations from datetime import datetime, timedelta import pytest from hypothesis import given, settings, strategies as st from cfn_updater.models import StackUpdateResult, UpdateRunReport from cfn_updater.report import format_report, generate_report, get_exit_code # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- _statuses = st.sampled_from(["succeeded", "failed", "skipped", "no-update-needed"]) _stack_update_result = st.builds( StackUpdateResult, stack_name=st.text(min_size=1, max_size=30).map(lambda s: f"CL-AppPipe-{s}"), status=_statuses, error=st.none(), duration_seconds=st.floats(min_value=0.0, max_value=300.0, allow_nan=False), ) _results_list = st.lists(_stack_update_result, min_size=0, max_size=50) # --------------------------------------------------------------------------- # Property-Based Test # --------------------------------------------------------------------------- # Feature: one-click-cfn-stack-updater, Property 9: Report aggregation and exit code correctness class TestPropertyReportAggregation: """ **Validates: Requirements 5.2, 5.3** For any list of StackUpdateResult, the generated report's succeeded, failed, skipped, and no_update_needed counts equal the actual counts of each status in the input list, and the exit code is non-zero if and only if failed > 0. """ @settings(max_examples=100) @given(results=_results_list) def test_report_counts_match_actual_and_exit_code_correct( self, results: list[StackUpdateResult] ) -> None: start = datetime(2024, 1, 1) end = datetime(2024, 1, 1, 1, 0, 0) total_found = len(results) + 5 # arbitrary; independent of counts report = generate_report(results, total_found, start, end) # Counts must match actual occurrences expected_succeeded = sum(1 for r in results if r.status == "succeeded") expected_failed = sum(1 for r in results if r.status == "failed") expected_skipped = sum(1 for r in results if r.status == "skipped") expected_no_update = sum(1 for r in results if r.status == "no-update-needed") assert report.succeeded == expected_succeeded assert report.failed == expected_failed assert report.skipped == expected_skipped assert report.no_update_needed == expected_no_update # Exit code: non-zero iff failed > 0 exit_code = get_exit_code(report) if expected_failed > 0: assert exit_code != 0 else: assert exit_code == 0 # --------------------------------------------------------------------------- # Unit Tests # --------------------------------------------------------------------------- _START = datetime(2024, 6, 15, 10, 0, 0) _END = datetime(2024, 6, 15, 10, 5, 30) class TestGenerateReportAllSuccess: """Req 5.2: All stacks succeed.""" def test_all_success_counts(self) -> None: results = [ StackUpdateResult(stack_name="CL-AppPipe-a", status="succeeded", duration_seconds=1.0), StackUpdateResult(stack_name="CL-AppPipe-b", status="succeeded", duration_seconds=2.0), StackUpdateResult(stack_name="CL-AppPipe-c", status="succeeded", duration_seconds=0.5), ] report = generate_report(results, total_found=3, start_time=_START, end_time=_END) assert report.succeeded == 3 assert report.failed == 0 assert report.skipped == 0 assert report.no_update_needed == 0 assert report.total_found == 3 assert len(report.results) == 3 def test_all_success_exit_code_zero(self) -> None: results = [ StackUpdateResult(stack_name="CL-AppPipe-x", status="succeeded"), ] report = generate_report(results, total_found=1, start_time=_START, end_time=_END) assert get_exit_code(report) == 0 class TestGenerateReportMixed: """Req 5.2, 5.3: Mixed results produce correct counts and non-zero exit.""" def test_mixed_results_counts(self) -> None: results = [ StackUpdateResult(stack_name="CL-AppPipe-ok", status="succeeded", duration_seconds=1.0), StackUpdateResult(stack_name="CL-AppPipe-fail", status="failed", error="boom", duration_seconds=2.0), StackUpdateResult(stack_name="CL-AppPipe-skip", status="skipped", duration_seconds=0.0), StackUpdateResult(stack_name="CL-AppPipe-noop", status="no-update-needed", duration_seconds=0.3), StackUpdateResult(stack_name="CL-AppPipe-ok2", status="succeeded", duration_seconds=1.5), ] report = generate_report(results, total_found=10, start_time=_START, end_time=_END) assert report.succeeded == 2 assert report.failed == 1 assert report.skipped == 1 assert report.no_update_needed == 1 assert report.total_found == 10 def test_mixed_results_exit_code_nonzero(self) -> None: results = [ StackUpdateResult(stack_name="CL-AppPipe-ok", status="succeeded"), StackUpdateResult(stack_name="CL-AppPipe-fail", status="failed", error="err"), ] report = generate_report(results, total_found=2, start_time=_START, end_time=_END) assert get_exit_code(report) == 1 class TestGenerateReportEmpty: """Req 5.2: Empty results list.""" def test_empty_results_all_counts_zero(self) -> None: report = generate_report([], total_found=0, start_time=_START, end_time=_END) assert report.succeeded == 0 assert report.failed == 0 assert report.skipped == 0 assert report.no_update_needed == 0 assert report.total_found == 0 assert report.results == [] def test_empty_results_exit_code_zero(self) -> None: report = generate_report([], total_found=0, start_time=_START, end_time=_END) assert get_exit_code(report) == 0 class TestFormatReport: """Req 5.1, 5.4: Formatted output contains expected fields.""" def test_format_contains_times(self) -> None: report = generate_report([], total_found=0, start_time=_START, end_time=_END) output = format_report(report) assert _START.isoformat() in output assert _END.isoformat() in output def test_format_contains_stack_names_and_statuses(self) -> None: results = [ StackUpdateResult(stack_name="CL-AppPipe-alpha", status="succeeded", duration_seconds=1.2), StackUpdateResult(stack_name="CL-AppPipe-beta", status="failed", error="timeout", duration_seconds=5.0), ] report = generate_report(results, total_found=2, start_time=_START, end_time=_END) output = format_report(report) assert "CL-AppPipe-alpha" in output assert "succeeded" in output assert "CL-AppPipe-beta" in output assert "failed" in output assert "timeout" in output def test_format_contains_summary_counts(self) -> None: results = [ StackUpdateResult(stack_name="CL-AppPipe-a", status="succeeded"), StackUpdateResult(stack_name="CL-AppPipe-b", status="skipped"), StackUpdateResult(stack_name="CL-AppPipe-c", status="no-update-needed"), ] report = generate_report(results, total_found=5, start_time=_START, end_time=_END) output = format_report(report) assert "Total Found: 5" in output assert "Succeeded : 1" in output assert "Failed : 0" in output assert "Skipped : 1" in output assert "No Update Needed: 1" in output def test_format_contains_duration(self) -> None: results = [ StackUpdateResult(stack_name="CL-AppPipe-d", status="succeeded", duration_seconds=3.7), ] report = generate_report(results, total_found=1, start_time=_START, end_time=_END) output = format_report(report) assert "3.7s" in output