cfn-stack-updater/tests/test_report.py
Vijaya Manne 632ac9e328 Initial commit: One-Click CloudFormation Stack Updater
- Python CLI tool for rolling updates of CL-AppPipe-* and CL-SvcPipe-* stacks
- Async update engine with configurable concurrency (asyncio.Semaphore)
- Exponential backoff retry for API throttling
- Dry-run mode for safe preview
- IAM permission pre-validation
- Comprehensive test suite (80 tests: 11 property-based + 69 unit)
- Full spec documentation (requirements, design, tasks)
2026-05-29 14:56:59 -04:00

212 lines
8.1 KiB
Python

"""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