"""Unit tests for the single recipe-meta loader + key registry (rcust P1; spec §8 R1/R6). Covers: every in-repo recipe_meta.py loads clean through the registry (THE typo gate), validation hard-errors (unknown key, wrong type, callable on a data key), the zero-config baseline defaults (spec §2), the underscore exemption for recipe-private constants, and the registry↔generated-doc sync (P1.5; drift fails CI). Run: cc-ci-run -m pytest tests/unit/test_meta.py -q """ from __future__ import annotations import os import subprocess import sys import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) from harness import meta as meta_mod # noqa: E402 from harness.meta import KEYS, MetaError, RecipeMeta # noqa: E402 ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) def _recipes_with_meta() -> list[str]: tests_dir = os.path.join(ROOT, "tests") return sorted( n for n in os.listdir(tests_dir) if os.path.isfile(os.path.join(tests_dir, n, "recipe_meta.py")) ) # ---- the typo gate: every in-repo recipe meta must validate against the registry -------------- @pytest.mark.parametrize("recipe", _recipes_with_meta()) def test_every_recipe_meta_loads_clean(recipe): """All tests/*/recipe_meta.py in the repo load + validate through the registry. A typo'd or unregistered ALL-CAPS key in any recipe meta fails HERE, at PR time — not silently at run time (the R6 failure mode this restructure kills).""" meta = meta_mod.load(recipe) assert isinstance(meta, RecipeMeta) # sanity: the 4 base keys always materialize with usable types assert isinstance(meta.HEALTH_PATH, str) assert isinstance(meta.HEALTH_OK, tuple) and meta.HEALTH_OK assert isinstance(meta.DEPLOY_TIMEOUT, int) and isinstance(meta.HTTP_TIMEOUT, int) # ---- zero-config baseline (spec §2) ------------------------------------------------------------ def test_missing_meta_yields_spec_baseline(tmp_path): meta = meta_mod.load("no-such-recipe", tests_dir=str(tmp_path)) assert meta.HEALTH_PATH == "/" assert meta.HEALTH_OK == (200, 301, 302) assert meta.DEPLOY_TIMEOUT == 600 assert meta.HTTP_TIMEOUT == 300 assert meta.BACKUP_CAPABLE is None # None = auto-detect (tri-state, not False) assert meta.EXPECTED_NA is None assert meta.READY_PROBE is None assert meta.UPGRADE_BASE_VERSION is None assert meta.BACKUP_VERIFY is None assert meta.UPGRADE_EXTRA_ENV is None assert meta.EXTRA_ENV == {} assert meta.DEPS == [] assert meta.WARM_CANONICAL is False assert meta.SCREENSHOT is None assert meta_mod.non_default(meta) == {} def test_registry_field_set_matches_dataclass(): """The RecipeMeta field set is generated from KEYS — no drift possible, pinned anyway.""" import dataclasses assert [f.name for f in dataclasses.fields(RecipeMeta)] == [k.name for k in KEYS] # the 14 final keys, no more (the 3 P2-deleted legacy keys are gone from the registry, # so any recipe_meta still setting them hard-fails the typo gate) assert len(KEYS) == 14 assert not [k for k in KEYS if k.deprecated] for gone in ("CHAOS_BASE_DEPLOY", "OIDC_AT_INSTALL", "SKIP_GENERIC"): assert gone not in {k.name for k in KEYS} # ---- validation hard errors (locked decision: fail fast at load) ------------------------------- def _write_meta(tmp_path, body: str, recipe: str = "r") -> str: d = tmp_path / recipe d.mkdir(exist_ok=True) (d / "recipe_meta.py").write_text(body) return recipe def test_unknown_key_raises_with_suggestion(tmp_path): r = _write_meta(tmp_path, "READINESS_PROBE = None\n") # the R6 typo example with pytest.raises(MetaError) as ei: meta_mod.load(r, tests_dir=str(tmp_path)) msg = str(ei.value) assert "READINESS_PROBE" in msg and "READY_PROBE" in msg # names the typo + nearest key def test_unknown_key_without_near_match_lists_registry(tmp_path): r = _write_meta(tmp_path, "TOTALLY_BOGUS_KNOB = 1\n") with pytest.raises(MetaError) as ei: meta_mod.load(r, tests_dir=str(tmp_path)) assert "HEALTH_PATH" in str(ei.value) # registered keys listed for the reader def test_wrong_type_raises(tmp_path): r = _write_meta(tmp_path, 'DEPLOY_TIMEOUT = "900"\n') with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"): meta_mod.load(r, tests_dir=str(tmp_path)) def test_bool_not_accepted_as_int(tmp_path): r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = True\n") with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"): meta_mod.load(r, tests_dir=str(tmp_path)) def test_callable_on_data_key_rejected(tmp_path): r = _write_meta(tmp_path, "def HEALTH_PATH():\n return '/'\n") with pytest.raises(MetaError, match="hook-typed"): meta_mod.load(r, tests_dir=str(tmp_path)) def test_non_callable_on_hook_key_rejected(tmp_path): r = _write_meta(tmp_path, "READY_PROBE = ['not', 'a', 'callable']\n") with pytest.raises(MetaError, match="READY_PROBE"): meta_mod.load(r, tests_dir=str(tmp_path)) def test_underscore_names_are_private_and_exempt(tmp_path): r = _write_meta( tmp_path, "_WELCOME_TEXT_MARKER = 'marker-xyz'\n_MAX_USERS = 42\n" "EXTRA_ENV = {'WELCOME_TEXT': _WELCOME_TEXT_MARKER, 'USERS': str(_MAX_USERS)}\n", ) meta = meta_mod.load(r, tests_dir=str(tmp_path)) assert meta.EXTRA_ENV == {"WELCOME_TEXT": "marker-xyz", "USERS": "42"} def test_lowercase_helpers_ignored(tmp_path): r = _write_meta( tmp_path, "def _helper(d):\n return {'K': d}\n\ndef EXTRA_ENV(ctx):\n return _helper(ctx.domain)\n", ) meta = meta_mod.load(r, tests_dir=str(tmp_path)) ctx = meta_mod.hook_ctx("x.example", meta) assert meta_mod.extra_env(meta, ctx) == {"K": "x.example"} # ---- normalization + helpers -------------------------------------------------------------------- def test_health_ok_list_normalized_to_tuple(tmp_path): r = _write_meta(tmp_path, "HEALTH_OK = [200, 302]\n") assert meta_mod.load(r, tests_dir=str(tmp_path)).HEALTH_OK == (200, 302) def test_extra_env_dict_and_callable_forms(tmp_path): r = _write_meta(tmp_path, "EXTRA_ENV = {'A': 1}\n") meta = meta_mod.load(r, tests_dir=str(tmp_path)) assert meta_mod.extra_env(meta, meta_mod.hook_ctx("d", meta)) == {"A": "1"} # stringified r2 = _write_meta( tmp_path, "UPGRADE_EXTRA_ENV = lambda ctx: {'COMPOSE_FILE': ctx.domain}\n", recipe="r2" ) meta2 = meta_mod.load(r2, tests_dir=str(tmp_path)) ctx2 = meta_mod.hook_ctx("dom.x", meta2, op="upgrade") assert meta_mod.upgrade_extra_env(meta2, ctx2) == {"COMPOSE_FILE": "dom.x"} assert meta_mod.extra_env(meta2, ctx2) == {} # unset EXTRA_ENV resolves to {} # ---- P3: uniform ctx hook convention ------------------------------------------------------------- def test_hook_ctx_fields(tmp_path): meta = meta_mod.load("no-such", tests_dir=str(tmp_path)) ctx = meta_mod.hook_ctx("app.ci.example", meta, op="backup") assert ctx.domain == "app.ci.example" assert ctx.base_url == "https://app.ci.example" assert ctx.meta is meta assert ctx.op == "backup" assert meta_mod.hook_ctx("d", meta).op is None def test_hook_ctx_deps_from_run_file(tmp_path, monkeypatch): import json meta = meta_mod.load("no-such", tests_dir=str(tmp_path)) monkeypatch.delenv("CCCI_DEPS_FILE", raising=False) assert meta_mod.hook_ctx("d", meta).deps is None f = tmp_path / "deps.json" f.write_text(json.dumps({"keycloak": {"recipe": "keycloak", "domain": "kc.x"}})) monkeypatch.setenv("CCCI_DEPS_FILE", str(f)) deps = meta_mod.hook_ctx("d", meta).deps assert deps["keycloak"]["domain"] == "kc.x" f.write_text("{}") # empty dict -> None (deps declared but not provisioned) assert meta_mod.hook_ctx("d", meta).deps is None def test_legacy_hook_signature_raises_clear_meta_error(tmp_path): """A pre-restructure hook signature must fail AT LOAD with a migration message — never a silent TypeError mid-run (P3.4).""" r = _write_meta(tmp_path, "def READY_PROBE(domain):\n return []\n") with pytest.raises(MetaError, match="ctx"): meta_mod.load(r, tests_dir=str(tmp_path)) r2 = _write_meta(tmp_path, "EXTRA_ENV = lambda domain: {}\n", recipe="r2") with pytest.raises(MetaError, match="restructure"): meta_mod.load(r2, tests_dir=str(tmp_path)) r3 = _write_meta( tmp_path, "def SCREENSHOT(page, domain, meta):\n return None\n", recipe="r3" ) with pytest.raises(MetaError, match="page, ctx"): meta_mod.load(r3, tests_dir=str(tmp_path)) def test_ctx_hook_signatures_accepted(tmp_path): r = _write_meta( tmp_path, "def READY_PROBE(ctx):\n return []\n" "def BACKUP_VERIFY(ctx):\n return True\n" "def SCREENSHOT(page, ctx):\n return None\n" "def EXTRA_ENV(ctx):\n return {}\n", ) meta = meta_mod.load(r, tests_dir=str(tmp_path)) assert callable(meta.READY_PROBE) and callable(meta.SCREENSHOT) def test_check_hook_signature_for_pre_op_hooks(): """The orchestrator validates ops.py pre_ hooks with the same checker (legacy (domain, meta) form names the migration).""" def legacy(domain, meta): pass def new(ctx): pass with pytest.raises(MetaError, match="ctx"): meta_mod.check_hook_signature(legacy, ("ctx",), "tests/x/ops.py::pre_upgrade") meta_mod.check_hook_signature(new, ("ctx",), "tests/x/ops.py::pre_upgrade") # no raise def test_non_default_reports_only_customized_keys(tmp_path): r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = 1500\nDEPS = ['keycloak']\n") nd = meta_mod.non_default(meta_mod.load(r, tests_dir=str(tmp_path))) assert nd == {"DEPLOY_TIMEOUT": 1500, "DEPS": ["keycloak"]} def test_meta_is_frozen(): import dataclasses meta = meta_mod.load("custom-html") with pytest.raises(dataclasses.FrozenInstanceError): meta.DEPLOY_TIMEOUT = 1 # ---- doc generation sync (P1.5: the committed §4 table == the registry rendering) --------------- def test_generated_doc_table_in_sync(): """docs/recipe-customization.md's key reference table is GENERATED from the registry (scripts/gen-meta-docs.py). If this fails: re-run `python3 scripts/gen-meta-docs.py` and commit the result — the table must never drift from the registry (R5).""" gen = os.path.join(ROOT, "scripts", "gen-meta-docs.py") doc = os.path.join(ROOT, "docs", "recipe-customization.md") rendered = subprocess.run( [sys.executable, gen, "--print"], capture_output=True, text=True, check=True ).stdout with open(doc) as f: committed = f.read() assert rendered.strip() in committed, ( "docs/recipe-customization.md key table is out of sync with the harness.meta registry — " "run `python3 scripts/gen-meta-docs.py` and commit" )