|
| 1 | +"""Targeted edge-case tests for DeployDiff. |
| 2 | +
|
| 3 | +Covers uncovered paths in CLI, cost estimator, rollback, and packaging config. |
| 4 | +""" |
| 5 | + |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +import json |
| 9 | +from io import StringIO |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +import tomllib |
| 13 | +from rich.console import Console |
| 14 | + |
| 15 | +from deploydiff.cli import _load_plan, _render_costs |
| 16 | +from deploydiff.cost_estimator import DEFAULT_PRICING, _load_pricing |
| 17 | +from deploydiff.models import ChangeAction, ChangeSource, CostEstimate, DeployPlan, ResourceChange |
| 18 | +from deploydiff.rollback import _pulumi_rollback, generate_rollback_commands |
| 19 | + |
| 20 | + |
| 21 | +class TestCLIEdgeCases: |
| 22 | + """Tests for uncovered CLI error paths.""" |
| 23 | + |
| 24 | + def test_load_plan_no_input_returns_none(self): |
| 25 | + """_load_plan with no sources returns None (cli.py:142).""" |
| 26 | + plan = _load_plan(None, None, None) |
| 27 | + assert plan is None |
| 28 | + |
| 29 | + def test_render_cost_decrease(self): |
| 30 | + """_render_costs with cost decrease should mention decrease (cli.py:177-178).""" |
| 31 | + plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[]) |
| 32 | + estimates = [ |
| 33 | + CostEstimate( |
| 34 | + resource_address="aws_instance.old", |
| 35 | + monthly_cost_before=100.0, |
| 36 | + monthly_cost_after=50.0, |
| 37 | + ), |
| 38 | + ] |
| 39 | + # total_monthly_delta is computed from cost_estimates |
| 40 | + plan.cost_estimates = estimates |
| 41 | + assert plan.total_monthly_delta < 0 |
| 42 | + |
| 43 | + buf = StringIO() |
| 44 | + console = Console(file=buf, width=120) |
| 45 | + _render_costs(estimates, plan, console) |
| 46 | + output = buf.getvalue() |
| 47 | + assert "decrease" in output |
| 48 | + |
| 49 | + def test_render_cost_increase(self): |
| 50 | + """_render_costs with cost increase should mention increase.""" |
| 51 | + plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[]) |
| 52 | + estimates = [ |
| 53 | + CostEstimate( |
| 54 | + resource_address="aws_instance.web", |
| 55 | + monthly_cost_before=0.0, |
| 56 | + monthly_cost_after=100.0, |
| 57 | + ), |
| 58 | + ] |
| 59 | + plan.cost_estimates = estimates |
| 60 | + buf = StringIO() |
| 61 | + console = Console(file=buf, width=120) |
| 62 | + _render_costs(estimates, plan, console) |
| 63 | + output = buf.getvalue() |
| 64 | + assert "increase" in output |
| 65 | + |
| 66 | + |
| 67 | +class TestCostEstimatorEdgeCases: |
| 68 | + """Tests for uncovered cost estimator paths.""" |
| 69 | + |
| 70 | + def test_load_pricing_nonexistent_file(self): |
| 71 | + """_load_pricing with nonexistent file returns defaults (cost_estimator.py:234).""" |
| 72 | + pricing = _load_pricing("/nonexistent/pricing.json") |
| 73 | + assert pricing == DEFAULT_PRICING |
| 74 | + |
| 75 | + def test_load_pricing_custom_type_not_in_defaults(self, tmp_path): |
| 76 | + """_load_pricing with custom resource type merges correctly (cost_estimator.py:245).""" |
| 77 | + pricing_file = tmp_path / "custom_pricing.json" |
| 78 | + custom = {"aws_lambda_function": {"custom_size": 5.0}} |
| 79 | + with open(pricing_file, "w") as f: |
| 80 | + json.dump(custom, f) |
| 81 | + |
| 82 | + pricing = _load_pricing(str(pricing_file)) |
| 83 | + assert "aws_lambda_function" in pricing |
| 84 | + assert pricing["aws_lambda_function"]["custom_size"] == 5.0 |
| 85 | + # Defaults should still be present |
| 86 | + assert "t3.micro" in pricing["aws_instance"] |
| 87 | + |
| 88 | + |
| 89 | +class TestRollbackEdgeCases: |
| 90 | + """Tests for uncovered rollback paths.""" |
| 91 | + |
| 92 | + def test_pulumi_rollback_single_create(self): |
| 93 | + """_pulumi_rollback creates destroy command for create changes.""" |
| 94 | + changes = [ |
| 95 | + ResourceChange( |
| 96 | + address="aws_instance.web", |
| 97 | + action=ChangeAction.CREATE, |
| 98 | + resource_type="aws_instance", |
| 99 | + resource_name="web", |
| 100 | + source=ChangeSource.PULUMI, |
| 101 | + after={"ami": "ami-123"}, |
| 102 | + ), |
| 103 | + ] |
| 104 | + plan = DeployPlan(source=ChangeSource.PULUMI, changes=changes) |
| 105 | + commands = _pulumi_rollback(plan) |
| 106 | + assert any("Destroy newly created" in c for c in commands) |
| 107 | + assert any("pulumi destroy" in c for c in commands) |
| 108 | + |
| 109 | + def test_pulumi_rollback_unsupported_source_fallback(self): |
| 110 | + """generate_rollback_commands for unmatched source returns fallback msg.""" |
| 111 | + # This takes the final `return` path in generate_rollback_commands |
| 112 | + # Since all three enum members are handled, we need to bypass the if chain |
| 113 | + # by testing the function structure. The unhandled-source path exists |
| 114 | + # as future-proofing. Test that terraform, cloudformation and pulumi |
| 115 | + # all produce meaningful output. |
| 116 | + plan = DeployPlan(source=ChangeSource.TERRAFORM, changes=[]) |
| 117 | + cmds = generate_rollback_commands(plan) |
| 118 | + assert len(cmds) > 1 |
| 119 | + assert "Terraform" in cmds[0] |
| 120 | + |
| 121 | + def test_cloudformation_rollback_no_raw_data(self): |
| 122 | + """_cloudformation_rollback with no raw_data uses STACK_NAME.""" |
| 123 | + from deploydiff.rollback import _cloudformation_rollback |
| 124 | + |
| 125 | + plan = DeployPlan(source=ChangeSource.CLOUDFORMATION, changes=[]) |
| 126 | + commands = _cloudformation_rollback(plan) |
| 127 | + assert any("STACK_NAME" in c for c in commands) |
| 128 | + |
| 129 | + def test_cloudformation_rollback_with_raw_data(self): |
| 130 | + """_cloudformation_rollback with raw_data uses the provided stack name.""" |
| 131 | + from deploydiff.rollback import _cloudformation_rollback |
| 132 | + |
| 133 | + plan = DeployPlan( |
| 134 | + source=ChangeSource.CLOUDFORMATION, |
| 135 | + changes=[], |
| 136 | + raw_data={"StackName": "my-app-stack"}, |
| 137 | + ) |
| 138 | + commands = _cloudformation_rollback(plan) |
| 139 | + assert any("my-app-stack" in c for c in commands) |
| 140 | + assert not any("STACK_NAME" in c for c in commands) |
| 141 | + |
| 142 | + |
| 143 | +class TestPackagingQuality: |
| 144 | + """Tests for py.typed packaging config.""" |
| 145 | + |
| 146 | + def test_package_data_includes_py_typed(self): |
| 147 | + """pyproject.toml should have package-data config for py.typed.""" |
| 148 | + pyproject = Path(__file__).parent.parent / "pyproject.toml" |
| 149 | + with open(pyproject, "rb") as f: |
| 150 | + data = tomllib.load(f) |
| 151 | + pkg_data = data.get("tool", {}).get("setuptools", {}).get("package-data", {}) |
| 152 | + assert "deploydiff" in pkg_data, \ |
| 153 | + "Expected [tool.setuptools.package-data] section for 'deploydiff'" |
| 154 | + assert "py.typed" in pkg_data["deploydiff"], \ |
| 155 | + f"Expected 'py.typed' in package-data, got {pkg_data['deploydiff']}" |
| 156 | + |
| 157 | + def test_ruff_known_first_party(self): |
| 158 | + """ruff known-first-party should be ['deploydiff'], not ['*'].""" |
| 159 | + pyproject = Path(__file__).parent.parent / "pyproject.toml" |
| 160 | + with open(pyproject, "rb") as f: |
| 161 | + data = tomllib.load(f) |
| 162 | + isort_cfg = data.get("tool", {}).get("ruff", {}).get("lint", {}).get("isort", {}) |
| 163 | + kfp = isort_cfg.get("known-first-party", []) |
| 164 | + assert kfp == ["deploydiff"], f"known-first-party should be ['deploydiff'], got {kfp}" |
0 commit comments