diff --git a/tests/configs/invalid/default-version-not-deployed/example-module-shell/1.0.yaml b/tests/configs/invalid/default-version-not-deployed/example-module-shell/1.0.yaml new file mode 100644 index 00000000..570bd2bf --- /dev/null +++ b/tests/configs/invalid/default-version-not-deployed/example-module-shell/1.0.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-shell + version: "1.0" + description: Minimal shell-only module with a single shell application + + applications: + - app_type: shell + + name: test-shell-echo + script: + - echo hello diff --git a/tests/configs/invalid/default-version-not-deployed/settings.yaml b/tests/configs/invalid/default-version-not-deployed/settings.yaml new file mode 100644 index 00000000..33cecd08 --- /dev/null +++ b/tests/configs/invalid/default-version-not-deployed/settings.yaml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: + example-module-shell: "2.0" diff --git a/tests/configs/invalid/deprecated-on-creation/example-module-shell/1.0.yaml b/tests/configs/invalid/deprecated-on-creation/example-module-shell/1.0.yaml new file mode 100644 index 00000000..570bd2bf --- /dev/null +++ b/tests/configs/invalid/deprecated-on-creation/example-module-shell/1.0.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-shell + version: "1.0" + description: Minimal shell-only module with a single shell application + + applications: + - app_type: shell + + name: test-shell-echo + script: + - echo hello diff --git a/tests/configs/invalid/deprecated-on-creation/example-module-shell/2.0.yaml b/tests/configs/invalid/deprecated-on-creation/example-module-shell/2.0.yaml new file mode 100644 index 00000000..ba509f0f --- /dev/null +++ b/tests/configs/invalid/deprecated-on-creation/example-module-shell/2.0.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-shell + version: "2.0" + description: Minimal shell-only module with a single shell application + + applications: + - app_type: shell + + name: test-shell-echo + script: + - echo hello + +deprecated: true diff --git a/tests/configs/invalid/deprecated-on-creation/settings.yaml b/tests/configs/invalid/deprecated-on-creation/settings.yaml new file mode 100644 index 00000000..70f60017 --- /dev/null +++ b/tests/configs/invalid/deprecated-on-creation/settings.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: {} diff --git a/tests/configs/invalid/invalid-shell-script/example-module-shell/1.0.yaml b/tests/configs/invalid/invalid-shell-script/example-module-shell/1.0.yaml new file mode 100644 index 00000000..8b749cd2 --- /dev/null +++ b/tests/configs/invalid/invalid-shell-script/example-module-shell/1.0.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-shell + version: "1.0" + description: Module whose shell script is not valid bash + + applications: + - app_type: shell + + name: test-shell-echo + # 'fi' with no matching 'if' is invalid bash, so `validate --test-build` must + # reject the generated entrypoint when it runs `bash -n` over it. + script: + - fi diff --git a/tests/configs/invalid/invalid-shell-script/settings.yaml b/tests/configs/invalid/invalid-shell-script/settings.yaml new file mode 100644 index 00000000..70f60017 --- /dev/null +++ b/tests/configs/invalid/invalid-shell-script/settings.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: {} diff --git a/tests/configs/invalid/modified-without-allow-updates/example-module-shell/1.0.yaml b/tests/configs/invalid/modified-without-allow-updates/example-module-shell/1.0.yaml new file mode 100644 index 00000000..c2771566 --- /dev/null +++ b/tests/configs/invalid/modified-without-allow-updates/example-module-shell/1.0.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-shell + version: "1.0" + description: Minimal shell-only module with a single shell application + + applications: + - app_type: shell + + name: test-shell-echo + # Differs from the valid/minimal baseline (echo hello) so that deploying this over + # it is an in-place modification, rejected without allow_updates / a version bump. + script: + - echo modified diff --git a/tests/configs/invalid/modified-without-allow-updates/settings.yaml b/tests/configs/invalid/modified-without-allow-updates/settings.yaml new file mode 100644 index 00000000..70f60017 --- /dev/null +++ b/tests/configs/invalid/modified-without-allow-updates/settings.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: {} diff --git a/tests/configs/invalid/no-eligible-default/example-module-shell/1.0.yaml b/tests/configs/invalid/no-eligible-default/example-module-shell/1.0.yaml new file mode 100644 index 00000000..77d40f52 --- /dev/null +++ b/tests/configs/invalid/no-eligible-default/example-module-shell/1.0.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-shell + version: "1.0" + description: Shell module excluded from defaults with no eligible fallback + + applications: + - app_type: shell + + name: test-shell-echo + script: + - echo hello + + exclude_from_defaults: true diff --git a/tests/configs/invalid/no-eligible-default/settings.yaml b/tests/configs/invalid/no-eligible-default/settings.yaml new file mode 100644 index 00000000..70f60017 --- /dev/null +++ b/tests/configs/invalid/no-eligible-default/settings.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: {} diff --git a/tests/configs/invalid/removed-without-deprecation/settings.yaml b/tests/configs/invalid/removed-without-deprecation/settings.yaml new file mode 100644 index 00000000..70f60017 --- /dev/null +++ b/tests/configs/invalid/removed-without-deprecation/settings.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: {} diff --git a/tests/configs/invalid/unknown-dependency/example-module-base/1.0.yaml b/tests/configs/invalid/unknown-dependency/example-module-base/1.0.yaml new file mode 100644 index 00000000..7e8c3115 --- /dev/null +++ b/tests/configs/invalid/unknown-dependency/example-module-base/1.0.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-base + version: "1.0" + description: Base module that the dependent module references + + applications: [] diff --git a/tests/configs/invalid/unknown-dependency/example-module-dependent/1.0.yaml b/tests/configs/invalid/unknown-dependency/example-module-dependent/1.0.yaml new file mode 100644 index 00000000..dc38a528 --- /dev/null +++ b/tests/configs/invalid/unknown-dependency/example-module-dependent/1.0.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-dependent + version: "1.0" + description: References a version of example-module-base that does not exist + + dependencies: + - name: example-module-base + version: "9.9" + + applications: [] diff --git a/tests/configs/invalid/unknown-dependency/settings.yaml b/tests/configs/invalid/unknown-dependency/settings.yaml new file mode 100644 index 00000000..70f60017 --- /dev/null +++ b/tests/configs/invalid/unknown-dependency/settings.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: {} diff --git a/tests/configs/valid/minimal/example-module-shell/1.0.yaml b/tests/configs/valid/minimal/example-module-shell/1.0.yaml new file mode 100644 index 00000000..570bd2bf --- /dev/null +++ b/tests/configs/valid/minimal/example-module-shell/1.0.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/release.json + +module: + name: example-module-shell + version: "1.0" + description: Minimal shell-only module with a single shell application + + applications: + - app_type: shell + + name: test-shell-echo + script: + - echo hello diff --git a/tests/configs/valid/minimal/settings.yaml b/tests/configs/valid/minimal/settings.yaml new file mode 100644 index 00000000..70f60017 --- /dev/null +++ b/tests/configs/valid/minimal/settings.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=/workspaces/deploy-tools/src/deploy_tools/models/schemas/deployment-settings.json + +default_versions: {} diff --git a/tests/test_default_versions.py b/tests/test_default_versions.py new file mode 100644 index 00000000..95dbbb79 --- /dev/null +++ b/tests/test_default_versions.py @@ -0,0 +1,127 @@ +"""Unit tests for default-version selection. + +These call the function directly rather than through the CLI. Here that is to isolate +pure, non-trivial logic (e.g. natsort ordering of non-SemVer versions). +""" + +from collections import defaultdict + +import pytest + +from deploy_tools.models.deployment import ( + Deployment, + DeploymentSettings, + ReleasesByNameAndVersion, +) +from deploy_tools.models.module import Module, Release +from deploy_tools.validate import ValidationError, validate_default_versions + + +def _release( + name: str, version: str, *, deprecated: bool = False, excluded: bool = False +) -> Release: + """Build a minimal Release with no applications, for selection tests.""" + return Release( + module=Module( + name=name, + version=version, + applications=[], + exclude_from_defaults=excluded, + ), + deprecated=deprecated, + ) + + +def _deployment( + *releases: Release, default_versions: dict[str, str] | None = None +) -> Deployment: + """Assemble a Deployment from loose Releases and optional explicit defaults.""" + by_name: ReleasesByNameAndVersion = defaultdict(dict) + for release in releases: + by_name[release.module.name][release.module.version] = release + + return Deployment( + settings=DeploymentSettings(default_versions=default_versions or {}), + releases=by_name, + ) + + +def test_auto_selects_latest_version_by_natsort() -> None: + # natsort, not string ordering: "10.0" must beat "2.0" (which sorts last lexically). + deployment = _deployment( + _release("mod", "1.0"), + _release("mod", "2.0"), + _release("mod", "10.0"), + ) + assert validate_default_versions(deployment) == {"mod": "10.0"} + + +def test_prerelease_sorts_before_final_release() -> None: + # The documented non-SemVer case: "1.2rc1" must come before "1.2", so the final + # release is chosen as default over its own release candidate. + deployment = _deployment( + _release("mod", "1.2rc1"), + _release("mod", "1.2"), + ) + assert validate_default_versions(deployment) == {"mod": "1.2"} + + +def test_excluded_versions_are_not_auto_selected() -> None: + # The highest version opts out of defaults, so the next-highest is chosen instead. + deployment = _deployment( + _release("mod", "1.0"), + _release("mod", "2.0", excluded=True), + ) + assert validate_default_versions(deployment) == {"mod": "1.0"} + + +def test_deprecated_versions_are_not_auto_selected() -> None: + # Deprecated releases are excluded from the deployed set, so a higher deprecated + # version must not become the default over a lower active one. + deployment = _deployment( + _release("mod", "1.0"), + _release("mod", "2.0", deprecated=True), + ) + assert validate_default_versions(deployment) == {"mod": "1.0"} + + +def test_explicit_default_is_preserved_over_auto_selection() -> None: + # An explicit default wins even when a higher version exists. + deployment = _deployment( + _release("mod", "1.0"), + _release("mod", "2.0"), + default_versions={"mod": "1.0"}, + ) + assert validate_default_versions(deployment) == {"mod": "1.0"} + + +def test_explicit_default_for_nonexistent_version_raises() -> None: + # Pointing the default at a version that will not be deployed is rejected. + deployment = _deployment( + _release("mod", "1.0"), + default_versions={"mod": "9.9"}, + ) + with pytest.raises(ValidationError, match="Unable to configure mod/9.9 as default"): + validate_default_versions(deployment) + + +def test_explicit_default_for_deprecated_version_raises() -> None: + # A deprecated version is not in the deployed set, so it cannot be an explicit + # default either. + deployment = _deployment( + _release("mod", "1.0", deprecated=True), + default_versions={"mod": "1.0"}, + ) + with pytest.raises(ValidationError, match="Unable to configure mod/1.0 as default"): + validate_default_versions(deployment) + + +def test_all_versions_excluded_raises() -> None: + # With every version opting out and no explicit default, there is no eligible + # version to make default. + deployment = _deployment(_release("mod", "1.0", excluded=True)) + with pytest.raises( + ValidationError, + match="every version for name: mod has set exclude_from_defaults", + ): + validate_default_versions(deployment) diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 00000000..31566841 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,131 @@ +from pathlib import Path + +import pytest + +from conftest import run_cli +from deploy_tools.validate import ValidationError + +# Configurations that 'validate' should reject when deployed from scratch into an empty +# area. Each maps a config folder under configs/invalid to a substring expected in the +# resulting ValidationError. +INVALID_INITIAL_CONFIGS = [ + ( + "unknown-dependency", + "Module example-module-dependent/1.0 has unknown module dependency " + "example-module-base/9.9", + ), + ( + "default-version-not-deployed", + "Unable to configure example-module-shell/2.0 as default", + ), + ( + "no-eligible-default", + "every version for name: example-module-shell has set exclude_from_defaults", + ), +] + + +@pytest.mark.parametrize("config_name, message", INVALID_INITIAL_CONFIGS) +def test_validate_rejects_invalid_initial_config( + tmp_path: Path, configs: Path, config_name: str, message: str +) -> None: + with pytest.raises(ValidationError, match=message): + run_cli( + "validate", "--from-scratch", tmp_path, configs / "invalid" / config_name + ) + + +def test_validate_rejects_update_without_allow_updates( + tmp_path: Path, configs: Path +) -> None: + # Deploy a baseline whose module did not opt in to allow_updates, then validate a + # config that changes that module in place: this must be rejected. + run_cli("sync", "--from-scratch", tmp_path, configs / "valid" / "minimal") + with pytest.raises( + ValidationError, + match="Module example-module-shell/1.0 modified without updating version", + ): + run_cli( + "validate", tmp_path, configs / "invalid" / "modified-without-allow-updates" + ) + + +def test_validate_rejects_removal_without_deprecation( + tmp_path: Path, configs: Path +) -> None: + # Deploy a baseline module, then validate a config that drops it without first + # deprecating it (and without --allow-all): this must be rejected. + run_cli("sync", "--from-scratch", tmp_path, configs / "valid" / "minimal") + with pytest.raises( + ValidationError, + match="Module example-module-shell/1.0 removed without prior deprecation", + ): + run_cli( + "validate", tmp_path, configs / "invalid" / "removed-without-deprecation" + ) + + +def test_validate_rejects_added_deprecated_module( + tmp_path: Path, configs: Path +) -> None: + # Deploy a baseline module, then validate a config that introduces a brand-new + # release already in a deprecated state. Deprecating a module on initial creation + # is only allowed with --allow-all, so a normal validate must reject it. + run_cli("sync", "--from-scratch", tmp_path, configs / "valid" / "minimal") + with pytest.raises( + ValidationError, + match="Module example-module-shell/2.0 cannot have deprecated status", + ): + run_cli("validate", tmp_path, configs / "invalid" / "deprecated-on-creation") + + +def test_validate_allows_added_deprecated_module_with_allow_all( + tmp_path: Path, configs: Path +) -> None: + # The --allow-all flag deliberately permits introducing an already-deprecated + # release on initial creation. + run_cli("sync", "--from-scratch", tmp_path, configs / "valid" / "minimal") + run_cli( + "validate", + "--allow-all", + tmp_path, + configs / "invalid" / "deprecated-on-creation", + ) + + +def test_validate_test_build_accepts_valid_config( + tmp_path: Path, configs: Path +) -> None: + # A valid shell-only config survives the build and `bash -n` check unchanged. + run_cli( + "validate", + "--test-build", + "--from-scratch", + tmp_path, + configs / "valid" / "minimal", + ) + + +def test_validate_test_build_catches_invalid_script( + tmp_path: Path, configs: Path +) -> None: + # This config's entrypoint is not valid bash ('fi' with no 'if'). Only the build + # and `bash -n` check can catch it, so it passes without the --test-build flag and + # fails with it. This is proof the flag actually carries out the build. + config = configs / "invalid" / "invalid-shell-script" + + run_cli("validate", "--from-scratch", tmp_path, config) + + with pytest.raises( + ValidationError, match="Output script .*/test-shell-echo is invalid with errors" + ): + run_cli("validate", "--test-build", "--from-scratch", tmp_path, config) + + +def test_validate_reports_no_actions_when_unchanged( + tmp_path: Path, configs: Path +) -> None: + # Validating a config that is already fully deployed previews no changes. + run_cli("sync", "--from-scratch", tmp_path, configs / "valid" / "minimal") + output = run_cli("validate", tmp_path, configs / "valid" / "minimal") + assert "No release actions required" in output