"""Step definitions for RegistryCache BDD tests.""" from __future__ import annotations import asyncio import hashlib import time from typing import Any from unittest.mock import AsyncMock, MagicMock from behave import given, then, when from cleveractors.registry.cache import RegistryCache from cleveractors.registry.canonical import Canonicalizer from cleveractors.registry.client import RegistryClient from cleveractors.registry.exceptions import ( InvalidPackageIdError, PackageNotFoundError, RegistryNetworkError, ) from cleveractors.registry.types import PackageContent, PackageId def _canonical_sha1(content: dict[str, Any]) -> str: canonicalizer = Canonicalizer() canonical = canonicalizer.canonicalize(content) return hashlib.sha1(canonical.encode("utf-8"), usedforsecurity=False).hexdigest() _STANDARD_CONTENT = {"name": "test-pkg", "type": "actor"} _PKG_A_CONTENT = {"name": "pkg-a", "type": "actor"} _PKG_B_CONTENT = {"name": "pkg-b", "type": "actor"} _PKG_C_CONTENT = {"name": "pkg-c", "type": "actor"} _LABEL_CONTENT_MAP: dict[str, dict[str, Any]] = { "A": _PKG_A_CONTENT, "B": _PKG_B_CONTENT, "C": _PKG_C_CONTENT, } def _run_async(context: Any, coro: Any) -> Any: """Run a coroutine using the scenario event loop.""" return context.loop.run_until_complete(coro) @given("a clean test environment for registry cache") def step_clean_cache_env(context: Any) -> None: context.cache = None context.error = None context._mock_responses: dict[str, dict[str, Any]] = {} context._content_labels: dict[str, dict[str, Any]] = {} context._current_standard_pkg_id = "" context._upstream_mock = AsyncMock() context._real_upstream_get_package = None _mock_client = MagicMock(spec=RegistryClient) _mock_client.get_package = context._upstream_mock _mock_client.close = MagicMock() context._mock_client_ref = _mock_client # ── Cache Initialisation ────────────────────────────────────────────────── def _create_cache(context: Any, **kwargs: Any) -> None: mock_client = MagicMock(spec=RegistryClient) mock_client.get_package = context._upstream_mock mock_client.close = _async_noop mock_client._get_client = MagicMock() context.cache = RegistryCache(mock_client, **kwargs) context._mock_client_ref = mock_client async def _async_noop() -> None: pass @when("I create a RegistryCache with default settings") def step_create_cache_default(context: Any) -> None: _create_cache(context, validate_content=False) @when('I create a RegistryCache with validation disabled and label "{label}"') def step_create_cache_no_val(context: Any, label: str) -> None: _create_cache(context, validate_content=False) context._cache_label = label @when('I create a RegistryCache with validation enabled and label "{label}"') def step_create_cache_with_val(context: Any, label: str) -> None: _create_cache(context, validate_content=True) context._cache_label = label @when("I create a RegistryCache with max_size {max_size:d} and ttl {ttl:d}") def step_create_cache_custom(context: Any, max_size: int, ttl: int) -> None: _create_cache(context, max_size=max_size, ttl=ttl, validate_content=False) @when( 'I create a RegistryCache with max_size {max_size:d} and ttl {ttl} and no validation and label "{label}"' ) def step_create_cache_custom_no_val( context: Any, max_size: int, ttl: str, label: str ) -> None: _create_cache(context, max_size=max_size, ttl=float(ttl), validate_content=False) context._cache_label = label @when("I try to create a RegistryCache with a non-client argument") def step_create_cache_bad_client(context: Any) -> None: bad_client: Any = "not-a-client" try: RegistryCache(bad_client) except TypeError as exc: context.error = exc @when("I try to create a RegistryCache with max_size {max_size:d}") def step_create_cache_bad_size(context: Any, max_size: int) -> None: mock = MagicMock(spec=RegistryClient) mock.get_package = context._upstream_mock mock.close = _async_noop try: RegistryCache(mock, max_size=max_size) except ValueError as exc: context.error = exc @when("I try to create a RegistryCache with ttl {ttl:d}") def step_create_cache_bad_ttl(context: Any, ttl: int) -> None: mock = MagicMock(spec=RegistryClient) mock.get_package = context._upstream_mock mock.close = _async_noop try: RegistryCache(mock, ttl=ttl) except ValueError as exc: context.error = exc @then("the cache max_size should be {expected:d}") def step_check_max_size(context: Any, expected: int) -> None: assert context.cache is not None assert context.cache.max_size == expected @then("the cache ttl should be {expected:d}") def step_check_ttl(context: Any, expected: int) -> None: assert context.cache is not None assert context.cache.ttl == expected @then("a TypeError should be raised by the registry cache") def step_check_type_error(context: Any) -> None: assert context.error is not None assert isinstance(context.error, TypeError) @then("a ValueError should be raised by the registry cache") def step_check_value_error(context: Any) -> None: assert context.error is not None assert isinstance(context.error, ValueError) @when("I try to create a RegistryCache with max_size boolean True") def step_create_cache_bool_max_size(context: Any) -> None: mock = MagicMock(spec=RegistryClient) mock.get_package = context._upstream_mock mock.close = _async_noop try: RegistryCache(mock, max_size=True) except ValueError as exc: context.error = exc # ── Mock upstream content ───────────────────────────────────────────────── def _register_content(context: Any, content: dict[str, Any], label: str) -> str: pkg_id = f"pkg_act_{_canonical_sha1(content)}" context._content_labels[label] = content context._mock_responses[pkg_id] = content async def _get_package(pid: str) -> dict[str, Any]: data = context._mock_responses.get(pid) if data is not None: return data.copy() raise RuntimeError(f"No content for {pid}") context._upstream_mock.side_effect = _get_package return pkg_id @when("the mock upstream records a standard package") def step_record_standard(context: Any) -> None: pkg_id = _register_content(context, _STANDARD_CONTENT, "standard") context._current_standard_pkg_id = pkg_id @when("the mock upstream records package A") def step_record_a(context: Any) -> None: _register_content(context, _PKG_A_CONTENT, "A") @when("the mock upstream records package B") def step_record_b(context: Any) -> None: _register_content(context, _PKG_B_CONTENT, "B") @when("the mock upstream records package C") def step_record_c(context: Any) -> None: _register_content(context, _PKG_C_CONTENT, "C") # ── Calling cache get_package ────────────────────────────────────────────── @when("I call cache get_package for the standard package") def step_call_standard(context: Any) -> None: async def _call() -> None: context._cache_result = await context.cache.get_package( context._current_standard_pkg_id ) _run_async(context, _call()) @when("I call cache get_package for package {label}") def step_call_labeled(context: Any, label: str) -> None: content = _LABEL_CONTENT_MAP[label] pkg_id = f"pkg_act_{_canonical_sha1(content)}" async def _call() -> None: context._cache_result = await context.cache.get_package(pkg_id) _run_async(context, _call()) @when("I call cache get_package_content for the standard package") def step_call_get_package_content_standard(context: Any) -> None: async def _call() -> None: context._cache_result = await context.cache.get_package_content( context._current_standard_pkg_id ) _run_async(context, _call()) # ── Concurrent access ────────────────────────────────────────────────────── @when("I call cache get_package for the standard package {count:d} times concurrently") def step_call_concurrent(context: Any, count: int) -> None: pkg_id = context._current_standard_pkg_id async def _concurrent() -> None: tasks = [ asyncio.create_task(context.cache.get_package(pkg_id)) for _ in range(count) ] context._concurrent_results = list(await asyncio.gather(*tasks)) _run_async(context, _concurrent()) context._cache_result = context._concurrent_results[0] # ── Tamper ───────────────────────────────────────────────────────────────── @when("I tamper with the cached standard package") def step_tamper_standard(context: Any) -> None: async def _tamper() -> None: async with context.cache._lock: pkg_id = context._current_standard_pkg_id if pkg_id in context.cache._store: stored_content, stored_at = context.cache._store[pkg_id] tampered_data = dict(stored_content.content) tampered_data["tampered"] = True new_content = PackageContent( id=stored_content.id, content=tampered_data, ) context.cache._store[pkg_id] = (new_content, stored_at) _run_async(context, _tamper()) # ── Error propagation ────────────────────────────────────────────────────── @when("the mock upstream raises PackageNotFoundError for the standard package") def step_upstream_not_found(context: Any) -> None: pkg_id = context._current_standard_pkg_id async def _raise_not_found(pid: str) -> dict[str, Any]: if pid == pkg_id: raise PackageNotFoundError("Package not found") return context._mock_responses.get(pid, {}) context._upstream_mock.side_effect = _raise_not_found @when("the mock upstream raises RegistryNetworkError for the standard package") def step_upstream_network_error(context: Any) -> None: pkg_id = context._current_standard_pkg_id async def _raise_network(pid: str) -> dict[str, Any]: if pid == pkg_id: raise RegistryNetworkError("Connection refused") return context._mock_responses.get(pid, {}) context._upstream_mock.side_effect = _raise_network # ── Statistics assertions ────────────────────────────────────────────────── @then("the cache stats hits should be {expected:d}") def step_check_stats_hits(context: Any, expected: int) -> None: assert context.cache is not None assert context.cache.stats.hits == expected, ( f"Expected {expected} hits, got {context.cache.stats.hits}" ) @then("the cache stats misses should be {expected:d}") def step_check_stats_misses(context: Any, expected: int) -> None: assert context.cache is not None assert context.cache.stats.misses == expected, ( f"Expected {expected} misses, got {context.cache.stats.misses}" ) @then("the cache stats evictions should be {expected:d}") def step_check_stats_evictions(context: Any, expected: int) -> None: assert context.cache is not None assert context.cache.stats.evictions == expected, ( f"Expected {expected} evictions, got {context.cache.stats.evictions}" ) @then("the total stats hits plus misses should be {expected:d}") def step_check_total_hits_misses(context: Any, expected: int) -> None: assert context.cache is not None total = context.cache.stats.hits + context.cache.stats.misses assert total == expected, f"Expected total {expected}, got {total}" # ── Content assertions ───────────────────────────────────────────────────── @then("the cache content should match the standard package") def step_match_standard(context: Any) -> None: assert context._cache_result is not None assert context._cache_result == _STANDARD_CONTENT, ( f"Expected {_STANDARD_CONTENT}, got {context._cache_result}" ) @then("the standard package should NOT be in cache") def step_not_in_cache(context: Any) -> None: assert context.cache is not None pkg_id = context._current_standard_pkg_id assert pkg_id not in context.cache, f"Cache unexpectedly contains {pkg_id}" @then("the standard package should be in cache") def step_in_cache(context: Any) -> None: assert context.cache is not None pkg_id = context._current_standard_pkg_id assert pkg_id in context.cache, f"Package {pkg_id} not in cache" @then("package {label} should NOT be in cache") def step_label_not_in_cache(context: Any, label: str) -> None: content = _LABEL_CONTENT_MAP[label] pkg_id = f"pkg_act_{_canonical_sha1(content)}" assert context.cache is not None assert pkg_id not in context.cache, ( f"Cache unexpectedly contains {pkg_id} for {label}" ) @then("package {label} should be in cache") def step_label_in_cache(context: Any, label: str) -> None: content = _LABEL_CONTENT_MAP[label] pkg_id = f"pkg_act_{_canonical_sha1(content)}" assert context.cache is not None assert pkg_id in context.cache, f"Package {label} not in cache" # ── TTL wait ─────────────────────────────────────────────────────────────── @then("I wait {seconds:f} seconds for TTL to expire") def step_wait_ttl(context: Any, seconds: float) -> None: time.sleep(seconds) # ── Invalidation ─────────────────────────────────────────────────────────── @when("I invalidate the standard package from cache") def step_invalidate_standard(context: Any) -> None: async def _inv() -> None: context._invalidate_result = await context.cache.invalidate( context._current_standard_pkg_id ) _run_async(context, _inv()) @when("I invalidate a missing cache entry") def step_invalidate_missing(context: Any) -> None: async def _inv() -> None: context._invalidate_result = await context.cache.invalidate( "pkg_act_0000000000000000000000000000000000000000" ) _run_async(context, _inv()) @then("the cache should report the entry was NOT invalidated") def step_check_not_invalidated(context: Any) -> None: assert context._invalidate_result is False # ── Clear ────────────────────────────────────────────────────────────────── @when("I clear the cache") def step_clear_cache(context: Any) -> None: async def _clear() -> None: await context.cache.clear() _run_async(context, _clear()) # ── Wrapping RegistryClient ──────────────────────────────────────────────── @when("I create a RegistryCache wrapping a real RegistryClient") def step_create_cache_wrapping_real(context: Any) -> None: real_client = RegistryClient(base_url="https://registry.example.com") context.cache = RegistryCache(real_client) @then('the cache should have the same base URL "{expected_url}"') def step_check_base_url_via_cache(context: Any, expected_url: str) -> None: assert context.cache is not None assert context.cache._client.base_url == expected_url.rstrip("/") # ── Async context manager ────────────────────────────────────────────────── @when("I use the RegistryCache as an async context manager") def step_async_context_manager(context: Any) -> None: async def _use_context() -> None: mock_client = MagicMock(spec=RegistryClient) mock_client.get_package = context._upstream_mock mock_client.close = AsyncMock() mock_client._get_client = MagicMock() async with RegistryCache(mock_client) as cache: context._ctx_cache = cache context._ctx_client = mock_client pkg_id = context._current_standard_pkg_id await cache.get_package(pkg_id) _run_async(context, _use_context()) @then("the underlying client should be closed after context exit") def step_client_closed(context: Any) -> None: context._ctx_client.close.assert_called_once() # ── Error propagation ────────────────────────────────────────────────────── @when("I call cache get_package for the standard package and catch the error") def step_call_standard_catch(context: Any) -> None: try: async def _call() -> None: await context.cache.get_package(context._current_standard_pkg_id) _run_async(context, _call()) except Exception as exc: context.error = exc @when("I try to call cache get_package with an invalid package ID") def step_call_invalid_id(context: Any) -> None: try: async def _call() -> None: await context.cache.get_package("not-a-valid-id") _run_async(context, _call()) except Exception as exc: context.error = exc @then("an InvalidPackageIdError should be raised by the registry cache") def step_check_invalid_package_id_error(context: Any) -> None: assert context.error is not None assert isinstance(context.error, InvalidPackageIdError), ( f"Expected InvalidPackageIdError, got {type(context.error).__name__}" ) @then("a PackageNotFoundError should be raised by the registry cache") def step_check_not_found_error(context: Any) -> None: assert context.error is not None assert isinstance(context.error, PackageNotFoundError), ( f"Expected PackageNotFoundError, got {type(context.error).__name__}" ) @then("a RegistryNetworkError should be raised by the registry cache") def step_check_network_error(context: Any) -> None: assert context.error is not None assert isinstance(context.error, RegistryNetworkError), ( f"Expected RegistryNetworkError, got {type(context.error).__name__}" )