mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 11:43:26 +08:00
186 lines
8.2 KiB
Python
186 lines
8.2 KiB
Python
"""structural validation of the describe CLI recipes.
|
|
|
|
Before these recipes shipped, `scripts/probe_channels.py::_describe_sobject`
|
|
called `run_sf("describe_sobject", ...)` / `run_sf("describe_tooling_sobject",
|
|
...)` against YAMLs that didn't exist on disk. Every unit test mocked
|
|
`run_sf`, so the suite stayed green — but the first real invocation would
|
|
have raised `SfCliError: recipe not found`. This module closes that gap
|
|
by loading each YAML via `sf_cli._load_recipe` and asserting the schema
|
|
`sf_cli.run_sf` expects.
|
|
|
|
Deliberately does NOT invoke `sf sobject describe` — that needs a live
|
|
org. Structural validation only; the wire-level contract is covered by
|
|
the existing probe_channels integration path once the skill ships.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
import unittest.mock as mock
|
|
from pathlib import Path
|
|
|
|
from . import _bootstrap # noqa: F401
|
|
|
|
import sf_cli # type: ignore
|
|
|
|
# SKILL_ROOT is now file-relative (config.py uses
|
|
# Path(__file__).resolve().parent.parent), so config.CLI_DIR auto-resolves
|
|
# to the repo's assets/cli/ under test. _REPO_CLI_DIR is kept for tests
|
|
# that compare paths or pass them as explicit args.
|
|
_REPO_CLI_DIR = (
|
|
Path(__file__).resolve().parent.parent.parent / "assets" / "cli"
|
|
)
|
|
|
|
|
|
class DescribeRecipeStructureTests(unittest.TestCase):
|
|
"""Both describe recipes must parse + conform to the run_sf schema."""
|
|
|
|
def setUp(self) -> None:
|
|
# patch the module-level CLI_DIR binding inside sf_cli so
|
|
# _load_recipe reads from the repo's assets/cli/. `sf_cli`
|
|
# imported `CLI_DIR` by name at module load, so we must patch
|
|
# the *local* reference in sf_cli — patching `config.CLI_DIR`
|
|
# alone would leave the stale import behind.
|
|
self._patch = mock.patch.object(sf_cli, "CLI_DIR", _REPO_CLI_DIR)
|
|
self._patch.start()
|
|
self.addCleanup(self._patch.stop)
|
|
|
|
def _load(self, name: str) -> dict:
|
|
# _load_recipe is the same function run_sf uses at runtime, so if
|
|
# it parses here it will parse in production. yaml.safe_load + the
|
|
# mapping-shape guard both run inside _load_recipe .
|
|
return sf_cli._load_recipe(name)
|
|
|
|
# ---- parsing + mapping shape -----------------------------------------
|
|
|
|
def test_describe_sobject_parses(self):
|
|
recipe = self._load("describe_sobject")
|
|
self.assertIsInstance(recipe, dict)
|
|
self.assertEqual(recipe.get("name"), "describe_sobject")
|
|
|
|
def test_describe_tooling_sobject_parses(self):
|
|
recipe = self._load("describe_tooling_sobject")
|
|
self.assertIsInstance(recipe, dict)
|
|
self.assertEqual(recipe.get("name"), "describe_tooling_sobject")
|
|
|
|
# ---- required_params: a list, covers what probe_channels passes -------
|
|
|
|
def test_required_params_are_list_of_strings(self):
|
|
for name in ("describe_sobject", "describe_tooling_sobject"):
|
|
with self.subTest(recipe=name):
|
|
recipe = self._load(name)
|
|
required = recipe.get("required_params")
|
|
self.assertIsInstance(required, list)
|
|
self.assertTrue(
|
|
all(isinstance(p, str) for p in required),
|
|
f"{name}: required_params must be list[str]",
|
|
)
|
|
# probe_channels._describe_sobject passes exactly these two.
|
|
# If the recipe drops either, run_sf will SfCliError before
|
|
# the subprocess call — so assert both are present.
|
|
self.assertIn("ORG_ALIAS", required)
|
|
self.assertIn("SOBJECT", required)
|
|
|
|
# ---- argv: a list of strings with both placeholders referenced --------
|
|
|
|
def test_argv_is_list_of_strings(self):
|
|
for name in ("describe_sobject", "describe_tooling_sobject"):
|
|
with self.subTest(recipe=name):
|
|
recipe = self._load(name)
|
|
argv = recipe.get("argv")
|
|
self.assertIsInstance(argv, list)
|
|
self.assertGreater(len(argv), 0)
|
|
self.assertTrue(
|
|
all(isinstance(e, str) for e in argv),
|
|
f"{name}: argv must be list[str]",
|
|
)
|
|
|
|
def test_argv_references_both_required_placeholders(self):
|
|
"""Every key in required_params must appear as `{{KEY}}` somewhere
|
|
in argv — otherwise the substitution is a no-op and the caller's
|
|
input never reaches the CLI. Defensive sanity check, not a
|
|
functional run.
|
|
"""
|
|
for name in ("describe_sobject", "describe_tooling_sobject"):
|
|
with self.subTest(recipe=name):
|
|
recipe = self._load(name)
|
|
argv_joined = " ".join(recipe["argv"])
|
|
for key in recipe.get("required_params") or []:
|
|
self.assertIn(
|
|
f"{{{{{key}}}}}", argv_joined,
|
|
f"{name}: required param {key} not referenced in argv",
|
|
)
|
|
|
|
# ---- success_check contract -----------------------------------------
|
|
|
|
def test_success_check_is_stdout_json_status_zero(self):
|
|
"""run_sf's success-detection code path (in sf_cli.run_sf) assumes
|
|
`stdout_json_status_zero`. Any other value silently changes the
|
|
pass/fail contract, so pin it.
|
|
"""
|
|
for name in ("describe_sobject", "describe_tooling_sobject"):
|
|
with self.subTest(recipe=name):
|
|
recipe = self._load(name)
|
|
self.assertEqual(
|
|
recipe.get("success_check"), "stdout_json_status_zero",
|
|
)
|
|
|
|
# ---- timeout is a positive int ---------------------------------------
|
|
|
|
def test_timeout_is_positive_int(self):
|
|
for name in ("describe_sobject", "describe_tooling_sobject"):
|
|
with self.subTest(recipe=name):
|
|
recipe = self._load(name)
|
|
t = recipe.get("timeout_seconds")
|
|
self.assertIsInstance(t, int)
|
|
self.assertGreater(t, 0)
|
|
|
|
# ---- auth_required_stderr_patterns -----------------------------------
|
|
|
|
def test_auth_patterns_cover_the_known_failures(self):
|
|
"""sf CLI surfaces `NoOrgAuthenticationError` for a never-logged-in
|
|
alias and `AuthInfoError` for a stale/revoked session. Both must
|
|
classify as AuthRequired, not a generic SfCliError, so the skill
|
|
can prompt the user to re-login instead of aborting.
|
|
"""
|
|
for name in ("describe_sobject", "describe_tooling_sobject"):
|
|
with self.subTest(recipe=name):
|
|
recipe = self._load(name)
|
|
patterns = recipe.get("auth_required_stderr_patterns") or []
|
|
self.assertIn("NoOrgAuthenticationError", patterns)
|
|
self.assertIn("AuthInfoError", patterns)
|
|
|
|
# ---- tooling recipe has --use-tooling-api; data-API recipe does NOT ---
|
|
|
|
def test_tooling_recipe_uses_tooling_api_flag(self):
|
|
recipe = self._load("describe_tooling_sobject")
|
|
self.assertIn("--use-tooling-api", recipe["argv"])
|
|
|
|
def test_data_api_recipe_does_not_use_tooling_api_flag(self):
|
|
"""Separating the two recipes rather than toggling via a param
|
|
was a deliberate design call (call sites stay explicit). Verify
|
|
the separation hasn't regressed into a single merged recipe.
|
|
"""
|
|
recipe = self._load("describe_sobject")
|
|
self.assertNotIn("--use-tooling-api", recipe["argv"])
|
|
|
|
# ---- run_sf missing-param surfacing runs end-to-end through recipe ----
|
|
|
|
def test_run_sf_raises_cleanly_when_required_param_absent(self):
|
|
"""This exercises the full load_recipe → required_params check
|
|
path inside run_sf, with a REAL (on-disk) recipe rather than a
|
|
mock. If the YAML parses but required_params is malformed, this
|
|
would surface as something other than SfCliError with a clear
|
|
'missing required params' message.
|
|
"""
|
|
with self.assertRaises(sf_cli.SfCliError) as ctx:
|
|
# Deliberately omit SOBJECT — should raise at the required-
|
|
# params check, well before subprocess.run. Pass ORG_ALIAS so
|
|
# we isolate the failure to the missing param.
|
|
sf_cli.run_sf("describe_sobject", ORG_ALIAS="whatever")
|
|
self.assertIn("missing required params", str(ctx.exception))
|
|
self.assertIn("SOBJECT", str(ctx.exception))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|