- 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)
212 lines
8.1 KiB
Python
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
|