Files
SnarfCode/tests/unit/test_change_detector.py
2026-05-22 00:19:30 -04:00

336 lines
13 KiB
Python

"""Unit tests for the ChangeDetector class."""
import pytest
from iac_reverse.incremental.change_detector import ChangeDetector
from iac_reverse.models import (
ChangeSummary,
ChangeType,
CpuArchitecture,
DiscoveredResource,
PlatformCategory,
ProviderType,
ScanResult,
)
def _make_resource(
unique_id: str = "res-1",
name: str = "test-resource",
resource_type: str = "kubernetes_deployment",
attributes: dict | None = None,
) -> DiscoveredResource:
"""Create a sample DiscoveredResource for testing."""
return DiscoveredResource(
resource_type=resource_type,
unique_id=unique_id,
name=name,
provider=ProviderType.KUBERNETES,
platform_category=PlatformCategory.CONTAINER_ORCHESTRATION,
architecture=CpuArchitecture.AMD64,
endpoint="https://k8s-api.internal.lab:6443",
attributes=attributes if attributes is not None else {"replicas": 3},
)
def _make_scan_result(
resources: list[DiscoveredResource] | None = None,
) -> ScanResult:
"""Create a sample ScanResult for testing."""
return ScanResult(
resources=resources if resources is not None else [],
warnings=[],
errors=[],
scan_timestamp="2024-01-15T10:30:00Z",
profile_hash="abc123",
)
class TestNoChanges:
"""Tests for identical scans producing no changes."""
def test_identical_scans_produce_no_changes(self) -> None:
"""Comparing identical scans returns empty change summary."""
resource = _make_resource(unique_id="res-1", attributes={"replicas": 3})
current = _make_scan_result(resources=[resource])
previous = _make_scan_result(resources=[resource])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.added_count == 0
assert summary.removed_count == 0
assert summary.modified_count == 0
assert summary.changes == []
def test_empty_scans_produce_no_changes(self) -> None:
"""Comparing two empty scans returns empty change summary."""
current = _make_scan_result(resources=[])
previous = _make_scan_result(resources=[])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.added_count == 0
assert summary.removed_count == 0
assert summary.modified_count == 0
assert summary.changes == []
class TestAddedResources:
"""Tests for detecting added resources."""
def test_new_resource_detected_as_added(self) -> None:
"""A resource in current but not in previous is classified as ADDED."""
resource = _make_resource(unique_id="new-res", name="new-service")
current = _make_scan_result(resources=[resource])
previous = _make_scan_result(resources=[])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.added_count == 1
assert summary.removed_count == 0
assert summary.modified_count == 0
assert len(summary.changes) == 1
change = summary.changes[0]
assert change.resource_id == "new-res"
assert change.resource_name == "new-service"
assert change.change_type == ChangeType.ADDED
assert change.changed_attributes is None
def test_multiple_added_resources(self) -> None:
"""Multiple new resources are all classified as ADDED."""
res1 = _make_resource(unique_id="res-1", name="service-1")
res2 = _make_resource(unique_id="res-2", name="service-2")
current = _make_scan_result(resources=[res1, res2])
previous = _make_scan_result(resources=[])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.added_count == 2
added_ids = {c.resource_id for c in summary.changes}
assert added_ids == {"res-1", "res-2"}
class TestRemovedResources:
"""Tests for detecting removed resources."""
def test_missing_resource_detected_as_removed(self) -> None:
"""A resource in previous but not in current is classified as REMOVED."""
resource = _make_resource(unique_id="old-res", name="old-service")
current = _make_scan_result(resources=[])
previous = _make_scan_result(resources=[resource])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.added_count == 0
assert summary.removed_count == 1
assert summary.modified_count == 0
assert len(summary.changes) == 1
change = summary.changes[0]
assert change.resource_id == "old-res"
assert change.resource_name == "old-service"
assert change.change_type == ChangeType.REMOVED
assert change.changed_attributes is None
def test_multiple_removed_resources(self) -> None:
"""Multiple missing resources are all classified as REMOVED."""
res1 = _make_resource(unique_id="res-1", name="service-1")
res2 = _make_resource(unique_id="res-2", name="service-2")
current = _make_scan_result(resources=[])
previous = _make_scan_result(resources=[res1, res2])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.removed_count == 2
removed_ids = {c.resource_id for c in summary.changes}
assert removed_ids == {"res-1", "res-2"}
class TestModifiedResources:
"""Tests for detecting modified resources."""
def test_changed_attributes_detected_as_modified(self) -> None:
"""A resource with changed attributes is classified as MODIFIED."""
prev_resource = _make_resource(
unique_id="res-1", attributes={"replicas": 3, "image": "nginx:1.24"}
)
curr_resource = _make_resource(
unique_id="res-1", attributes={"replicas": 5, "image": "nginx:1.24"}
)
current = _make_scan_result(resources=[curr_resource])
previous = _make_scan_result(resources=[prev_resource])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.added_count == 0
assert summary.removed_count == 0
assert summary.modified_count == 1
assert len(summary.changes) == 1
change = summary.changes[0]
assert change.resource_id == "res-1"
assert change.change_type == ChangeType.MODIFIED
assert change.changed_attributes == {"replicas": {"old": 3, "new": 5}}
def test_added_attribute_detected_as_modified(self) -> None:
"""A resource with a new attribute key is classified as MODIFIED."""
prev_resource = _make_resource(
unique_id="res-1", attributes={"replicas": 3}
)
curr_resource = _make_resource(
unique_id="res-1", attributes={"replicas": 3, "image": "nginx:1.25"}
)
current = _make_scan_result(resources=[curr_resource])
previous = _make_scan_result(resources=[prev_resource])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.modified_count == 1
change = summary.changes[0]
assert change.changed_attributes == {
"image": {"old": None, "new": "nginx:1.25"}
}
def test_removed_attribute_detected_as_modified(self) -> None:
"""A resource with a removed attribute key is classified as MODIFIED."""
prev_resource = _make_resource(
unique_id="res-1", attributes={"replicas": 3, "image": "nginx:1.25"}
)
curr_resource = _make_resource(
unique_id="res-1", attributes={"replicas": 3}
)
current = _make_scan_result(resources=[curr_resource])
previous = _make_scan_result(resources=[prev_resource])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.modified_count == 1
change = summary.changes[0]
assert change.changed_attributes == {
"image": {"old": "nginx:1.25", "new": None}
}
class TestMixedChanges:
"""Tests for scans with a mix of added, removed, and modified resources."""
def test_mixed_changes_detected_correctly(self) -> None:
"""A scan with added, removed, and modified resources is classified correctly."""
# Shared resource (modified)
prev_shared = _make_resource(
unique_id="shared", name="shared-svc", attributes={"replicas": 2}
)
curr_shared = _make_resource(
unique_id="shared", name="shared-svc", attributes={"replicas": 4}
)
# Removed resource
removed = _make_resource(unique_id="old-res", name="old-svc")
# Added resource
added = _make_resource(unique_id="new-res", name="new-svc")
previous = _make_scan_result(resources=[prev_shared, removed])
current = _make_scan_result(resources=[curr_shared, added])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.added_count == 1
assert summary.removed_count == 1
assert summary.modified_count == 1
assert len(summary.changes) == 3
change_map = {c.resource_id: c for c in summary.changes}
assert change_map["new-res"].change_type == ChangeType.ADDED
assert change_map["old-res"].change_type == ChangeType.REMOVED
assert change_map["shared"].change_type == ChangeType.MODIFIED
class TestFirstScan:
"""Tests for first scan (no previous snapshot)."""
def test_first_scan_treats_all_as_added(self) -> None:
"""When previous is None, all current resources are classified as ADDED."""
res1 = _make_resource(unique_id="res-1", name="service-1")
res2 = _make_resource(unique_id="res-2", name="service-2")
current = _make_scan_result(resources=[res1, res2])
detector = ChangeDetector()
summary = detector.compare(current, previous=None)
assert summary.added_count == 2
assert summary.removed_count == 0
assert summary.modified_count == 0
assert len(summary.changes) == 2
assert all(c.change_type == ChangeType.ADDED for c in summary.changes)
def test_first_scan_empty_produces_empty_summary(self) -> None:
"""First scan with no resources produces empty change summary."""
current = _make_scan_result(resources=[])
detector = ChangeDetector()
summary = detector.compare(current, previous=None)
assert summary.added_count == 0
assert summary.removed_count == 0
assert summary.modified_count == 0
assert summary.changes == []
class TestChangeSummaryCounts:
"""Tests that ChangeSummary counts are always correct."""
def test_counts_match_change_list(self) -> None:
"""The counts in ChangeSummary always match the actual changes list."""
# Set up a scenario with 2 added, 1 removed, 1 modified
prev_mod = _make_resource(
unique_id="mod-1", name="mod-svc", attributes={"port": 80}
)
curr_mod = _make_resource(
unique_id="mod-1", name="mod-svc", attributes={"port": 8080}
)
removed = _make_resource(unique_id="rem-1", name="rem-svc")
added1 = _make_resource(unique_id="add-1", name="add-svc-1")
added2 = _make_resource(unique_id="add-2", name="add-svc-2")
previous = _make_scan_result(resources=[prev_mod, removed])
current = _make_scan_result(resources=[curr_mod, added1, added2])
detector = ChangeDetector()
summary = detector.compare(current, previous)
# Verify counts match actual changes
actual_added = [c for c in summary.changes if c.change_type == ChangeType.ADDED]
actual_removed = [c for c in summary.changes if c.change_type == ChangeType.REMOVED]
actual_modified = [c for c in summary.changes if c.change_type == ChangeType.MODIFIED]
assert summary.added_count == len(actual_added) == 2
assert summary.removed_count == len(actual_removed) == 1
assert summary.modified_count == len(actual_modified) == 1
def test_resource_type_preserved_in_changes(self) -> None:
"""ResourceChange objects preserve the resource_type from the resource."""
resource = _make_resource(
unique_id="res-1",
resource_type="docker_service",
name="my-service",
)
current = _make_scan_result(resources=[resource])
previous = _make_scan_result(resources=[])
detector = ChangeDetector()
summary = detector.compare(current, previous)
assert summary.changes[0].resource_type == "docker_service"