Files
temp/features/steps/service_retry_wiring_coverage_steps.py
freemo 051ee7c290 test(coverage): add Behave BDD tests to improve coverage across 52 source files
Added 52 new .feature files and corresponding _steps.py files targeting
previously uncovered code paths in the following areas:

- TUI layer: app, commands, persona (state/schema/registry), widgets,
  input (shell_exec, reference_parser)
- Application services: plan lifecycle/service/executor, session,
  project, repo indexing, correction, checkpoint, actor, llm_actors,
  strategy coordinator, resource file watcher, service retry wiring
- CLI commands: session, resource, repl, plan, db, automation_profile
- Domain models: retry_policy, resource_type, cost_budget,
  docker_compose_analyzer, detail_level, _sql_string_aware,
  _postgresql_helpers
- Core: circuit_breaker, retry_service_patterns
- Infrastructure: repositories, transaction_sandbox, strategy_registry,
  plugins/loader, container
- Config: settings
- Agents: plan_generation, context_analysis, auto_debug
- A2A: facade

All new tests follow the Behave/Gherkin BDD standard. Resolved step
definition collisions with unique prefixes. Fixed Alembic fileConfig
logger disabling issue (disable_existing_loggers=False).

ISSUES CLOSED: #1068
2026-03-20 21:22:10 +00:00

479 lines
17 KiB
Python

