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
- 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
529 lines
19 KiB
Python
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__}"
|
|
)
|