BoilerWorks

boilerworks / tests / test_generator.py
Source Blame History 342 lines
0cb4a5e… anonymous 1 """Tests for boilerworks.generator."""
0cb4a5e… anonymous 2
0cb4a5e… anonymous 3 from __future__ import annotations
0cb4a5e… anonymous 4
0cb4a5e… anonymous 5 import shutil
0cb4a5e… anonymous 6 from pathlib import Path
0cb4a5e… anonymous 7 from unittest.mock import MagicMock, patch
0cb4a5e… anonymous 8
0cb4a5e… anonymous 9 import pytest
0cb4a5e… anonymous 10
0cb4a5e… anonymous 11 from boilerworks.generator import (
0cb4a5e… anonymous 12 _clone_and_render_ops,
0cb4a5e… anonymous 13 _dry_run_plan,
0cb4a5e… anonymous 14 _write_ops_config,
0cb4a5e… anonymous 15 generate_from_manifest,
0cb4a5e… anonymous 16 )
0cb4a5e… anonymous 17 from boilerworks.manifest import BoilerworksManifest
0cb4a5e… anonymous 18
0cb4a5e… anonymous 19
0cb4a5e… anonymous 20 class TestDryRun:
0cb4a5e… anonymous 21 def test_dry_run_no_files_created(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
0cb4a5e… anonymous 22 """Dry-run should print the plan without touching the filesystem."""
0cb4a5e… anonymous 23 project_dir = tmp_path / valid_manifest.project
0cb4a5e… anonymous 24 _dry_run_plan(valid_manifest, tmp_path)
0cb4a5e… anonymous 25 assert not project_dir.exists()
0cb4a5e… anonymous 26
0cb4a5e… anonymous 27 def test_generate_from_manifest_dry_run(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
0cb4a5e… anonymous 28 """generate_from_manifest with dry_run=True must not create any files."""
0cb4a5e… anonymous 29 manifest_file = tmp_path / "boilerworks.yaml"
0cb4a5e… anonymous 30 valid_manifest.to_file(manifest_file)
0cb4a5e… anonymous 31
0cb4a5e… anonymous 32 project_dir = tmp_path / valid_manifest.project
0cb4a5e… anonymous 33 generate_from_manifest(
0cb4a5e… anonymous 34 manifest_path=str(manifest_file),
0cb4a5e… anonymous 35 output_dir=str(tmp_path),
0cb4a5e… anonymous 36 dry_run=True,
0cb4a5e… anonymous 37 )
0cb4a5e… anonymous 38 assert not project_dir.exists()
0cb4a5e… anonymous 39
0cb4a5e… anonymous 40 def test_dry_run_with_ops_standard(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 41 """Dry-run with cloud + ops shows ops clone step (standard topology)."""
0cb4a5e… anonymous 42 manifest = BoilerworksManifest(
0cb4a5e… anonymous 43 project="test-app",
0cb4a5e… anonymous 44 family="django-nextjs",
0cb4a5e… anonymous 45 size="full",
0cb4a5e… anonymous 46 cloud="aws",
0cb4a5e… anonymous 47 ops=True,
0cb4a5e… anonymous 48 topology="standard",
0cb4a5e… anonymous 49 )
0cb4a5e… anonymous 50 _dry_run_plan(manifest, tmp_path) # should not raise
0cb4a5e… anonymous 51
0cb4a5e… anonymous 52 def test_dry_run_with_ops_omni(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 53 """Dry-run with cloud + ops shows ops clone step (omni topology)."""
0cb4a5e… anonymous 54 manifest = BoilerworksManifest(
0cb4a5e… anonymous 55 project="test-app",
0cb4a5e… anonymous 56 family="django-nextjs",
0cb4a5e… anonymous 57 size="full",
0cb4a5e… anonymous 58 cloud="gcp",
0cb4a5e… anonymous 59 ops=True,
0cb4a5e… anonymous 60 topology="omni",
0cb4a5e… anonymous 61 )
0cb4a5e… anonymous 62 _dry_run_plan(manifest, tmp_path) # should not raise
0cb4a5e… anonymous 63
0cb4a5e… anonymous 64 def test_dry_run_no_ops_when_flag_false(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 65 """Dry-run with cloud set but ops=False does not include ops steps."""
0cb4a5e… anonymous 66 manifest = BoilerworksManifest(
0cb4a5e… anonymous 67 project="test-app",
0cb4a5e… anonymous 68 family="django-nextjs",
0cb4a5e… anonymous 69 size="full",
0cb4a5e… anonymous 70 cloud="aws",
0cb4a5e… anonymous 71 ops=False,
0cb4a5e… anonymous 72 )
0cb4a5e… anonymous 73 _dry_run_plan(manifest, tmp_path) # should not raise
0cb4a5e… anonymous 74
0cb4a5e… anonymous 75 def test_dry_run_shows_mobile_step(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 76 manifest = BoilerworksManifest(
0cb4a5e… anonymous 77 project="test-app",
0cb4a5e… anonymous 78 family="django-nextjs",
0cb4a5e… anonymous 79 size="full",
0cb4a5e… anonymous 80 mobile=True,
0cb4a5e… anonymous 81 )
0cb4a5e… anonymous 82 _dry_run_plan(manifest, tmp_path)
0cb4a5e… anonymous 83
0cb4a5e… anonymous 84
0cb4a5e… anonymous 85 class TestWriteOpsConfig:
0cb4a5e… anonymous 86 def _make_ops_dir(self, tmp_path: Path, cloud: str) -> Path:
0cb4a5e… anonymous 87 ops_dir = tmp_path / "ops"
0cb4a5e… anonymous 88 cloud_dir = ops_dir / cloud
0cb4a5e… anonymous 89 cloud_dir.mkdir(parents=True)
0cb4a5e… anonymous 90 config = cloud_dir / "config.env"
0cb4a5e… anonymous 91 config.write_text('PROJECT="boilerworks"\nAWS_REGION="us-west-2"\nOWNER="conflict"\n')
0cb4a5e… anonymous 92 return ops_dir
0cb4a5e… anonymous 93
0cb4a5e… anonymous 94 def test_aws_config_written(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 95 ops_dir = self._make_ops_dir(tmp_path, "aws")
0cb4a5e… anonymous 96 _write_ops_config(ops_dir, "aws", "myproject", "eu-west-1", "myproject.com")
0cb4a5e… anonymous 97
0cb4a5e… anonymous 98 content = (ops_dir / "aws" / "config.env").read_text()
0cb4a5e… anonymous 99 assert 'PROJECT="myproject"' in content
0cb4a5e… anonymous 100 assert 'AWS_REGION="eu-west-1"' in content
0cb4a5e… anonymous 101 assert 'OWNER="myproject"' in content
0cb4a5e… anonymous 102 assert 'DOMAIN="myproject.com"' in content
0cb4a5e… anonymous 103
0cb4a5e… anonymous 104 def test_gcp_config_written(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 105 ops_dir = self._make_ops_dir(tmp_path, "gcp")
0cb4a5e… anonymous 106 _write_ops_config(ops_dir, "gcp", "myproject", "us-central1", None)
0cb4a5e… anonymous 107
0cb4a5e… anonymous 108 content = (ops_dir / "gcp" / "config.env").read_text()
0cb4a5e… anonymous 109 assert 'PROJECT="myproject"' in content
0cb4a5e… anonymous 110 assert 'GCP_REGION="us-central1"' in content
0cb4a5e… anonymous 111 assert "DOMAIN" not in content
0cb4a5e… anonymous 112
0cb4a5e… anonymous 113 def test_azure_config_default_region(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 114 ops_dir = self._make_ops_dir(tmp_path, "azure")
0cb4a5e… anonymous 115 _write_ops_config(ops_dir, "azure", "myproject", None, None)
0cb4a5e… anonymous 116
0cb4a5e… anonymous 117 content = (ops_dir / "azure" / "config.env").read_text()
0cb4a5e… anonymous 118 assert 'AZURE_REGION="eastus"' in content
0cb4a5e… anonymous 119
0cb4a5e… anonymous 120 def test_missing_config_file_is_noop(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 121 """If config.env doesn't exist yet, write_ops_config should not raise."""
0cb4a5e… anonymous 122 ops_dir = tmp_path / "ops"
0cb4a5e… anonymous 123 ops_dir.mkdir()
0cb4a5e… anonymous 124 (ops_dir / "aws").mkdir()
0cb4a5e… anonymous 125 # No config.env file
0cb4a5e… anonymous 126 _write_ops_config(ops_dir, "aws", "myproject", "us-east-1", None)
0cb4a5e… anonymous 127
0cb4a5e… anonymous 128
0cb4a5e… anonymous 129 class TestCloneAndRenderOps:
0cb4a5e… anonymous 130 def _fake_clone(self, src: Path) -> None:
0cb4a5e… anonymous 131 """Create a fake clone that looks like a minimal boilerworks-opscode."""
0cb4a5e… anonymous 132 src.mkdir(parents=True, exist_ok=True)
0cb4a5e… anonymous 133 (src / ".git").mkdir()
0cb4a5e… anonymous 134 (src / "aws").mkdir()
0cb4a5e… anonymous 135 (src / "aws" / "config.env").write_text('PROJECT="boilerworks"\nAWS_REGION="us-west-2"\nOWNER="conflict"\n')
0cb4a5e… anonymous 136 (src / "README.md").write_text("# Boilerworks Opscode\nBoilerworks infrastructure.\n")
0cb4a5e… anonymous 137
0cb4a5e… anonymous 138 def test_ops_clone_and_render_standard(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 139 """_clone_and_render_ops populates dest and renders project name."""
0cb4a5e… anonymous 140 ops_dest = tmp_path / "myproject-ops"
0cb4a5e… anonymous 141
0cb4a5e… anonymous 142 def fake_clone(repo: str, dest: Path) -> None:
0cb4a5e… anonymous 143 self._fake_clone(dest)
0cb4a5e… anonymous 144
0cb4a5e… anonymous 145 progress = MagicMock()
0cb4a5e… anonymous 146 progress.add_task.return_value = "task-id"
0cb4a5e… anonymous 147
0cb4a5e… anonymous 148 with patch("boilerworks.generator._clone_repo", side_effect=fake_clone):
0cb4a5e… anonymous 149 _clone_and_render_ops("myproject", "aws", "us-east-1", "myproject.com", ops_dest, progress)
0cb4a5e… anonymous 150
0cb4a5e… anonymous 151 assert ops_dest.exists()
0cb4a5e… anonymous 152 assert not (ops_dest / ".git").exists()
0cb4a5e… anonymous 153 config_content = (ops_dest / "aws" / "config.env").read_text()
0cb4a5e… anonymous 154 assert 'PROJECT="myproject"' in config_content
0cb4a5e… anonymous 155
0cb4a5e… anonymous 156 def test_ops_clone_failure_exits(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 157 """When cloning ops fails, process exits."""
0cb4a5e… anonymous 158 ops_dest = tmp_path / "myproject-ops"
0cb4a5e… anonymous 159 progress = MagicMock()
0cb4a5e… anonymous 160 progress.add_task.return_value = "task-id"
0cb4a5e… anonymous 161
fb274c9… anonymous 162 with (
fb274c9… anonymous 163 patch("boilerworks.generator._clone_repo", side_effect=RuntimeError("clone failed")),
fb274c9… anonymous 164 pytest.raises(SystemExit),
fb274c9… anonymous 165 ):
fb274c9… anonymous 166 _clone_and_render_ops("myproject", "aws", "us-east-1", None, ops_dest, progress)
0cb4a5e… anonymous 167
0cb4a5e… anonymous 168
0cb4a5e… anonymous 169 class TestGenerateFromManifestErrors:
0cb4a5e… anonymous 170 def test_missing_manifest_exits(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 171 with pytest.raises(SystemExit):
0cb4a5e… anonymous 172 generate_from_manifest(
0cb4a5e… anonymous 173 manifest_path=str(tmp_path / "nonexistent.yaml"),
0cb4a5e… anonymous 174 output_dir=str(tmp_path),
0cb4a5e… anonymous 175 )
0cb4a5e… anonymous 176
0cb4a5e… anonymous 177 def test_invalid_manifest_yaml_exits(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 178 bad_yaml = tmp_path / "boilerworks.yaml"
0cb4a5e… anonymous 179 bad_yaml.write_text("project: Invalid Name With Spaces\nfamily: django-nextjs\nsize: full\n")
0cb4a5e… anonymous 180 with pytest.raises(SystemExit):
0cb4a5e… anonymous 181 generate_from_manifest(
0cb4a5e… anonymous 182 manifest_path=str(bad_yaml),
0cb4a5e… anonymous 183 output_dir=str(tmp_path),
0cb4a5e… anonymous 184 )
0cb4a5e… anonymous 185
0cb4a5e… anonymous 186 def test_existing_project_dir_exits(self, tmp_path: Path, valid_manifest: BoilerworksManifest) -> None:
0cb4a5e… anonymous 187 manifest_file = tmp_path / "boilerworks.yaml"
0cb4a5e… anonymous 188 valid_manifest.to_file(manifest_file)
0cb4a5e… anonymous 189
0cb4a5e… anonymous 190 # Pre-create the project dir
0cb4a5e… anonymous 191 (tmp_path / valid_manifest.project).mkdir()
0cb4a5e… anonymous 192
0cb4a5e… anonymous 193 with pytest.raises(SystemExit):
0cb4a5e… anonymous 194 generate_from_manifest(
0cb4a5e… anonymous 195 manifest_path=str(manifest_file),
0cb4a5e… anonymous 196 output_dir=str(tmp_path),
0cb4a5e… anonymous 197 )
0cb4a5e… anonymous 198
0cb4a5e… anonymous 199
0cb4a5e… anonymous 200 class TestGenerateWithOps:
0cb4a5e… anonymous 201 """Integration-style tests using mocked git operations."""
0cb4a5e… anonymous 202
0cb4a5e… anonymous 203 def _seed_template(self, dest: Path, project: str = "boilerworks") -> None:
0cb4a5e… anonymous 204 """Create a minimal template directory (simulates cloned repo)."""
0cb4a5e… anonymous 205 dest.mkdir(parents=True, exist_ok=True)
0cb4a5e… anonymous 206 (dest / ".git").mkdir()
0cb4a5e… anonymous 207 (dest / "README.md").write_text(f"# {project.title()}\nA {project} app.\n")
0cb4a5e… anonymous 208 (dest / "docker-compose.yaml").write_text(f"services:\n db:\n image: postgres\n # {project}\n")
0cb4a5e… anonymous 209
0cb4a5e… anonymous 210 def _seed_opscode(self, dest: Path) -> None:
0cb4a5e… anonymous 211 dest.mkdir(parents=True, exist_ok=True)
0cb4a5e… anonymous 212 (dest / ".git").mkdir()
0cb4a5e… anonymous 213 (dest / "aws").mkdir()
0cb4a5e… anonymous 214 (dest / "aws" / "config.env").write_text('PROJECT="boilerworks"\nAWS_REGION="us-west-2"\nOWNER="conflict"\n')
0cb4a5e… anonymous 215 (dest / "README.md").write_text("# Boilerworks Opscode\n")
0cb4a5e… anonymous 216
0cb4a5e… anonymous 217 def test_generate_standard_with_ops(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 218 """Standard topology: app and ops end up as sibling dirs."""
0cb4a5e… anonymous 219 manifest = BoilerworksManifest(
0cb4a5e… anonymous 220 project="myapp",
0cb4a5e… anonymous 221 family="django-nextjs",
0cb4a5e… anonymous 222 size="full",
0cb4a5e… anonymous 223 topology="standard",
0cb4a5e… anonymous 224 cloud="aws",
0cb4a5e… anonymous 225 ops=True,
0cb4a5e… anonymous 226 region="us-east-1",
0cb4a5e… anonymous 227 )
0cb4a5e… anonymous 228 manifest_file = tmp_path / "boilerworks.yaml"
0cb4a5e… anonymous 229 manifest.to_file(manifest_file)
0cb4a5e… anonymous 230
0cb4a5e… anonymous 231 call_count = 0
0cb4a5e… anonymous 232
0cb4a5e… anonymous 233 def fake_clone(repo: str, dest: Path) -> None:
0cb4a5e… anonymous 234 nonlocal call_count
0cb4a5e… anonymous 235 call_count += 1
0cb4a5e… anonymous 236 if "opscode" in repo:
0cb4a5e… anonymous 237 self._seed_opscode(dest)
0cb4a5e… anonymous 238 else:
0cb4a5e… anonymous 239 self._seed_template(dest)
0cb4a5e… anonymous 240
fb274c9… anonymous 241 with (
fb274c9… anonymous 242 patch("boilerworks.generator._clone_repo", side_effect=fake_clone),
fb274c9… anonymous 243 patch("boilerworks.generator.subprocess.run"),
fb274c9… anonymous 244 ):
fb274c9… anonymous 245 generate_from_manifest(manifest_path=str(manifest_file), output_dir=str(tmp_path))
0cb4a5e… anonymous 246
0cb4a5e… anonymous 247 assert call_count == 2
0cb4a5e… anonymous 248 assert (tmp_path / "myapp").exists()
0cb4a5e… anonymous 249 assert (tmp_path / "myapp-ops").exists()
0cb4a5e… anonymous 250 assert not (tmp_path / "myapp-ops" / ".git").exists()
0cb4a5e… anonymous 251
0cb4a5e… anonymous 252 def test_generate_omni_with_ops(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 253 """Omni topology: ops/ lives inside the app directory."""
0cb4a5e… anonymous 254 manifest = BoilerworksManifest(
0cb4a5e… anonymous 255 project="myapp",
0cb4a5e… anonymous 256 family="django-nextjs",
0cb4a5e… anonymous 257 size="full",
0cb4a5e… anonymous 258 topology="omni",
0cb4a5e… anonymous 259 cloud="aws",
0cb4a5e… anonymous 260 ops=True,
0cb4a5e… anonymous 261 region="us-east-1",
0cb4a5e… anonymous 262 )
0cb4a5e… anonymous 263 manifest_file = tmp_path / "boilerworks.yaml"
0cb4a5e… anonymous 264 manifest.to_file(manifest_file)
0cb4a5e… anonymous 265
0cb4a5e… anonymous 266 call_count = 0
0cb4a5e… anonymous 267
0cb4a5e… anonymous 268 def fake_clone(repo: str, dest: Path) -> None:
0cb4a5e… anonymous 269 nonlocal call_count
0cb4a5e… anonymous 270 call_count += 1
0cb4a5e… anonymous 271 if "opscode" in repo:
0cb4a5e… anonymous 272 self._seed_opscode(dest)
0cb4a5e… anonymous 273 else:
0cb4a5e… anonymous 274 self._seed_template(dest)
0cb4a5e… anonymous 275
fb274c9… anonymous 276 with (
fb274c9… anonymous 277 patch("boilerworks.generator._clone_repo", side_effect=fake_clone),
fb274c9… anonymous 278 patch("boilerworks.generator.subprocess.run"),
fb274c9… anonymous 279 ):
fb274c9… anonymous 280 generate_from_manifest(manifest_path=str(manifest_file), output_dir=str(tmp_path))
0cb4a5e… anonymous 281
0cb4a5e… anonymous 282 assert call_count == 2
0cb4a5e… anonymous 283 assert (tmp_path / "myapp").exists()
0cb4a5e… anonymous 284 assert (tmp_path / "myapp" / "ops").exists()
0cb4a5e… anonymous 285
0cb4a5e… anonymous 286 def test_generate_without_ops(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 287 """When ops=False, only the app template is cloned."""
0cb4a5e… anonymous 288 manifest = BoilerworksManifest(
0cb4a5e… anonymous 289 project="myapp",
0cb4a5e… anonymous 290 family="django-nextjs",
0cb4a5e… anonymous 291 size="full",
0cb4a5e… anonymous 292 topology="standard",
0cb4a5e… anonymous 293 cloud="aws",
0cb4a5e… anonymous 294 ops=False,
0cb4a5e… anonymous 295 )
0cb4a5e… anonymous 296 manifest_file = tmp_path / "boilerworks.yaml"
0cb4a5e… anonymous 297 manifest.to_file(manifest_file)
0cb4a5e… anonymous 298
0cb4a5e… anonymous 299 call_count = 0
0cb4a5e… anonymous 300
0cb4a5e… anonymous 301 def fake_clone(repo: str, dest: Path) -> None:
0cb4a5e… anonymous 302 nonlocal call_count
0cb4a5e… anonymous 303 call_count += 1
0cb4a5e… anonymous 304 self._seed_template(dest)
0cb4a5e… anonymous 305
fb274c9… anonymous 306 with (
fb274c9… anonymous 307 patch("boilerworks.generator._clone_repo", side_effect=fake_clone),
fb274c9… anonymous 308 patch("boilerworks.generator.subprocess.run"),
fb274c9… anonymous 309 ):
fb274c9… anonymous 310 generate_from_manifest(manifest_path=str(manifest_file), output_dir=str(tmp_path))
0cb4a5e… anonymous 311
0cb4a5e… anonymous 312 assert call_count == 1
0cb4a5e… anonymous 313 assert (tmp_path / "myapp").exists()
0cb4a5e… anonymous 314 assert not (tmp_path / "myapp-ops").exists()
0cb4a5e… anonymous 315
0cb4a5e… anonymous 316 def test_generate_existing_ops_dir_exits(self, tmp_path: Path) -> None:
0cb4a5e… anonymous 317 """Standard topology: if ops dir already exists, exits cleanly."""
0cb4a5e… anonymous 318 manifest = BoilerworksManifest(
0cb4a5e… anonymous 319 project="myapp",
0cb4a5e… anonymous 320 family="django-nextjs",
0cb4a5e… anonymous 321 size="full",
0cb4a5e… anonymous 322 topology="standard",
0cb4a5e… anonymous 323 cloud="aws",
0cb4a5e… anonymous 324 ops=True,
0cb4a5e… anonymous 325 )
0cb4a5e… anonymous 326 manifest_file = tmp_path / "boilerworks.yaml"
0cb4a5e… anonymous 327 manifest.to_file(manifest_file)
0cb4a5e… anonymous 328
0cb4a5e… anonymous 329 # Pre-create ops dir
0cb4a5e… anonymous 330 (tmp_path / "myapp-ops").mkdir()
0cb4a5e… anonymous 331
0cb4a5e… anonymous 332 def fake_clone(repo: str, dest: Path) -> None:
0cb4a5e… anonymous 333 self._seed_template(dest)
0cb4a5e… anonymous 334
fb274c9… anonymous 335 with (
fb274c9… anonymous 336 patch("boilerworks.generator._clone_repo", side_effect=fake_clone),
fb274c9… anonymous 337 patch("boilerworks.generator.subprocess.run"),
fb274c9… anonymous 338 pytest.raises(SystemExit),
fb274c9… anonymous 339 ):
fb274c9… anonymous 340 generate_from_manifest(manifest_path=str(manifest_file), output_dir=str(tmp_path))
0cb4a5e… anonymous 341
0cb4a5e… anonymous 342 shutil.rmtree(tmp_path / "myapp", ignore_errors=True)

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button