"""Step definitions for service_retry_wiring_coverage.feature.
Targets uncovered lines in service_retry_wiring.py:
- Line 149, 220: _apply_settings_defaults with only_services parameter
- Lines 252-254: _apply_config_overrides inner TypeError fallback
- Line 262: _build_cached_wait with string backoff_strategy
- Line 303: _get_or_create_cb returns None when CB disabled
- Line 308: _get_or_create_cb double-check after lock
- Lines 385-387: execute() TypeError on async callable
- Line 407: execute() nesting guard with no CB
- Line 441: execute() retry loop with no CB
- Line 521: async_execute() nesting guard with no CB
- Line 553: async_execute() retry loop with no CB
- Line 625: wrap_service_method with string backoff_strategy
"""
import asyncio
import json
import os
from behave import given, then, when
from cleveragents.application.services import service_retry_wiring as srw_module
from cleveragents.application.services.service_retry_wiring import ServiceRetryWiring
from cleveragents.config.settings import Settings
from cleveragents.core.retry_patterns import _retry_depth
from cleveragents.domain.models.core.retry_policy import (
CircuitBreakerConfig,
RetryPolicyConfig,
RetryStrategy,
ServiceRetryPolicy,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the service retry wiring module is imported")
def step_module_imported(context):
"""Verify the module is importable."""
assert ServiceRetryWiring is not None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_settings_via_env(**env_overrides) -> Settings:
"""Create a Settings instance using environment variables for overrides.
pydantic-settings prioritises env vars over constructor kwargs, so we
must set env vars to override field defaults.
"""
env_map = {
"retry_max_attempts": "CLEVERAGENTS_RETRY_MAX_ATTEMPTS",
"retry_base_delay": "CLEVERAGENTS_RETRY_BASE_DELAY",
"retry_service_overrides": "CLEVERAGENTS_RETRY_SERVICE_OVERRIDES",
}
saved = {}
for key, value in env_overrides.items():
env_key = env_map.get(key, f"CLEVERAGENTS_{key.upper()}")
saved[env_key] = os.environ.get(env_key)
os.environ[env_key] = str(value)
try:
return Settings(env="test", mock_providers=True)
finally:
for env_key, old_val in saved.items():
if old_val is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_val
def _make_default_settings() -> Settings:
"""Create a Settings instance with all defaults."""
return Settings(env="test", mock_providers=True)
# ---------------------------------------------------------------------------
# Line 149, 220: Config overrides add new service, global defaults re-applied
# ---------------------------------------------------------------------------
@given("settings with a non-default retry_max_attempts of {n:d}")
def step_settings_non_default_max_attempts(context, n):
context.custom_max_attempts = n
@given('settings with retry_service_overrides introducing a new service "{svc_name}"')
def step_settings_with_new_service_override(context, svc_name):
overrides = {svc_name: {"retry": {"base_delay": 0.5, "max_delay": 5.0}}}
context.custom_settings = _make_settings_via_env(
retry_max_attempts=context.custom_max_attempts,
retry_service_overrides=json.dumps(overrides),
)
@when("I create a ServiceRetryWiring from those settings")
def step_create_wiring_from_settings(context):
context.wiring = ServiceRetryWiring(context.custom_settings)
@then('the policy for "{svc_name}" should have max_attempts of {n:d}')
def step_verify_policy_max_attempts(context, svc_name, n):
policy = context.wiring.get_policy(svc_name)
assert policy.retry.max_attempts == n, (
f"Expected max_attempts={n}, got {policy.retry.max_attempts}"
)
# ---------------------------------------------------------------------------
# Lines 252-254: Invalid JSON + logger.warning raises TypeError
# ---------------------------------------------------------------------------
@given("settings with invalid JSON in retry_service_overrides")
def step_settings_invalid_json(context):
context.invalid_json_settings = _make_settings_via_env(
retry_service_overrides="{this is not valid json!!!}",
)
@given("a logger that raises TypeError on warning calls")
def step_patch_logger_typeerror(context):
"""Install a logger whose .warning() always raises TypeError."""
class TypeErrorLogger:
def debug(self, *a, **kw):
pass
def info(self, *a, **kw):
pass
def warning(self, *a, **kw):
raise TypeError("simulated logger TypeError")
def error(self, *a, **kw):
pass
context.original_logger = srw_module.logger
context.type_error_logger = TypeErrorLogger()
@when("I create a ServiceRetryWiring with the patched logger")
def step_create_wiring_patched_logger(context):
original = srw_module.logger
srw_module.logger = context.type_error_logger
try:
context.wiring = ServiceRetryWiring(context.invalid_json_settings)
context.construction_error = None
except Exception as exc:
context.construction_error = exc
finally:
srw_module.logger = original
@then("no exception should be raised during construction")
def step_verify_no_construction_error(context):
assert context.construction_error is None, (
f"Expected no error, got {context.construction_error!r}"
)
# ---------------------------------------------------------------------------
# Line 262: _build_cached_wait with plain string backoff_strategy
# ---------------------------------------------------------------------------
@given('a ServiceRetryPolicy with a plain string backoff_strategy "{strategy}"')
def step_policy_with_string_backoff(context, strategy):
policy = ServiceRetryPolicy(
service_name="string_backoff_svc",
retry=RetryPolicyConfig(
max_attempts=2,
base_delay=0.1,
max_delay=1.0,
backoff_strategy=RetryStrategy.EXPONENTIAL,
),
)
# Force the backoff_strategy to a plain string to bypass enum
object.__setattr__(policy.retry, "backoff_strategy", strategy)
context.string_backoff_policy = policy
@when("I call _build_cached_wait with the string-strategy policy")
def step_call_build_cached_wait(context):
context.wait_strategy = ServiceRetryWiring._build_cached_wait(
context.string_backoff_policy
)
@then("a valid wait strategy should be returned")
def step_verify_wait_strategy(context):
assert context.wait_strategy is not None
# ---------------------------------------------------------------------------
# Line 303: _get_or_create_cb returns None when CB disabled
# ---------------------------------------------------------------------------
@given("a ServiceRetryWiring with a service that has circuit breaker disabled")
def step_wiring_with_disabled_cb(context):
settings = _make_default_settings()
context.wiring = ServiceRetryWiring(settings)
# Register a service with CB disabled
disabled_cb_policy = ServiceRetryPolicy(
service_name="no_cb_service",
retry=RetryPolicyConfig(max_attempts=2, base_delay=0.01, max_delay=0.1),
circuit_breaker=CircuitBreakerConfig(enabled=False),
)
context.wiring._registry.register(disabled_cb_policy)
@when("I call _get_or_create_cb for the disabled-CB service")
def step_call_get_or_create_cb_disabled(context):
context.cb_result = context.wiring._get_or_create_cb("no_cb_service")
@then("srwcov the result should be None")
def step_verify_cb_none(context):
assert context.cb_result is None
# ---------------------------------------------------------------------------
# Line 308: _get_or_create_cb double-check path
# ---------------------------------------------------------------------------
@given("a ServiceRetryWiring with default settings")
def step_wiring_default_settings(context):
settings = _make_default_settings()
context.wiring = ServiceRetryWiring(settings)
@when("I concurrently request the circuit breaker for a new service with CB enabled")
def step_concurrent_cb_request(context):
"""Simulate the double-check-after-lock path by using threading to
insert a CB between the initial dict miss and the lock acquisition."""
svc_name = "double_check_svc"
# Ensure the service has a policy with CB enabled
policy = ServiceRetryPolicy(
service_name=svc_name,
retry=RetryPolicyConfig(max_attempts=2, base_delay=0.01, max_delay=0.1),
circuit_breaker=CircuitBreakerConfig(enabled=True),
)
context.wiring._registry.register(policy)
# First call creates the CB normally
cb1 = context.wiring._get_or_create_cb(svc_name)
assert cb1 is not None
# Now simulate the double-check race condition (line 308):
# Remove the CB from the fast dict, then use a thread to re-insert
# it before the lock-guarded second check in _get_or_create_cb.
existing_cb = context.wiring._circuit_breakers.pop(svc_name)
# Replace _cache_lock with a wrapper that injects the CB before
# the code inside the `with` block runs.
original_lock = context.wiring._cache_lock
class InjectingLock:
"""A lock wrapper that injects the CB after acquire but before
the inner code checks the dict again (line 306-308)."""
def __init__(self, real_lock, inject_fn):
self._real_lock = real_lock
self._inject_fn = inject_fn
def acquire(self, *args, **kwargs):
result = self._real_lock.acquire(*args, **kwargs)
self._inject_fn()
return result
def release(self, *args, **kwargs):
return self._real_lock.release(*args, **kwargs)
def __enter__(self):
self.acquire()
return self
def __exit__(self, *args):
self.release()
def inject_cb():
context.wiring._circuit_breakers[svc_name] = existing_cb
context.wiring._cache_lock = InjectingLock(original_lock, inject_cb)
try:
result = context.wiring._get_or_create_cb(svc_name)
finally:
context.wiring._cache_lock = original_lock
context.double_check_cb = result
context.existing_cb = existing_cb
@then("the circuit breaker should be created once and cached")
def step_verify_cb_cached(context):
# The double-check path should return the pre-existing CB
assert context.double_check_cb is context.existing_cb
# ---------------------------------------------------------------------------
# Lines 385-387: execute() with async callable raises TypeError
# ---------------------------------------------------------------------------
@given("an async callable function")
def step_async_callable(context):
async def async_fn():
return "async_result"
context.async_fn = async_fn
@when("I call sync execute with the async callable")
def step_call_sync_execute_with_async(context):
try:
context.wiring.execute(
service_name="plan_service",
operation_name="test_op",
func=context.async_fn,
)
context.execute_error = None
except TypeError as exc:
context.execute_error = exc
@then("a TypeError should be raised mentioning async_execute")
def step_verify_type_error(context):
assert context.execute_error is not None
assert "async_execute" in str(context.execute_error)
# ---------------------------------------------------------------------------
# Line 407: execute() nesting guard with no CB
# ---------------------------------------------------------------------------
@given("the retry nesting depth is at the maximum")
def step_set_max_nesting_depth(context):
"""Set the retry depth context var to the maximum nesting depth."""
context.depth_token = _retry_depth.set(srw_module.MAX_RETRY_NESTING_DEPTH)
def restore():
_retry_depth.reset(context.depth_token)
context.add_cleanup(restore)
@when("I call execute for the disabled-CB service with a simple function")
def step_execute_nesting_guard_no_cb(context):
def simple_fn():
return "nesting_guard_result"
context.execute_result = context.wiring.execute(
service_name="no_cb_service",
operation_name="test_op",
func=simple_fn,
)
@then("the function should be called directly without retry wrapping")
def step_verify_nesting_guard_result(context):
assert context.execute_result == "nesting_guard_result"
# ---------------------------------------------------------------------------
# Line 441: execute() retry loop with no CB
# ---------------------------------------------------------------------------
@when("I call execute for the disabled-CB service with a simple function normally")
def step_execute_no_cb_normal(context):
def simple_fn():
return "no_cb_result"
context.execute_result = context.wiring.execute(
service_name="no_cb_service",
operation_name="test_op",
func=simple_fn,
)
@then("the function result should be returned successfully")
def step_verify_no_cb_result(context):
assert context.execute_result == "no_cb_result"
# ---------------------------------------------------------------------------
# Line 521: async_execute() nesting guard with no CB
# ---------------------------------------------------------------------------
@when("I call async_execute for the disabled-CB service with an async function")
def step_async_execute_nesting_guard_no_cb(context):
async def async_fn():
return "async_nesting_guard_result"
loop = asyncio.new_event_loop()
try:
context.async_result = loop.run_until_complete(
context.wiring.async_execute(
service_name="no_cb_service",
operation_name="test_op",
func=async_fn,
)
)
finally:
loop.close()
@then("the async function should be called directly without retry wrapping")
def step_verify_async_nesting_guard_result(context):
assert context.async_result == "async_nesting_guard_result"
# ---------------------------------------------------------------------------
# Line 553: async_execute() retry loop with no CB
# ---------------------------------------------------------------------------
@when(
"I call async_execute for the disabled-CB service with an async function normally"
)
def step_async_execute_no_cb_normal(context):
async def async_fn():
return "async_no_cb_result"
loop = asyncio.new_event_loop()
try:
context.async_result = loop.run_until_complete(
context.wiring.async_execute(
service_name="no_cb_service",
operation_name="test_op",
func=async_fn,
)
)
finally:
loop.close()
@then("the async function result should be returned successfully")
def step_verify_async_no_cb_result(context):
assert context.async_result == "async_no_cb_result"
# ---------------------------------------------------------------------------
# Line 625: wrap_service_method with string backoff_strategy
# ---------------------------------------------------------------------------
@given("a ServiceRetryWiring where a service has a plain string backoff_strategy")
def step_wiring_with_string_backoff(context):
settings = _make_default_settings()
context.wiring = ServiceRetryWiring(settings)
# Create a policy with a string backoff_strategy and CB disabled
# (CB disabled avoids the _cache_lock deadlock inside wrap_service_method).
policy = ServiceRetryPolicy(
service_name="str_backoff_wrap_svc",
retry=RetryPolicyConfig(
max_attempts=2,
base_delay=0.01,
max_delay=0.1,
backoff_strategy=RetryStrategy.EXPONENTIAL,
),
circuit_breaker=CircuitBreakerConfig(enabled=False),
)
# Force backoff_strategy to a plain string to exercise line 625
object.__setattr__(policy.retry, "backoff_strategy", "exponential")
context.string_policy_for_wrap = policy
@when("I call wrap_service_method for that service")
def step_call_wrap_service_method(context):
# Patch registry.get to return our policy with string backoff_strategy.
# We must patch at the registry level because registry.get() deep-copies
# the stored policy, which would re-validate and coerce the string back
# to the RetryStrategy enum.
original_get = context.wiring._registry.get
def patched_get(service_name):
if service_name == "str_backoff_wrap_svc":
return context.string_policy_for_wrap
return original_get(service_name)
context.wiring._registry.get = patched_get
try:
context.decorator = context.wiring.wrap_service_method(
service_name="str_backoff_wrap_svc",
operation_name="test_op",
)
finally:
context.wiring._registry.get = original_get
@then("a callable decorator should be returned")
def step_verify_decorator_returned(context):
assert callable(context.decorator)