Files
cleveractors-core/features/steps/registry_exceptions_steps.py
CoreRasurae e8bd348c77
CI / lint (pull_request) Successful in 41s
CI / typecheck (pull_request) Successful in 56s
CI / security (pull_request) Successful in 51s
CI / quality (pull_request) Successful in 53s
CI / integration_tests (pull_request) Successful in 1m10s
CI / build (pull_request) Successful in 43s
CI / unit_tests (pull_request) Successful in 3m30s
CI / coverage (pull_request) Successful in 3m22s
CI / status-check (pull_request) Successful in 4s
CI / quality (push) Successful in 45s
CI / security (push) Successful in 48s
CI / lint (push) Successful in 49s
CI / typecheck (push) Successful in 50s
CI / build (push) Successful in 49s
CI / integration_tests (push) Successful in 1m9s
CI / unit_tests (push) Successful in 3m13s
CI / coverage (push) Successful in 3m7s
CI / status-check (push) Successful in 3s
feat(registry): implement RegistryError hierarchy with typed exceptions
Add RegistryError base with message, details, and original_reference fields.
Implement all 9 typed exception types per Package Registry Standard §13.2:
PackageNotFoundError, InvalidPackageIdError, InvalidPackageReferenceError,
VersionNotFoundError, ValidationError, AuthenticationRequiredError,
AccessDeniedError, ConflictError, and RegistryNetworkError.

RegistryNetworkError carries status_code and url for transport failures.
__str__ includes original_reference when available.
exception_for_status() maps HTTP codes to typed exceptions.
_ERROR_TYPE_MAP enables error-type parsing from JSON error bodies.

23 Behave BDD scenarios in features/registry_exceptions.feature.
12 Robot Framework integration tests in robot/exceptions.robot.
Existing registry_http_client tests updated for InternalServerError removal.

ISSUES CLOSED: #29
2026-06-11 20:44:54 +01:00

301 lines
10 KiB
Python

