Files
CoreRasurae 054b432197
CI / lint (pull_request) Successful in 56s
CI / typecheck (pull_request) Successful in 56s
CI / security (pull_request) Successful in 1m2s
CI / quality (pull_request) Successful in 41s
CI / integration_tests (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 45s
CI / unit_tests (pull_request) Successful in 3m29s
CI / coverage (pull_request) Successful in 3m14s
CI / status-check (pull_request) Successful in 4s
CI / lint (push) Successful in 48s
CI / typecheck (push) Successful in 58s
CI / security (push) Successful in 1m1s
CI / quality (push) Successful in 39s
CI / integration_tests (push) Successful in 1m15s
CI / build (push) Successful in 41s
CI / unit_tests (push) Successful in 3m31s
CI / coverage (push) Successful in 3m27s
CI / status-check (push) Successful in 3s
feat(registry): implement RegistryCache with LRU eviction and TTL
- Add RegistryCache with LRU eviction, TTL expiry, SHA-1 content validation
 - Add CacheFactory for centralised cache configuration (Factory Method pattern)
 - Add CacheStats dataclass for hit/miss/eviction observability
 - Implement singleflight coalescing for concurrent-miss protection
 - Use tuple keys for resolve store to prevent separator collision
 - Fix cancellation-safe singleflight via plain Future + asyncio.shield
 - Independent max_size budgets per store (content + resolve)
 - Concurrent test assertions strengthened
 - All test imports moved to module level per CONTRIBUTING.md
 - Unused dead code removed from tests

 ISSUES CLOSED: #28
2026-06-11 19:26:04 +01:00

529 lines
19 KiB
Python

"""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__}"
)