|
| 1 | +""" |
| 2 | +Contract tests: verify that the Python CLI accepts exactly the argument signatures |
| 3 | +passed by the SF CLI plugin (@salesforce/plugin-data-code-extension v0.1.4) via spawn(). |
| 4 | +
|
| 5 | +These tests do NOT exercise business logic. They verify that: |
| 6 | + 1. All flags recognised by the SF CLI plugin are still accepted by the Python CLI. |
| 7 | + 2. Exit code is never 2 (Click's "bad usage" code) for valid SF CLI invocations. |
| 8 | + 3. stdout patterns parsed by the SF CLI plugin are present in the Python CLI output. |
| 9 | +
|
| 10 | +Source of truth for expected args and stdout regex patterns: |
| 11 | + data-code-extension/src/utils/datacodeBinaryExecutor.ts |
| 12 | +""" |
| 13 | + |
| 14 | +import os |
| 15 | +import re |
| 16 | +from typing import ClassVar |
| 17 | +from unittest.mock import patch |
| 18 | + |
| 19 | +from click.testing import CliRunner |
| 20 | + |
| 21 | +from datacustomcode.cli import ( |
| 22 | + deploy, |
| 23 | + init, |
| 24 | + run, |
| 25 | + scan, |
| 26 | + zip as zip_cmd, |
| 27 | +) |
| 28 | +from datacustomcode.deploy import AccessTokenResponse |
| 29 | + |
| 30 | + |
| 31 | +class TestInitArgContract: |
| 32 | + """ |
| 33 | + SF CLI spawn: datacustomcode init --code-type <script|function> <packageDir> |
| 34 | + Ref: executeBinaryInit() |
| 35 | + """ |
| 36 | + |
| 37 | + @patch("datacustomcode.template.copy_script_template") |
| 38 | + @patch("datacustomcode.scan.dc_config_json_from_file") |
| 39 | + @patch("datacustomcode.scan.update_config") |
| 40 | + @patch("datacustomcode.scan.write_sdk_config") |
| 41 | + def test_accepts_code_type_script( |
| 42 | + self, mock_write_sdk, mock_update, mock_scan, mock_copy |
| 43 | + ): |
| 44 | + mock_scan.return_value = {} |
| 45 | + mock_update.return_value = {} |
| 46 | + runner = CliRunner() |
| 47 | + with runner.isolated_filesystem(): |
| 48 | + os.makedirs(os.path.join("mydir", "payload"), exist_ok=True) |
| 49 | + result = runner.invoke(init, ["--code-type", "script", "mydir"]) |
| 50 | + assert result.exit_code != 2, result.output |
| 51 | + |
| 52 | + @patch("datacustomcode.template.copy_function_template") |
| 53 | + @patch("datacustomcode.scan.dc_config_json_from_file") |
| 54 | + @patch("datacustomcode.scan.update_config") |
| 55 | + @patch("datacustomcode.scan.write_sdk_config") |
| 56 | + def test_accepts_code_type_function( |
| 57 | + self, mock_write_sdk, mock_update, mock_scan, mock_copy |
| 58 | + ): |
| 59 | + mock_scan.return_value = {} |
| 60 | + mock_update.return_value = {} |
| 61 | + runner = CliRunner() |
| 62 | + with runner.isolated_filesystem(): |
| 63 | + os.makedirs(os.path.join("mydir", "payload"), exist_ok=True) |
| 64 | + result = runner.invoke(init, ["--code-type", "function", "mydir"]) |
| 65 | + assert result.exit_code != 2, result.output |
| 66 | + |
| 67 | + |
| 68 | +class TestScanArgContract: |
| 69 | + """ |
| 70 | + SF CLI spawn: datacustomcode scan [--dry-run] [--no-requirements] |
| 71 | + [--config <file>] <entrypoint> |
| 72 | + Ref: executeBinaryScan() |
| 73 | + """ |
| 74 | + |
| 75 | + @patch("datacustomcode.scan.update_config") |
| 76 | + def test_accepts_positional_entrypoint(self, mock_update): |
| 77 | + mock_update.return_value = {} |
| 78 | + runner = CliRunner() |
| 79 | + result = runner.invoke(scan, ["--dry-run", "payload/entrypoint.py"]) |
| 80 | + assert result.exit_code != 2, result.output |
| 81 | + |
| 82 | + @patch("datacustomcode.scan.update_config") |
| 83 | + def test_accepts_dry_run_flag(self, mock_update): |
| 84 | + mock_update.return_value = {} |
| 85 | + runner = CliRunner() |
| 86 | + result = runner.invoke(scan, ["--dry-run", "payload/entrypoint.py"]) |
| 87 | + assert result.exit_code != 2, result.output |
| 88 | + |
| 89 | + @patch("datacustomcode.scan.update_config") |
| 90 | + def test_accepts_no_requirements_flag(self, mock_update): |
| 91 | + mock_update.return_value = {} |
| 92 | + runner = CliRunner() |
| 93 | + result = runner.invoke( |
| 94 | + scan, ["--no-requirements", "--dry-run", "payload/entrypoint.py"] |
| 95 | + ) |
| 96 | + assert result.exit_code != 2, result.output |
| 97 | + |
| 98 | + @patch("datacustomcode.scan.update_config") |
| 99 | + def test_accepts_config_flag(self, mock_update): |
| 100 | + mock_update.return_value = {} |
| 101 | + runner = CliRunner() |
| 102 | + result = runner.invoke( |
| 103 | + scan, |
| 104 | + ["--config", "custom/config.json", "--dry-run", "payload/entrypoint.py"], |
| 105 | + ) |
| 106 | + assert result.exit_code != 2, result.output |
| 107 | + |
| 108 | + |
| 109 | +class TestZipArgContract: |
| 110 | + """ |
| 111 | + SF CLI spawn: datacustomcode zip [--network <network>] <packageDir> |
| 112 | + Ref: executeBinaryZip() |
| 113 | + """ |
| 114 | + |
| 115 | + @patch("datacustomcode.deploy.zip") |
| 116 | + @patch("datacustomcode.scan.find_base_directory") |
| 117 | + @patch("datacustomcode.scan.get_package_type") |
| 118 | + def test_accepts_positional_packagedir( |
| 119 | + self, mock_pkg_type, mock_find_base, mock_zip |
| 120 | + ): |
| 121 | + mock_find_base.return_value = "payload" |
| 122 | + mock_pkg_type.return_value = "script" |
| 123 | + runner = CliRunner() |
| 124 | + result = runner.invoke(zip_cmd, ["payload"]) |
| 125 | + assert result.exit_code != 2, result.output |
| 126 | + |
| 127 | + @patch("datacustomcode.deploy.zip") |
| 128 | + @patch("datacustomcode.scan.find_base_directory") |
| 129 | + @patch("datacustomcode.scan.get_package_type") |
| 130 | + def test_accepts_network_flag(self, mock_pkg_type, mock_find_base, mock_zip): |
| 131 | + mock_find_base.return_value = "payload" |
| 132 | + mock_pkg_type.return_value = "script" |
| 133 | + runner = CliRunner() |
| 134 | + result = runner.invoke(zip_cmd, ["--network", "custom", "payload"]) |
| 135 | + assert result.exit_code != 2, result.output |
| 136 | + |
| 137 | + |
| 138 | +class TestDeployArgContract: |
| 139 | + """ |
| 140 | + SF CLI spawn: |
| 141 | + datacustomcode deploy |
| 142 | + --name <name> --version <ver> --description <desc> |
| 143 | + --path <dir> --sf-cli-org <org> --cpu-size <size> |
| 144 | + [--network <net>] [--function-invoke-opt <opt>] |
| 145 | + Ref: executeBinaryDeploy() |
| 146 | + """ |
| 147 | + |
| 148 | + _BASE_ARGS: ClassVar[list[str]] = [ |
| 149 | + "--name", "my-pkg", |
| 150 | + "--version", "1.0.0", |
| 151 | + "--description", "My description", |
| 152 | + "--path", "payload", |
| 153 | + "--sf-cli-org", "my-org", |
| 154 | + "--cpu-size", "CPU_2XL", |
| 155 | + ] # fmt: skip |
| 156 | + |
| 157 | + @patch("datacustomcode.deploy._retrieve_access_token_from_sf_cli") |
| 158 | + @patch("datacustomcode.deploy.deploy_full") |
| 159 | + @patch("datacustomcode.cli.find_base_directory") |
| 160 | + @patch("datacustomcode.cli.get_package_type") |
| 161 | + def test_accepts_required_flags( |
| 162 | + self, mock_pkg_type, mock_find_base, mock_deploy_full, mock_sf_cli_token |
| 163 | + ): |
| 164 | + mock_find_base.return_value = "payload" |
| 165 | + mock_pkg_type.return_value = "script" |
| 166 | + mock_sf_cli_token.return_value = AccessTokenResponse( |
| 167 | + access_token="tok", instance_url="https://example.com" |
| 168 | + ) |
| 169 | + runner = CliRunner() |
| 170 | + result = runner.invoke(deploy, self._BASE_ARGS) |
| 171 | + assert result.exit_code != 2, result.output |
| 172 | + |
| 173 | + @patch("datacustomcode.deploy._retrieve_access_token_from_sf_cli") |
| 174 | + @patch("datacustomcode.deploy.deploy_full") |
| 175 | + @patch("datacustomcode.cli.find_base_directory") |
| 176 | + @patch("datacustomcode.cli.get_package_type") |
| 177 | + def test_accepts_network_flag( |
| 178 | + self, mock_pkg_type, mock_find_base, mock_deploy_full, mock_sf_cli_token |
| 179 | + ): |
| 180 | + mock_find_base.return_value = "payload" |
| 181 | + mock_pkg_type.return_value = "script" |
| 182 | + mock_sf_cli_token.return_value = AccessTokenResponse( |
| 183 | + access_token="tok", instance_url="https://example.com" |
| 184 | + ) |
| 185 | + runner = CliRunner() |
| 186 | + result = runner.invoke(deploy, [*self._BASE_ARGS, "--network", "custom"]) |
| 187 | + assert result.exit_code != 2, result.output |
| 188 | + |
| 189 | + @patch("datacustomcode.deploy._retrieve_access_token_from_sf_cli") |
| 190 | + @patch("datacustomcode.deploy.deploy_full") |
| 191 | + @patch("datacustomcode.cli.find_base_directory") |
| 192 | + @patch("datacustomcode.cli.get_package_type") |
| 193 | + def test_accepts_function_invoke_opt_flag( |
| 194 | + self, mock_pkg_type, mock_find_base, mock_deploy_full, mock_sf_cli_token |
| 195 | + ): |
| 196 | + mock_find_base.return_value = "payload" |
| 197 | + mock_pkg_type.return_value = "function" |
| 198 | + mock_sf_cli_token.return_value = AccessTokenResponse( |
| 199 | + access_token="tok", instance_url="https://example.com" |
| 200 | + ) |
| 201 | + runner = CliRunner() |
| 202 | + result = runner.invoke( |
| 203 | + deploy, [*self._BASE_ARGS, "--function-invoke-opt", "ASYNC"] |
| 204 | + ) |
| 205 | + assert result.exit_code != 2, result.output |
| 206 | + |
| 207 | + |
| 208 | +class TestRunArgContract: |
| 209 | + """ |
| 210 | + SF CLI spawn: |
| 211 | + datacustomcode run --sf-cli-org <org> |
| 212 | + [--config-file <file>] [--dependencies <deps>] <packageDir> |
| 213 | + Ref: executeBinaryRun() |
| 214 | +
|
| 215 | + Known incompatibility: SF CLI passes `--dependencies` once as a single string. |
| 216 | + Python CLI declares multiple=True, so the value arrives as a 1-tuple containing |
| 217 | + the raw string rather than individual dep names. |
| 218 | + """ |
| 219 | + |
| 220 | + @patch("datacustomcode.run.run_entrypoint") |
| 221 | + def test_accepts_sf_cli_org_and_positional(self, mock_run): |
| 222 | + runner = CliRunner() |
| 223 | + result = runner.invoke(run, ["--sf-cli-org", "my-org", "payload/entrypoint.py"]) |
| 224 | + assert result.exit_code != 2, result.output |
| 225 | + |
| 226 | + @patch("datacustomcode.run.run_entrypoint") |
| 227 | + def test_accepts_config_file_flag(self, mock_run): |
| 228 | + runner = CliRunner() |
| 229 | + result = runner.invoke( |
| 230 | + run, |
| 231 | + [ |
| 232 | + "--sf-cli-org", |
| 233 | + "my-org", |
| 234 | + "--config-file", |
| 235 | + "payload/config.json", |
| 236 | + "payload/entrypoint.py", |
| 237 | + ], |
| 238 | + ) |
| 239 | + assert result.exit_code != 2, result.output |
| 240 | + |
| 241 | + @patch("datacustomcode.run.run_entrypoint") |
| 242 | + def test_accepts_dependencies_as_single_string(self, mock_run): |
| 243 | + """SF CLI passes --dependencies once as a comma-separated string. |
| 244 | +
|
| 245 | + Python CLI uses multiple=True, so run_entrypoint receives ("dep1,dep2",) |
| 246 | + not ("dep1", "dep2"). The string is NOT split on commas. |
| 247 | + """ |
| 248 | + runner = CliRunner() |
| 249 | + result = runner.invoke( |
| 250 | + run, |
| 251 | + [ |
| 252 | + "--sf-cli-org", |
| 253 | + "my-org", |
| 254 | + "--dependencies", |
| 255 | + "dep1,dep2", |
| 256 | + "payload/entrypoint.py", |
| 257 | + ], |
| 258 | + ) |
| 259 | + assert result.exit_code != 2, result.output |
| 260 | + # Document the incompatibility: SF CLI passes a single "dep1,dep2" string, |
| 261 | + # but run_entrypoint receives ("dep1,dep2",) — not ("dep1", "dep2"). |
| 262 | + assert mock_run.call_args[0][2] == ("dep1,dep2",) |
| 263 | + |
| 264 | + |
| 265 | +class TestSfCliOutputRegexContract: |
| 266 | + """ |
| 267 | + The SF CLI plugin parses stdout from each command with regex patterns. |
| 268 | + These tests verify that the Python CLI's actual output matches what the |
| 269 | + plugin expects (v0.1.4). |
| 270 | +
|
| 271 | + Ref: stdout parsing in each executeBinary*() method of datacodeBinaryExecutor.ts. |
| 272 | + """ |
| 273 | + |
| 274 | + # ── init ────────────────────────────────────────────────────────────────── |
| 275 | + |
| 276 | + @patch("datacustomcode.template.copy_script_template") |
| 277 | + @patch("datacustomcode.scan.dc_config_json_from_file") |
| 278 | + @patch("datacustomcode.scan.update_config") |
| 279 | + @patch("datacustomcode.scan.write_sdk_config") |
| 280 | + def test_init_copying_template_pattern( |
| 281 | + self, mock_write_sdk, mock_update, mock_scan, mock_copy |
| 282 | + ): |
| 283 | + """SF CLI parses 'Copying template to <dir>' from init stdout.""" |
| 284 | + mock_scan.return_value = {} |
| 285 | + mock_update.return_value = {} |
| 286 | + runner = CliRunner() |
| 287 | + with runner.isolated_filesystem(): |
| 288 | + os.makedirs(os.path.join("mydir", "payload"), exist_ok=True) |
| 289 | + result = runner.invoke(init, ["--code-type", "script", "mydir"]) |
| 290 | + assert re.search(r"Copying template to .+", result.output) |
| 291 | + |
| 292 | + # ── scan ────────────────────────────────────────────────────────────────── |
| 293 | + |
| 294 | + @patch("datacustomcode.scan.update_config") |
| 295 | + def test_scan_scanning_file_pattern(self, mock_update): |
| 296 | + """SF CLI parses 'Scanning <file>...' from scan stdout.""" |
| 297 | + mock_update.return_value = {} |
| 298 | + runner = CliRunner() |
| 299 | + result = runner.invoke(scan, ["--dry-run", "payload/entrypoint.py"]) |
| 300 | + assert re.search(r"Scanning .+\.\.\.", result.output) |
0 commit comments