"""
Step definitions for Registry Exception Hierarchy BDD tests.
"""
from __future__ import annotations
import json
from typing import cast
from behave import given, then, when
from behave.runner import Context
from cleveractors.core.exceptions import CleverAgentsException
from cleveractors.registry.exceptions import (
_ERROR_TYPE_MAP,
AccessDeniedError,
AuthenticationRequiredError,
ConflictError,
InvalidPackageIdError,
InvalidPackageReferenceError,
PackageNotFoundError,
RegistryError,
RegistryNetworkError,
ValidationError,
VersionNotFoundError,
exception_for_status,
)
_CLASS_MAP: dict[str, type[CleverAgentsException]] = {
"CleverAgentsException": CleverAgentsException,
"RegistryError": RegistryError,
"PackageNotFoundError": PackageNotFoundError,
"InvalidPackageIdError": InvalidPackageIdError,
"InvalidPackageReferenceError": InvalidPackageReferenceError,
"VersionNotFoundError": VersionNotFoundError,
"ValidationError": ValidationError,
"AuthenticationRequiredError": AuthenticationRequiredError,
"AccessDeniedError": AccessDeniedError,
"ConflictError": ConflictError,
"RegistryNetworkError": RegistryNetworkError,
}
@given("I have imported the registry exception module")
def step_import_exception_module(context: Context) -> None:
context._checks_passed = 0
# ── Construction steps ──────────────────────────────────────────────────────
@when('I raise a plain RegistryError with text "{message}"')
def step_raise_registry_error_message(context: Context, message: str) -> None:
context.error = RegistryError(message)
@when('I raise a RegistryError text "{message}" and detail {details_json}')
def step_raise_registry_error_message_details(
context: Context, message: str, details_json: str
) -> None:
context.error = RegistryError(message, details=json.loads(details_json))
@when('I raise a RegistryError text "{message}" and reference "{ref}"')
def step_raise_registry_error_message_ref(
context: Context, message: str, ref: str
) -> None:
context.error = RegistryError(message, original_reference=ref)
@when(
'I raise a RegistryError text "{message}" detail {details_json} and reference "{ref}"'
)
def step_raise_registry_error_full(
context: Context, message: str, details_json: str, ref: str
) -> None:
context.error = RegistryError(
message, details=json.loads(details_json), original_reference=ref
)
@when(
'I raise a simple network error message "{message}" status {status_code} url "{url}"'
)
def step_raise_network_error_basic(
context: Context, message: str, status_code: str, url: str
) -> None:
sc = int(status_code)
context.error = RegistryNetworkError(message, status_code=sc, url=url)
@when(
'I raise a fully detailed network error message "{message}" detail {details_json} ref "{ref}" status {status_code} url "{url}"'
)
def step_raise_network_error_full(
context: Context,
message: str,
details_json: str,
ref: str,
status_code: str,
url: str,
) -> None:
sc = None if status_code == "None" else int(status_code)
context.error = RegistryNetworkError(
message,
details=json.loads(details_json),
original_reference=ref,
status_code=sc,
url=url,
)
# ── Assertion steps ─────────────────────────────────────────────────────────
@then('the error message should be "{expected}"')
def step_error_message(context: Context, expected: str) -> None:
assert context.error.message == expected, (
f"Expected message {expected!r}, got {context.error.message!r}"
)
@then("the error should have no details")
def step_error_no_details(context: Context) -> None:
assert context.error.details is None, (
f"Expected details=None, got {context.error.details!r}"
)
@then("the error should have no original_reference")
def step_error_no_ref(context: Context) -> None:
assert context.error.original_reference is None, (
f"Expected original_reference=None, got {context.error.original_reference!r}"
)
@then("the error details should equal {expected_json}")
def step_error_details(context: Context, expected_json: str) -> None:
expected = json.loads(expected_json)
assert context.error.details == expected, (
f"Expected details {expected!r}, got {context.error.details!r}"
)
@then('the error original_reference should be "{expected}"')
def step_error_ref(context: Context, expected: str) -> None:
assert context.error.original_reference == expected, (
f"Expected original_reference {expected!r}, "
f"got {context.error.original_reference!r}"
)
@then('str of the error should be "{expected}"')
def step_error_str(context: Context, expected: str) -> None:
assert str(context.error) == expected, (
f"Expected str {expected!r}, got {str(context.error)!r}"
)
@then("the error status_code should be {expected}")
def step_error_status_code(context: Context, expected: str) -> None:
exp = None if expected == "None" else int(expected)
assert context.error.status_code == exp, (
f"Expected status_code {exp!r}, got {context.error.status_code!r}"
)
@then('the error url should be "{expected}"')
def step_error_url(context: Context, expected: str) -> None:
assert context.error.url == expected, (
f"Expected url {expected!r}, got {context.error.url!r}"
)
# ── exception_for_status steps ──────────────────────────────────────────────
@when('I call exception_for_status with code {code:d} and message "{message}"')
def step_call_exception_for_status(context: Context, code: int, message: str) -> None:
context.result = exception_for_status(code, message)
@when(
'I call exception_for_status with non-integer code "{code}" and message "{message}"'
)
def step_call_exception_for_status_non_int(
context: Context, code: str, message: str
) -> None:
# Intentionally non-int to exercise the isinstance guard in exception_for_status
context.result = exception_for_status(cast(int, code), message)
@then("the result should be an instance of {cls_name}")
def step_result_instance_of(context: Context, cls_name: str) -> None:
expected_cls = _CLASS_MAP[cls_name]
assert isinstance(context.result, expected_cls), (
f"Expected isinstance of {expected_cls.__name__}, "
f"got {type(context.result).__name__}"
)
context.error = context.result
# ── Inheritance chain steps ─────────────────────────────────────────────────
@when("I check that {child} is a subclass of {parent}")
def step_check_subclass(context: Context, child: str, parent: str) -> None:
child_cls = _CLASS_MAP[child]
parent_cls = _CLASS_MAP[parent]
assert issubclass(child_cls, parent_cls), (
f"{child_cls.__name__} is not a subclass of {parent_cls.__name__}"
)
context._checks_passed += 1
@then("all {count:d} checks should pass")
def step_all_checks_pass(context: Context, count: int) -> None:
assert context._checks_passed == count, (
f"Expected {count} inheritance checks, got {context._checks_passed}"
)
# ── Error type map steps ────────────────────────────────────────────────────
@when("I inspect the _ERROR_TYPE_MAP")
def step_inspect_map(context: Context) -> None:
context._error_map = _ERROR_TYPE_MAP
@then('the map should contain key "{key}"')
def step_map_has_key(context: Context, key: str) -> None:
assert key in context._error_map, f"Expected _ERROR_TYPE_MAP to contain key {key!r}"
@then("the map should have exactly {count:d} keys")
def step_map_cardinality(context: Context, count: int) -> None:
actual = len(context._error_map)
assert actual == count, (
f"Expected _ERROR_TYPE_MAP to have {count} keys, got {actual}"
)
@then('the value for key "{key}" should be {value_cls}')
def step_map_value(context: Context, key: str, value_cls: str) -> None:
expected = _CLASS_MAP[value_cls]
assert context._error_map[key] is expected, (
f"Expected _ERROR_TYPE_MAP[{key!r}] = {expected.__name__}, "
f"got {context._error_map[key].__name__}"
)
# ── Typed subclass construction steps ────────────────────────────────────────
@when('I raise a PackageNotFoundError with message "{message}"')
def step_raise_package_not_found_error(context: Context, message: str) -> None:
context.error = PackageNotFoundError(message)
@when('I raise an InvalidPackageIdError with message "{message}"')
def step_raise_invalid_id_error(context: Context, message: str) -> None:
context.error = InvalidPackageIdError(message)
@when('I raise an InvalidPackageReferenceError with message "{message}"')
def step_raise_invalid_ref_error(context: Context, message: str) -> None:
context.error = InvalidPackageReferenceError(message)
@when('I raise a VersionNotFoundError with message "{message}"')
def step_raise_version_not_found_error(context: Context, message: str) -> None:
context.error = VersionNotFoundError(message)
@when('I raise a ValidationError with message "{message}"')
def step_raise_validation_error(context: Context, message: str) -> None:
context.error = ValidationError(message)
@when('I raise an AuthenticationRequiredError with message "{message}"')
def step_raise_auth_required_error(context: Context, message: str) -> None:
context.error = AuthenticationRequiredError(message)
@when('I raise an AccessDeniedError with message "{message}"')
def step_raise_access_denied_error(context: Context, message: str) -> None:
context.error = AccessDeniedError(message)
@when('I raise a ConflictError with message "{message}"')
def step_raise_conflict_error(context: Context, message: str) -> None:
context.error = ConflictError(message)
@then("the error should be an instance of {cls_name}")
def step_error_is_instance(context: Context, cls_name: str) -> None:
assert isinstance(context.error, _CLASS_MAP[cls_name]), (
f"Expected {cls_name}, got {type(context.error).__name__}"
)