Public Access
DEVX-124: feat: extract shared utilities from infra and grm into devx
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / vikunja (push) Successful in 44s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / release (push) Successful in 1m7s
Post-merge / publish (push) Successful in 44s
Post-merge / badges (push) Successful in 1m6s
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / vikunja (push) Successful in 44s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / release (push) Successful in 1m7s
Post-merge / publish (push) Successful in 44s
Post-merge / badges (push) Successful in 1m6s
This commit was merged in pull request #188.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""Unit tests for devx.ci.record_deployed_tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.record_deployed_tag import main
|
||||
|
||||
|
||||
class TestRecordDeployedTag:
|
||||
@patch("devx.ci.record_deployed_tag.GiteaClient")
|
||||
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||
def test_records_production_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
|
||||
mock_token.return_value = "fake-token"
|
||||
client_instance = MagicMock()
|
||||
mock_client.return_value = client_instance
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "PRODUCTION_DEPLOY_TAG" in result.output
|
||||
assert "v1.0.0" in result.output
|
||||
client_instance.set_repo_variable.assert_called_once_with("PRODUCTION_DEPLOY_TAG", "v1.0.0")
|
||||
|
||||
@patch("devx.ci.record_deployed_tag.GiteaClient")
|
||||
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||
def test_records_staging_tag(self, mock_token: MagicMock, mock_client: MagicMock) -> None:
|
||||
mock_token.return_value = "fake-token"
|
||||
client_instance = MagicMock()
|
||||
mock_client.return_value = client_instance
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "staging", "--tag", "master-abc123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "STAGING_DEPLOY_TAG" in result.output
|
||||
client_instance.set_repo_variable.assert_called_once_with("STAGING_DEPLOY_TAG", "master-abc123")
|
||||
|
||||
@patch("devx.ci.record_deployed_tag.get_ci_token")
|
||||
def test_token_error_exits_nonzero(self, mock_token: MagicMock) -> None:
|
||||
import click
|
||||
|
||||
mock_token.side_effect = click.ClickException("No token available")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "production", "--tag", "v1.0.0"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "No token available" in result.output
|
||||
|
||||
def test_invalid_env_choice(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "invalid", "--tag", "v1.0.0"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_missing_tag_option(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--env", "production"])
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Unit tests for devx.utils.confirm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devx.utils.confirm import validate_confirmation
|
||||
|
||||
|
||||
class TestValidateConfirmation:
|
||||
def test_exact_match(self) -> None:
|
||||
assert validate_confirmation("deploy-production", "deploy-production") is True
|
||||
|
||||
def test_mismatch(self) -> None:
|
||||
assert validate_confirmation("deploy-staging", "deploy-production") is False
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert validate_confirmation("", "deploy-production") is False
|
||||
|
||||
def test_case_sensitive(self) -> None:
|
||||
assert validate_confirmation("Deploy-Production", "deploy-production") is False
|
||||
|
||||
def test_partial_match(self) -> None:
|
||||
assert validate_confirmation("deploy", "deploy-production") is False
|
||||
|
||||
def test_extra_whitespace(self) -> None:
|
||||
assert validate_confirmation("deploy-production ", "deploy-production") is False
|
||||
|
||||
def test_custom_expected(self) -> None:
|
||||
assert validate_confirmation("yes-delete-all", "yes-delete-all") is True
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Unit tests for devx.utils.crypto."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devx.utils.crypto import (
|
||||
_DIGITS,
|
||||
_LOWER,
|
||||
_SYMBOLS,
|
||||
_UPPER,
|
||||
generate_hex_secret,
|
||||
generate_password,
|
||||
generate_secret,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateSecret:
|
||||
def test_returns_url_safe_string(self) -> None:
|
||||
secret = generate_secret()
|
||||
assert isinstance(secret, str)
|
||||
assert len(secret) > 0
|
||||
# URL-safe base64 characters only
|
||||
assert re.match(r"^[A-Za-z0-9_-]+$", secret)
|
||||
|
||||
def test_never_starts_with_dash(self) -> None:
|
||||
for _ in range(1000):
|
||||
secret = generate_secret()
|
||||
assert not secret.startswith("-")
|
||||
|
||||
|
||||
class TestGeneratePassword:
|
||||
def test_default_length(self) -> None:
|
||||
pw = generate_password()
|
||||
assert len(pw) == 32
|
||||
|
||||
def test_custom_length(self) -> None:
|
||||
pw = generate_password(length=64)
|
||||
assert len(pw) == 64
|
||||
|
||||
def test_contains_all_char_classes(self) -> None:
|
||||
pw = generate_password(length=32)
|
||||
assert any(c in _UPPER for c in pw), "Missing uppercase"
|
||||
assert any(c in _LOWER for c in pw), "Missing lowercase"
|
||||
assert any(c in _DIGITS for c in pw), "Missing digits"
|
||||
assert any(c in _SYMBOLS for c in pw), "Missing symbols"
|
||||
|
||||
def test_first_char_alphanumeric(self) -> None:
|
||||
for _ in range(1000):
|
||||
pw = generate_password()
|
||||
assert pw[0] not in _SYMBOLS, f"First char '{pw[0]}' is a symbol"
|
||||
|
||||
def test_minimum_length_4(self) -> None:
|
||||
pw = generate_password(length=4)
|
||||
assert len(pw) == 4
|
||||
|
||||
|
||||
class TestGenerateHexSecret:
|
||||
def test_returns_hex_string(self) -> None:
|
||||
secret = generate_hex_secret(length=32)
|
||||
assert re.match(r"^[0-9a-f]+$", secret)
|
||||
|
||||
def test_correct_length(self) -> None:
|
||||
secret = generate_hex_secret(length=20)
|
||||
assert len(secret) == 20
|
||||
|
||||
def test_empty_for_zero(self) -> None:
|
||||
secret = generate_hex_secret(length=0)
|
||||
assert secret == ""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for devx.utils.json_registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.utils.json_registry import JsonRegistry
|
||||
|
||||
|
||||
class TestJsonRegistry:
|
||||
def test_add_and_get(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item1", host="10.0.0.1", user="deploy")
|
||||
info = reg.get("item1")
|
||||
assert info is not None
|
||||
assert info["host"] == "10.0.0.1"
|
||||
assert info["user"] == "deploy"
|
||||
assert "created_at" in info
|
||||
|
||||
def test_get_nonexistent(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
assert reg.get("nope") is None
|
||||
|
||||
def test_remove(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item1", host="10.0.0.1")
|
||||
reg.remove("item1")
|
||||
assert reg.get("item1") is None
|
||||
|
||||
def test_remove_nonexistent_is_noop(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.remove("nonexistent") # should not raise
|
||||
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("a", host="1.1.1.1")
|
||||
reg.add("b", host="2.2.2.2")
|
||||
items = reg.list()
|
||||
assert set(items.keys()) == {"a", "b"}
|
||||
assert items["a"]["host"] == "1.1.1.1"
|
||||
|
||||
def test_list_empty(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
assert reg.list() == {}
|
||||
|
||||
def test_update_existing(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1", status="active")
|
||||
reg.update("item", status="inactive")
|
||||
info = reg.get("item")
|
||||
assert info["status"] == "inactive"
|
||||
assert info["host"] == "1.1.1.1" # unchanged
|
||||
|
||||
def test_update_nonexistent_raises(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
with pytest.raises(KeyError):
|
||||
reg.update("nonexistent", host="1.1.1.1")
|
||||
|
||||
def test_update_skips_none_values(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1")
|
||||
reg.update("item", host=None, status="active")
|
||||
info = reg.get("item")
|
||||
assert info["host"] == "1.1.1.1" # not overwritten by None
|
||||
assert info["status"] == "active"
|
||||
|
||||
def test_persistence_across_instances(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
reg1 = JsonRegistry(path)
|
||||
reg1.add("item", host="10.0.0.1")
|
||||
reg2 = JsonRegistry(path)
|
||||
info = reg2.get("item")
|
||||
assert info is not None
|
||||
assert info["host"] == "10.0.0.1"
|
||||
|
||||
def test_overwrite_existing(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1")
|
||||
reg.add("item", host="2.2.2.2")
|
||||
info = reg.get("item")
|
||||
assert info["host"] == "2.2.2.2"
|
||||
|
||||
def test_corrupt_json_returns_empty(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
path.write_text("{invalid json")
|
||||
reg = JsonRegistry(path)
|
||||
assert reg.list() == {}
|
||||
|
||||
def test_nonexistent_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "nonexistent.json")
|
||||
assert reg.list() == {}
|
||||
|
||||
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "subdir" / "deeper" / "state.json"
|
||||
reg = JsonRegistry(path)
|
||||
reg.add("item", host="1.1.1.1")
|
||||
assert path.exists()
|
||||
|
||||
def test_get_returns_copy(self, tmp_path: Path) -> None:
|
||||
reg = JsonRegistry(tmp_path / "state.json")
|
||||
reg.add("item", host="1.1.1.1", tags=["a", "b"])
|
||||
info = reg.get("item")
|
||||
assert info is not None
|
||||
info["tags"].append("c")
|
||||
# Original should be unchanged
|
||||
info2 = reg.get("item")
|
||||
assert info2 is not None
|
||||
assert info2["tags"] == ["a", "b"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Unit tests for devx.utils.logging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from devx.utils.logging import get_logger
|
||||
|
||||
|
||||
class TestGetLogger:
|
||||
def test_returns_logger_with_handlers(self) -> None:
|
||||
logger = get_logger("test_devx_unit_1")
|
||||
assert logger.handlers
|
||||
assert isinstance(logger.handlers[0], logging.FileHandler)
|
||||
|
||||
def test_idempotent(self) -> None:
|
||||
logger1 = get_logger("test_devx_unit_2")
|
||||
initial_count = len(logger1.handlers)
|
||||
logger2 = get_logger("test_devx_unit_2")
|
||||
assert logger1 is logger2
|
||||
assert len(logger2.handlers) == initial_count
|
||||
|
||||
def test_log_level_is_debug(self) -> None:
|
||||
logger = get_logger("test_devx_unit_3")
|
||||
assert logger.level == logging.DEBUG
|
||||
|
||||
def test_file_handler_level_is_debug(self) -> None:
|
||||
logger = get_logger("test_devx_unit_4")
|
||||
file_handler = logger.handlers[0]
|
||||
assert file_handler.level == logging.DEBUG
|
||||
|
||||
def test_default_name(self) -> None:
|
||||
logger = get_logger()
|
||||
assert logger.name == "devx"
|
||||
|
||||
def test_creates_log_directory(self, tmp_path: Path) -> None:
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
get_logger("test_app_creates_dir")
|
||||
log_dir = tmp_path / ".local" / "state" / "test_app_creates_dir" / "logs"
|
||||
assert log_dir.exists()
|
||||
assert (log_dir / "test_app_creates_dir.log").exists()
|
||||
|
||||
def test_formatter_includes_timestamp(self) -> None:
|
||||
logger = get_logger("test_devx_unit_5")
|
||||
file_handler = logger.handlers[0]
|
||||
fmt = file_handler.formatter
|
||||
assert fmt is not None
|
||||
assert "%(asctime)s" in fmt._fmt
|
||||
assert "%(levelname)s" in fmt._fmt
|
||||
assert "%(name)s" in fmt._fmt
|
||||
assert "%(message)s" in fmt._fmt
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Unit tests for devx.utils.network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devx.utils.network import check_http_connectivity, wait_for_ssh
|
||||
|
||||
_no_sleep = MagicMock()
|
||||
|
||||
|
||||
class TestCheckHttpConnectivity:
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_success(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = MagicMock(status_code=200)
|
||||
check_http_connectivity("https://example.com", max_attempts=3)
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_retries_on_connection_error(self, mock_get: MagicMock) -> None:
|
||||
mock_get.side_effect = [
|
||||
requests.exceptions.ConnectionError("refused"),
|
||||
requests.exceptions.ConnectionError("refused"),
|
||||
MagicMock(status_code=200),
|
||||
]
|
||||
check_http_connectivity("https://example.com", max_attempts=5, sleep=_no_sleep)
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_raises_after_max_attempts(self, mock_get: MagicMock) -> None:
|
||||
mock_get.side_effect = requests.exceptions.ConnectionError("refused")
|
||||
with pytest.raises(requests.exceptions.ConnectionError):
|
||||
check_http_connectivity("https://example.com", max_attempts=2, sleep=_no_sleep)
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
@patch("devx.utils.network.requests.get")
|
||||
def test_verify_false(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = MagicMock(status_code=200)
|
||||
check_http_connectivity("https://example.com", verify=False)
|
||||
mock_get.assert_called_once_with("https://example.com", timeout=10, verify=False)
|
||||
|
||||
|
||||
class TestWaitForSsh:
|
||||
@patch("devx.utils.network.socket.create_connection")
|
||||
def test_immediate_success(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1")
|
||||
mock_conn.assert_called_once()
|
||||
|
||||
@patch("devx.utils.network.socket.create_connection")
|
||||
def test_retries_until_success(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.side_effect = [
|
||||
OSError("refused"),
|
||||
OSError("refused"),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1", max_attempts=5, sleep=_no_sleep)
|
||||
assert mock_conn.call_count == 3
|
||||
|
||||
@patch("devx.utils.network.socket.create_connection")
|
||||
def test_timeout_after_max_attempts(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.side_effect = OSError("refused")
|
||||
with pytest.raises(RuntimeError, match="SSH not available"):
|
||||
wait_for_ssh("10.0.0.1", max_attempts=3, sleep=_no_sleep)
|
||||
assert mock_conn.call_count == 3
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Unit tests for devx.utils.ssh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.utils.ssh import docker_exec_on_vm, ssh_exec, wait_for_ssh
|
||||
|
||||
|
||||
class TestSshExec:
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
result = ssh_exec("10.0.0.1", "uname -a")
|
||||
assert result.returncode == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_failure_with_check(self, mock_run: MagicMock) -> None:
|
||||
mock_result = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
mock_result.check_returncode.side_effect = subprocess.CalledProcessError(1, "ssh")
|
||||
mock_run.return_value = mock_result
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
ssh_exec("10.0.0.1", "false")
|
||||
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_failure_without_check(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
result = ssh_exec("10.0.0.1", "false", check=False)
|
||||
assert result.returncode == 1
|
||||
|
||||
@patch("devx.utils.ssh.subprocess.run")
|
||||
def test_custom_user(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
ssh_exec("10.0.0.1", "whoami", user="root")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "root@10.0.0.1" in cmd
|
||||
|
||||
|
||||
class TestDockerExecOnVm:
|
||||
@patch("devx.utils.ssh.ssh_exec")
|
||||
def test_simple_command(self, mock_ssh: MagicMock) -> None:
|
||||
mock_ssh.return_value = MagicMock(stdout="output\n")
|
||||
result = docker_exec_on_vm("10.0.0.1", "mycontainer", "ls /")
|
||||
assert result == "output"
|
||||
mock_ssh.assert_called_once_with("10.0.0.1", "docker exec mycontainer ls /", user="deploy", timeout=30)
|
||||
|
||||
@patch("devx.utils.ssh.ssh_exec")
|
||||
def test_psql_mode(self, mock_ssh: MagicMock) -> None:
|
||||
mock_ssh.return_value = MagicMock(stdout="result\n")
|
||||
result = docker_exec_on_vm("10.0.0.1", "db", "SELECT 1", db_user="postgres", db_name="mydb")
|
||||
assert result == "result"
|
||||
call_args = mock_ssh.call_args[0][1]
|
||||
assert "psql -U postgres -d mydb" in call_args
|
||||
assert "SELECT 1" in call_args
|
||||
|
||||
@patch("devx.utils.ssh.ssh_exec")
|
||||
def test_psql_escapes_single_quotes(self, mock_ssh: MagicMock) -> None:
|
||||
mock_ssh.return_value = MagicMock(stdout="\n")
|
||||
docker_exec_on_vm("10.0.0.1", "db", "SELECT 'it''s ok'", db_user="pg", db_name="db")
|
||||
call_args = mock_ssh.call_args[0][1]
|
||||
assert "'\"'\"'" in call_args
|
||||
|
||||
|
||||
class TestWaitForSsh:
|
||||
@patch("devx.utils.ssh.socket.create_connection")
|
||||
def test_immediate_success(self, mock_conn: MagicMock) -> None:
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1")
|
||||
mock_conn.assert_called_once()
|
||||
|
||||
@patch("devx.utils.ssh.socket.create_connection")
|
||||
@patch("devx.utils.ssh.time.sleep")
|
||||
def test_retries_until_success(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
|
||||
# Fail twice, then succeed
|
||||
mock_conn.side_effect = [
|
||||
OSError("refused"),
|
||||
OSError("refused"),
|
||||
MagicMock(),
|
||||
]
|
||||
mock_conn.return_value.__enter__ = MagicMock()
|
||||
mock_conn.return_value.__exit__ = MagicMock(return_value=False)
|
||||
wait_for_ssh("10.0.0.1", max_attempts=5)
|
||||
assert mock_conn.call_count == 3
|
||||
|
||||
@patch("devx.utils.ssh.socket.create_connection")
|
||||
@patch("devx.utils.ssh.time.sleep")
|
||||
def test_timeout_after_max_attempts(self, mock_sleep: MagicMock, mock_conn: MagicMock) -> None:
|
||||
mock_conn.side_effect = OSError("refused")
|
||||
with pytest.raises(RuntimeError, match="SSH not available"):
|
||||
wait_for_ssh("10.0.0.1", max_attempts=3)
|
||||
assert mock_conn.call_count == 3
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Unit tests for devx.utils.step_tracker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.utils.step_tracker import Step, StepTracker, track_steps
|
||||
|
||||
|
||||
class TestStep:
|
||||
def test_initial_status_is_pending(self) -> None:
|
||||
step = Step("install")
|
||||
assert step.status == "pending"
|
||||
assert step.name == "install"
|
||||
|
||||
|
||||
class TestStepTracker:
|
||||
def test_begin_adds_step_as_in_progress(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("install deps")
|
||||
assert len(tracker.steps) == 1
|
||||
assert tracker.steps[0].status == "in_progress"
|
||||
|
||||
def test_done_marks_last_in_progress_as_completed(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
assert tracker.steps[0].status == "completed"
|
||||
|
||||
def test_done_no_op_if_no_in_progress(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.done() # should not raise, no-op
|
||||
assert tracker.steps[0].status == "completed"
|
||||
|
||||
def test_done_no_op_if_empty(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.done() # should not raise
|
||||
|
||||
def test_multiple_steps(self) -> None:
|
||||
tracker = StepTracker()
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
tracker.done()
|
||||
assert len(tracker.steps) == 2
|
||||
assert tracker.steps[0].status == "completed"
|
||||
assert tracker.steps[1].status == "completed"
|
||||
|
||||
|
||||
class TestTrackSteps:
|
||||
def test_successful_operation(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
tracker.done()
|
||||
assert len(tracker.steps) == 2
|
||||
assert all(s.status == "completed" for s in tracker.steps)
|
||||
|
||||
def test_exception_marks_in_progress_as_failed(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
raise ValueError("boom")
|
||||
assert tracker.steps[0].status == "completed"
|
||||
assert tracker.steps[1].status == "failed"
|
||||
|
||||
def test_pending_step_stays_pending_on_exception(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with pytest.raises(ValueError):
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
tracker.begin("step2")
|
||||
tracker.done()
|
||||
tracker.begin("step3") # in_progress
|
||||
# step4 is pending (not started)
|
||||
raise ValueError("oops")
|
||||
assert tracker.steps[2].status == "failed"
|
||||
|
||||
def test_empty_operation(self) -> None:
|
||||
runner = CliRunner()
|
||||
with runner.isolation():
|
||||
with track_steps() as tracker:
|
||||
pass
|
||||
assert tracker.steps == []
|
||||
|
||||
def test_report_printed_on_success(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(_cmd_success, [], color=False)
|
||||
assert result.exit_code == 0
|
||||
assert "Operation Report" in result.output
|
||||
assert "step1" in result.output
|
||||
|
||||
def test_report_printed_on_failure(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(_cmd_failure, [], color=False)
|
||||
assert result.exit_code != 0
|
||||
assert "Operation Report" in result.output
|
||||
|
||||
|
||||
@click.command()
|
||||
def _cmd_success() -> None:
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
tracker.done()
|
||||
|
||||
|
||||
@click.command()
|
||||
def _cmd_failure() -> None:
|
||||
with track_steps() as tracker:
|
||||
tracker.begin("step1")
|
||||
raise ValueError("oops")
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Unit tests for devx.utils.vault."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from devx.utils.vault import (
|
||||
decrypt_file,
|
||||
encrypt_file,
|
||||
is_encrypted,
|
||||
load_vault_yaml,
|
||||
save_vault_yaml,
|
||||
)
|
||||
|
||||
|
||||
class TestIsEncrypted:
|
||||
def test_encrypted_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "secret.yml"
|
||||
f.write_text("$ANSIBLE_VAULT;1.1;AES256\n9382928...\n")
|
||||
assert is_encrypted(f) is True
|
||||
|
||||
def test_plain_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "plain.yml"
|
||||
f.write_text("key: value\n")
|
||||
assert is_encrypted(f) is False
|
||||
|
||||
|
||||
class TestLoadVaultYaml:
|
||||
def test_plain_yaml_no_vault_pass(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "data.yml"
|
||||
f.write_text("key: value\nlist:\n - a\n - b\n")
|
||||
data = load_vault_yaml(f)
|
||||
assert data == {"key": "value", "list": ["a", "b"]}
|
||||
|
||||
def test_empty_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "empty.yml"
|
||||
f.write_text("")
|
||||
data = load_vault_yaml(f)
|
||||
assert data == {}
|
||||
|
||||
def test_vault_pass_not_exists(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "data.yml"
|
||||
f.write_text("key: value\n")
|
||||
data = load_vault_yaml(f, vault_pass=tmp_path / "nonexistent")
|
||||
assert data == {"key": "value"}
|
||||
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_encrypted_file_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "secret.yml"
|
||||
f.write_text("$ANSIBLE_VAULT\n...")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="key: decrypted\n", stderr="")
|
||||
data = load_vault_yaml(f, vault_pass=vp)
|
||||
assert data == {"key": "decrypted"}
|
||||
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_not_vault_encrypted_fallback(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "plain.yml"
|
||||
f.write_text("key: value\n")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="is not vault encrypted")
|
||||
data = load_vault_yaml(f, vault_pass=vp)
|
||||
assert data == {"key": "value"}
|
||||
|
||||
|
||||
class TestSaveVaultYaml:
|
||||
def test_save_plain(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "output.yml"
|
||||
save_vault_yaml(f, {"key": "value"})
|
||||
content = f.read_text()
|
||||
assert "key: value" in content
|
||||
|
||||
def test_save_with_vault_pass_not_exists(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "output.yml"
|
||||
vp = tmp_path / "nonexistent"
|
||||
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
|
||||
# Should save as plain YAML
|
||||
content = f.read_text()
|
||||
assert "key: value" in content
|
||||
assert "$ANSIBLE_VAULT" not in content
|
||||
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_save_and_encrypt(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "output.yml"
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
save_vault_yaml(f, {"key": "value"}, vault_pass=vp)
|
||||
# File should be written
|
||||
assert f.exists()
|
||||
# ansible-vault encrypt should be called
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "ansible-vault" in cmd
|
||||
assert "encrypt" in cmd
|
||||
|
||||
|
||||
class TestEncryptFile:
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "file.yml"
|
||||
f.write_text("key: value")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
encrypt_file(f, vp)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "ansible-vault" in cmd
|
||||
assert "encrypt" in cmd
|
||||
assert str(f) in cmd
|
||||
assert str(vp) in cmd
|
||||
|
||||
|
||||
class TestDecryptFile:
|
||||
@patch("devx.utils.vault.subprocess.run")
|
||||
def test_calls_ansible_vault(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
f = tmp_path / "file.yml"
|
||||
f.write_text("$ANSIBLE_VAULT\n...")
|
||||
vp = tmp_path / "vault-password"
|
||||
vp.write_text("secret")
|
||||
|
||||
decrypt_file(f, vp)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "ansible-vault" in cmd
|
||||
assert "decrypt" in cmd
|
||||
assert str(f) in cmd
|
||||
assert str(vp) in cmd
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for devx.ci.validate_deploy_ref."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.validate_deploy_ref import main
|
||||
|
||||
|
||||
class TestValidateDeployRef:
|
||||
def test_valid_tag_prints_ref(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef1234567890\n", stderr="")
|
||||
result = runner.invoke(main, ["--tag", "v1.0.0"])
|
||||
assert result.exit_code == 0
|
||||
assert "v1.0.0" in result.output
|
||||
|
||||
def test_invalid_tag_exits_nonzero(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
result = runner.invoke(main, ["--tag", "nonexistent"])
|
||||
assert result.exit_code == 1
|
||||
assert "does not exist" in result.output
|
||||
|
||||
def test_no_tag_without_allow_empty_exits_nonzero(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 1
|
||||
assert "No tag specified" in result.output
|
||||
|
||||
def test_allow_empty_prints_pr_mode(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--allow-empty"])
|
||||
assert result.exit_code == 0
|
||||
assert "PR mode" in result.output
|
||||
|
||||
def test_github_output_writes_ref(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
gh_output = tmp_path / "github_output"
|
||||
gh_output.write_text("")
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
|
||||
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
|
||||
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_output.read_text()
|
||||
assert "deploy-ref=v1.0.0" in content
|
||||
|
||||
def test_github_output_without_env_var_exits_nonzero(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch("devx.ci.validate_deploy_ref.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="abcdef12\n", stderr="")
|
||||
with runner.isolation(env={"GITHUB_OUTPUT": ""}):
|
||||
result = runner.invoke(main, ["--tag", "v1.0.0", "--github-output"])
|
||||
assert result.exit_code == 1
|
||||
assert "GITHUB_OUTPUT" in result.output
|
||||
|
||||
def test_allow_empty_with_github_output(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
gh_output = tmp_path / "github_output"
|
||||
gh_output.write_text("")
|
||||
with runner.isolation(env={"GITHUB_OUTPUT": str(gh_output)}):
|
||||
result = runner.invoke(main, ["--allow-empty", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
assert "deploy-ref=" in gh_output.read_text()
|
||||
Reference in New Issue
Block a user