mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
7.0 KiB
Python
194 lines
7.0 KiB
Python
"""Focused unit tests for ``utils.cache.base``.
|
|
|
|
Covers the cross-cutting helpers in isolation: constants, deep_merge,
|
|
yaml-loaders, signatures, content fingerprints, _reset(), and the
|
|
templar-render machinery (with both no-templar and stub-templar
|
|
inputs). The broader integration of these helpers via the
|
|
applications/users/domains paths is covered in the per-domain test
|
|
files; this file pins the contract of base itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from utils.cache import _reset_cache_for_tests, base
|
|
from utils.paths import FILE_TOKENS
|
|
|
|
|
|
def _write(path: Path, content: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(textwrap.dedent(content), encoding="utf-8")
|
|
|
|
|
|
class TestProjectRootInvariants(unittest.TestCase):
|
|
"""`base.PROJECT_ROOT` must point at the actual repo root, not at
|
|
`<repo>/utils/`. CI run 24934007615 was caused by exactly this
|
|
drift after `utils/cache/data.py` was moved one level deeper.
|
|
"""
|
|
|
|
def test_project_root_resolves_to_repo_root(self):
|
|
self.assertTrue((base.PROJECT_ROOT / "roles").is_dir())
|
|
self.assertTrue((base.PROJECT_ROOT / "cli").is_dir())
|
|
self.assertEqual(base.ROLES_DIR, base.PROJECT_ROOT / "roles")
|
|
|
|
def test_default_tokens_file_is_secrets_yaml(self):
|
|
self.assertEqual(base.DEFAULT_TOKENS_FILE, FILE_TOKENS)
|
|
|
|
|
|
class TestDeepMerge(unittest.TestCase):
|
|
def test_overrides_win_on_scalar_keys(self):
|
|
result = base._deep_merge({"a": 1, "b": 2}, {"b": 99})
|
|
self.assertEqual(result, {"a": 1, "b": 99})
|
|
|
|
def test_recurses_into_nested_mappings(self):
|
|
result = base._deep_merge(
|
|
{"x": {"y": 1, "z": 2}},
|
|
{"x": {"y": 99}},
|
|
)
|
|
self.assertEqual(result, {"x": {"y": 99, "z": 2}})
|
|
|
|
def test_override_replaces_when_types_differ(self):
|
|
result = base._deep_merge({"x": {"y": 1}}, {"x": ["a", "b"]})
|
|
self.assertEqual(result, {"x": ["a", "b"]})
|
|
|
|
def test_returns_deep_copy_of_override_when_base_is_none(self):
|
|
override = {"x": [1, 2, 3]}
|
|
result = base._deep_merge(None, override)
|
|
self.assertEqual(result, override)
|
|
result["x"].append(4)
|
|
self.assertEqual(override["x"], [1, 2, 3])
|
|
|
|
|
|
class TestResolveRolesDir(unittest.TestCase):
|
|
def test_explicit_arg_wins(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
self.assertEqual(
|
|
base._resolve_roles_dir(roles_dir=tmp), Path(tmp).resolve()
|
|
)
|
|
|
|
def test_falls_back_to_module_default(self):
|
|
self.assertEqual(base._resolve_roles_dir(), base.ROLES_DIR.resolve())
|
|
|
|
|
|
class TestCacheKey(unittest.TestCase):
|
|
def test_returns_resolved_string(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
self.assertEqual(base._cache_key(Path(tmp)), str(Path(tmp).resolve()))
|
|
|
|
|
|
class TestFingerprintMapping(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def test_none_short_circuits_to_zero(self):
|
|
self.assertEqual(base._fingerprint_mapping(None), "0")
|
|
|
|
def test_same_object_hashes_stably(self):
|
|
obj = {"a": 1}
|
|
first = base._fingerprint_mapping(obj)
|
|
self.assertEqual(first, base._fingerprint_mapping(obj))
|
|
|
|
def test_in_place_mutation_changes_the_digest(self):
|
|
obj = {"a": 1}
|
|
first = base._fingerprint_mapping(obj)
|
|
obj["a"] = 2
|
|
self.assertNotEqual(first, base._fingerprint_mapping(obj))
|
|
|
|
def test_equal_content_distinct_objects_hash_to_same_digest(self):
|
|
a = {"a": 1, "b": 2}
|
|
b = {"b": 2, "a": 1}
|
|
self.assertEqual(base._fingerprint_mapping(a), base._fingerprint_mapping(b))
|
|
|
|
|
|
class TestStableVariablesSignature(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def test_empty_variables_collapses_to_canonical_tuple(self):
|
|
self.assertEqual(
|
|
base._stable_variables_signature(None),
|
|
("0", "0", "", ""),
|
|
)
|
|
self.assertEqual(
|
|
base._stable_variables_signature({}),
|
|
("0", "0", "", ""),
|
|
)
|
|
|
|
def test_includes_domain_primary_and_email_domain_strings(self):
|
|
sig = base._stable_variables_signature(
|
|
{
|
|
"applications": {"web-app-x": {}},
|
|
"DOMAIN_PRIMARY": "infinito.example",
|
|
"SYSTEM_EMAIL_DOMAIN": "mail.infinito.example",
|
|
}
|
|
)
|
|
self.assertEqual(sig[2], "infinito.example")
|
|
self.assertEqual(sig[3], "mail.infinito.example")
|
|
|
|
|
|
class TestTokensFileSignature(unittest.TestCase):
|
|
def test_missing_file_returns_zeroed_signature(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
sig = base._tokens_file_signature(Path(tmp) / "absent.yml")
|
|
self.assertEqual(sig[1], 0)
|
|
self.assertEqual(sig[2], 0)
|
|
|
|
def test_signature_changes_when_file_changes(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "tokens.yml"
|
|
path.write_text("a: 1\n", encoding="utf-8")
|
|
sig_before = base._tokens_file_signature(path)
|
|
path.write_text("a: 1\nb: 2\n", encoding="utf-8")
|
|
sig_after = base._tokens_file_signature(path)
|
|
self.assertNotEqual(sig_before, sig_after)
|
|
|
|
|
|
class TestRenderWithTemplar(unittest.TestCase):
|
|
"""`_render_with_templar` is the single ansible-coupled symbol in
|
|
base. We don't simulate a real Templar (that's covered by the
|
|
domain integration tests); we pin the no-templar short-circuit and
|
|
the basic stub-templar dispatch shape so a regression in the
|
|
closure setup or available_variables push/pop trips here.
|
|
"""
|
|
|
|
def test_returns_value_unchanged_when_templar_is_none(self):
|
|
sentinel = {"a": 1, "b": [2, 3]}
|
|
result = base._render_with_templar(sentinel, templar=None, variables={"x": 1})
|
|
self.assertIs(result, sentinel)
|
|
|
|
|
|
class TestResolveOverrideMapping(unittest.TestCase):
|
|
def test_missing_key_returns_empty_dict(self):
|
|
self.assertEqual(base._resolve_override_mapping({}, "applications"), {})
|
|
|
|
def test_mapping_passes_through(self):
|
|
result = base._resolve_override_mapping(
|
|
{"applications": {"web-app-x": {"foo": 1}}}, "applications"
|
|
)
|
|
self.assertEqual(result, {"web-app-x": {"foo": 1}})
|
|
|
|
def test_falls_back_to_raw_inventory_when_value_is_not_mapping(self):
|
|
result = base._resolve_override_mapping(
|
|
{
|
|
"applications": "<placeholder>",
|
|
"_INFINITO_APPLICATIONS_RAW": {"web-app-x": {"foo": 1}},
|
|
},
|
|
"applications",
|
|
)
|
|
self.assertEqual(result, {"web-app-x": {"foo": 1}})
|
|
|
|
|
|
class TestFingerprintIsContentOnly(unittest.TestCase):
|
|
def test_fingerprint_survives_reset_because_it_holds_no_state(self):
|
|
before = base._fingerprint_mapping({"a": 1})
|
|
_reset_cache_for_tests()
|
|
self.assertEqual(before, base._fingerprint_mapping({"a": 1}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|