diff --git a/pyproject.toml b/pyproject.toml index d4835fa4..be1e791d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "opentelemetry-instrumentation-flask~=0.65b0", "mcp>=1.1.0,<3", "python-odata>=0.7.0", + "cloudevents>=2.2.0,<3", "cryptography>=48.0.1,<51", ] diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index 58ac0177..6521881a 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -40,11 +40,17 @@ CBCError, CBCClientError, CBCConfigError, + CBCEventError, CBCHttpError, CBCNetworkError, CBCServerError, HttpContext, ) +from sap_cloud_sdk.cbc._events import ( + CBCEventHandler, + ConsumptionVersionEvent, + FeatureActivationEvent, +) from sap_cloud_sdk.cbc._models import ( ApiError, ConfigurationData, @@ -53,6 +59,9 @@ ConsumptionVersion, ConsumptionVersions, EntityData, + FeatureActivationData, + FeatureStatus, + FeatureStatuses, ) @@ -67,13 +76,22 @@ "CBCError", "CBCClientError", "CBCConfigError", + "CBCEventError", "CBCHttpError", "CBCNetworkError", "CBCServerError", "HttpContext", + # event handler + "CBCEventHandler", + "ConsumptionVersionEvent", + "FeatureActivationData", + "FeatureActivationEvent", # models — consumption versions "ConsumptionVersion", "ConsumptionVersions", + # models — feature status + "FeatureStatus", + "FeatureStatuses", # models — entities "EntityData", "ConfigurationEntity", diff --git a/src/sap_cloud_sdk/cbc/_events.py b/src/sap_cloud_sdk/cbc/_events.py new file mode 100644 index 00000000..bd39682e --- /dev/null +++ b/src/sap_cloud_sdk/cbc/_events.py @@ -0,0 +1,224 @@ +"""CBC CloudEvent parsing and dispatch.""" + +from __future__ import annotations + +from collections.abc import Callable + +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.bindings.http import HTTPMessage, from_http_event +from pydantic import ValidationError + +from sap_cloud_sdk.cbc._models import ( + ConsumptionVersionEvent, + FeatureActivationData, + FeatureActivationEvent, + _ConsumptionVersionPayload, + _FeatureActivationItem, + _TenantContext, +) +from sap_cloud_sdk.cbc.exceptions import CBCEventError +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + +# --------------------------------------------------------------------------- +# Event type constants +# --------------------------------------------------------------------------- + +_CV_CREATED = "cbc.notification.consumptionversion.created" +_CV_UPDATED = "cbc.notification.consumptionversion.updated" +_FA_ACTIVATION = "cbc.notification.feature.activation.v1" + + +def _require(data: dict, key: str, event_type: str) -> object: + """Return ``data[key]``, raising ``CBCEventError`` if the key is absent.""" + value = data.get(key) + if value is None: + raise CBCEventError( + f"CBC event '{event_type}' is missing required field '{key}'" + ) + return value + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + +ConsumptionVersionCallback = Callable[[ConsumptionVersionEvent], None] +FeatureActivationCallback = Callable[[FeatureActivationEvent], None] + + +class CBCEventHandler: + """Parses and dispatches incoming CBC CloudEvents to registered callbacks. + + Register one callback per event category, then call :meth:`handle_request` + from your webhook endpoint for every incoming POST:: + + handler = cbc.CBCEventHandler() + + def on_cv(event: cbc.ConsumptionVersionEvent) -> None: + ... + + def on_fa(event: cbc.FeatureActivationEvent) -> None: + for f in event.features: + update_cache(f.feature_id, f.enabled) + + handler.on_consumption_version_event(on_cv) + handler.on_feature_activation_event(on_fa) + + # In your webhook route: + handler.handle_request(request.headers, request.body) + + Raises: + CBCEventError: If the incoming request cannot be parsed as a valid + CloudEvent, or if the event data is structurally invalid for a + known event type. Unknown event types are silently ignored. + """ + + def __init__(self) -> None: + self._cv_callback: ConsumptionVersionCallback | None = None + self._fa_callback: FeatureActivationCallback | None = None + + @staticmethod + def _parse_cloud_event( + headers: dict[str, str], body: str | bytes + ) -> BaseCloudEvent: + """Parse raw HTTP headers and body into a :class:`BaseCloudEvent`. + + Raises: + CBCEventError: If the request cannot be parsed as a valid CloudEvent. + """ + raw = body.encode() if isinstance(body, str) else body + try: + return from_http_event(HTTPMessage(headers, raw)) + except Exception as exc: + raise CBCEventError( + f"Failed to parse incoming request as a CloudEvent: {exc}" + ) from exc + + @staticmethod + def _extract_app_tenant_id(raw_data: dict, event_type: str) -> str: + """Extract the app tenant id from the event context block. + + Raises: + CBCEventError: If the context field is absent or structurally invalid. + """ + try: + context = _TenantContext.model_validate( + _require(raw_data, "context", event_type) + ) + except ValidationError as exc: + raise CBCEventError( + f"CBC event '{event_type}' data is invalid: {exc}" + ) from exc + return context.app_tenant_id + + @staticmethod + def _parse_consumption_version_event(ce: BaseCloudEvent) -> ConsumptionVersionEvent: + """Parse a consumption-version CloudEvent into a :class:`ConsumptionVersionEvent`. + + Raises: + CBCEventError: If the data block is missing or structurally invalid. + """ + event_type = ce.get_type() + raw_data = ce.get_data() + if not isinstance(raw_data, dict): + raise CBCEventError(f"CBC event '{event_type}' has no parseable data block") + + context = CBCEventHandler._extract_app_tenant_id(raw_data, event_type) + try: + consumption = _ConsumptionVersionPayload.model_validate( + _require(raw_data, "consumption", event_type) + ) + except ValidationError as exc: + raise CBCEventError( + f"CBC event '{event_type}' data is invalid: {exc}" + ) from exc + + return ConsumptionVersionEvent( + app_tenant_id=context, + consumption_version_id=consumption.consumption_version_id, + ) + + @staticmethod + def _parse_feature_activation_event(ce: BaseCloudEvent) -> FeatureActivationEvent: + """Parse a feature-activation CloudEvent into a :class:`FeatureActivationEvent`. + + Raises: + CBCEventError: If the data block is missing, the context is absent, or + the features list is missing or empty. + """ + event_type = ce.get_type() + raw_data = ce.get_data() + if not isinstance(raw_data, dict): + raise CBCEventError(f"CBC event '{event_type}' has no parseable data block") + + app_tenant_id = CBCEventHandler._extract_app_tenant_id(raw_data, event_type) + + raw_features = raw_data.get("features") + if not raw_features: + raise CBCEventError(f"CBC event '{event_type}' has no features") + + try: + items = [_FeatureActivationItem.model_validate(f) for f in raw_features] + except ValidationError as exc: + raise CBCEventError( + f"CBC event '{event_type}' features are invalid: {exc}" + ) from exc + + features = [ + FeatureActivationData(feature_id=f.feature_id, enabled=f.enabled) + for f in items + ] + return FeatureActivationEvent(app_tenant_id=app_tenant_id, features=features) + + def on_consumption_version_event( + self, callback: ConsumptionVersionCallback + ) -> None: + """Register a callback for consumption-version events (created and updated). + + Args: + callback: Callable invoked with a :class:`ConsumptionVersionEvent` for + each ``consumptionversion.created`` or ``consumptionversion.updated`` + event received. Replaces any previously registered callback. + """ + self._cv_callback = callback + + def on_feature_activation_event(self, callback: FeatureActivationCallback) -> None: + """Register a callback for feature-activation events. + + Args: + callback: Callable invoked with a :class:`FeatureActivationEvent` for + each ``feature.activation.v1`` event received. Replaces any + previously registered callback. + """ + self._fa_callback = callback + + @record_metrics(Module.CBC, Operation.CBC_HANDLE_EVENT) + def handle_request( + self, + headers: dict[str, str], + body: str | bytes, + ) -> None: + """Parse an incoming CBC CloudEvent and dispatch it to registered callbacks. + + Args: + headers: HTTP request headers (needed for binary-mode CloudEvents). + body: Raw HTTP request body. + + Raises: + CBCEventError: If the request is not a valid CloudEvent, or if the + event data is structurally invalid for a known CBC event type. + """ + ce = self._parse_cloud_event(headers, body) + event_type: str = ce.get_type() + + if event_type in (_CV_CREATED, _CV_UPDATED): + if self._cv_callback is None: + return + self._cv_callback(self._parse_consumption_version_event(ce)) + + elif event_type == _FA_ACTIVATION: + if self._fa_callback is None: + return + self._fa_callback(self._parse_feature_activation_event(ce)) + + # unknown event type: silently ignored diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index 672c8df5..38025234 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -67,6 +67,56 @@ def latest(self) -> ConsumptionVersion | None: return self.items[-1] +# --------------------------------------------------------------------------- +# Feature status models +# --------------------------------------------------------------------------- + + +class FeatureStatus(_FrozenModel): + """Activation status of one AI feature for an app tenant. + + Attributes: + feature_id: Feature identifier (e.g. ``"ai_assisted_supplier_bid_analysis"``). + enabled: Whether the feature is enabled for the tenant. + """ + + feature_id: str = Field(alias="featureId") + enabled: bool = False + + +class FeatureStatuses(_FrozenModel): + """Activation status of all AI features for an app tenant. + + Attributes: + features: List of :class:`FeatureStatus` objects. + """ + + features: list[FeatureStatus] + + def is_enabled(self, feature_id: str) -> bool: + """Return ``True`` if the feature is enabled, ``False`` if disabled or absent. + + Args: + feature_id: Feature identifier to look up. + + Returns: + ``True`` if the feature is enabled, ``False`` if disabled or not present. + """ + feature = next((f for f in self.features if f.feature_id == feature_id), None) + return feature.enabled if feature is not None else False + + def get(self, feature_id: str) -> FeatureStatus | None: + """Return the :class:`FeatureStatus` for ``feature_id``, or ``None`` if absent. + + Args: + feature_id: Feature identifier to look up. + + Returns: + Matching :class:`FeatureStatus`, or ``None`` if not found. + """ + return next((f for f in self.features if f.feature_id == feature_id), None) + + # --------------------------------------------------------------------------- # Entity models # --------------------------------------------------------------------------- @@ -246,6 +296,67 @@ def get_entity_data( return co.get_entity_data(entity_id) if co is not None else None +# --------------------------------------------------------------------------- +# Event models +# --------------------------------------------------------------------------- + + +class _TenantContext(_FrozenModel): + app_tenant_id: str = Field(alias="appTenantId") + + +class _ConsumptionVersionPayload(_FrozenModel): + consumption_version_id: str = Field(alias="consumptionVersionId", min_length=1) + + +@dataclass +class ConsumptionVersionEvent: + """Parsed CBC consumption-version CloudEvent. + + Attributes: + app_tenant_id: Application tenant id from the event context. + consumption_version_id: The new consumption version id. + """ + + app_tenant_id: str + consumption_version_id: str + + +@dataclass +class FeatureActivationData: + """Activation state of one AI feature from a feature activation event. + + Attributes: + feature_id: Feature identifier. + enabled: Whether the feature is enabled. + """ + + feature_id: str + enabled: bool + + +class _FeatureActivationItem(_FrozenModel): + feature_id: str = Field(alias="featureId") + enabled: bool + + +@dataclass +class FeatureActivationEvent: + """Parsed CBC feature-activation CloudEvent. + + Carries the app tenant id and the full list of feature activation states + for the tenant. Apply the state directly — no follow-up call to CBC is + required. + + Attributes: + app_tenant_id: Application tenant id from the event context. + features: All feature activation states for the tenant. + """ + + app_tenant_id: str + features: list[FeatureActivationData] + + # --------------------------------------------------------------------------- # API error model # --------------------------------------------------------------------------- diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index fa7ceea5..e4fb11c2 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -33,6 +33,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +from urllib.parse import quote as _url_quote import httpx @@ -44,6 +45,8 @@ ConfigurationObjectList, ConsumptionVersions, EntityData, + FeatureStatus, + FeatureStatuses, ) from sap_cloud_sdk.cbc.config import CBCConfig from sap_cloud_sdk.cbc.exceptions import ( @@ -126,6 +129,7 @@ class _ApiPaths: """API path configuration for a :class:`CBCClient` instance.""" configurations_path: str + features_path: str # --------------------------------------------------------------------------- @@ -181,6 +185,7 @@ def __init__( self._app_tenant_id = app_tenant_id self._config = _ApiPaths( configurations_path="/configuration/v1", + features_path="/feature/v1", ) self._ssl_factory = ssl_context self._lock = threading.Lock() @@ -394,6 +399,55 @@ def get_entity_data( entity_id, ).data + @record_metrics(Module.CBC, Operation.CBC_GET_FEATURE_STATUSES) + def get_feature_statuses(self) -> FeatureStatuses: + """Return the activation status of every AI feature for the configured tenant. + + Returns: + :class:`FeatureStatuses` with one :class:`FeatureStatus` per feature. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + base_url = self._base_url() + app_tenant_id = self._resolve_app_tenant_id() + url = self._features_url(base_url, f"/features?appTenantId={app_tenant_id}") + return FeatureStatuses.model_validate(self._request("GET", url).json()) + + @record_metrics(Module.CBC, Operation.CBC_GET_FEATURE_STATUS) + def get_feature_status(self, feature_id: str) -> FeatureStatus | None: + """Return the activation status of a single AI feature for the configured tenant. + + Queries the feature endpoint filtered to both the tenant and the feature, + so the server returns only the matching entry. + + Args: + feature_id: Feature identifier (e.g. ``"ai_assisted_supplier_bid_analysis"``). + + Returns: + The matching :class:`FeatureStatus`, or ``None`` if the feature is not + found for the tenant. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + base_url = self._base_url() + app_tenant_id = self._resolve_app_tenant_id() + url = self._features_url( + base_url, + f"/features?appTenantId={app_tenant_id}&featureId={_url_quote(feature_id, safe='')}", + ) + statuses = FeatureStatuses.model_validate(self._request("GET", url).json()) + return statuses.get(feature_id) + + def _features_url(self, base_url: str, path: str = "") -> str: + base = base_url.rstrip("/") + return f"{base}{self._config.features_path}{path}" + def _fetch_entity_data( self, base_url: str, diff --git a/src/sap_cloud_sdk/cbc/exceptions.py b/src/sap_cloud_sdk/cbc/exceptions.py index 30b552ec..b452b1f7 100644 --- a/src/sap_cloud_sdk/cbc/exceptions.py +++ b/src/sap_cloud_sdk/cbc/exceptions.py @@ -77,6 +77,12 @@ class CBCServerError(CBCHttpError): pass +class CBCEventError(CBCError): + """Raised when an incoming CloudEvent cannot be parsed or is structurally invalid.""" + + pass + + class CBCNetworkError(CBCError): """Raised for network-level failures (DNS, connection refused, timeouts).""" diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index ca5fdbd4..acfb9476 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -140,6 +140,131 @@ modes = cbc_client.get_entity_data( ``` +## Event handling + +CBC pushes CloudEvents to your application's webhook endpoint when configuration +changes or AI features are activated or deactivated. Use `CBCEventHandler` to parse and dispatch +incoming events. + +### Setup + +```python +from sap_cloud_sdk import cbc + +handler = cbc.CBCEventHandler() + +def on_cv(event: cbc.ConsumptionVersionEvent) -> None: + # event.app_tenant_id, event.consumption_version_id + ... + +def on_fa(event: cbc.FeatureActivationEvent) -> None: + for f in event.features: + print(f.feature_id, f.enabled) + +handler.on_consumption_version_event(on_cv) +handler.on_feature_activation_event(on_fa) +``` + +### Wiring into your webhook route + +Call `handle_request` from your framework's route handler, passing the raw HTTP +headers and body: + +```python +# FastAPI example +@app.post("/cbc/events") +async def cbc_webhook(request: Request): + handler.handle_request(dict(request.headers), await request.body()) + return Response(status_code=200) +``` + +`handle_request` raises `CBCEventError` (a subclass of `CBCError`) if the +request cannot be parsed as a valid CloudEvent or if the event data is +structurally invalid. Your webhook route should return HTTP 4xx in that case +so CBC does not retry the delivery. + +Unknown event types are silently ignored. + +### Securing the webhook endpoint + +CBC delivers events over mTLS using a client certificate with the following +identity (stable across all CBC regions): + +``` +Subject: CN=cbc-notify-client, + OU=SAP Cloud Platform Clients+OU=Canary+OU=e804bfc8-44a1-4f27-8625-d7e434148210, + O=SAP SE, L=canary-eu, C=DE + +Issuer: CN=SAP PKI Certificate Service Client CA, + OU=SAP BTP Clients, + O=SAP SE, L=cf-eu12, C=DE +``` + +Your webhook endpoint should verify the incoming client certificate against +this exact Subject+Issuer DN pair before invoking `handle_request`. Accepting +events without certificate verification allows any caller to inject arbitrary +payloads. Certificate verification is done at the TLS layer of your web server +or API gateway — `CBCEventHandler` itself operates on the already-accepted HTTP +request. + +### Event types + +| Callback | Event types routed to it | +|---|---| +| `on_consumption_version_event` | `cbc.notification.consumptionversion.created`, `cbc.notification.consumptionversion.updated` | +| `on_feature_activation_event` | `cbc.notification.feature.activation.v1` | + +### Event objects + +**`ConsumptionVersionEvent`** + +| Attribute | Type | Description | +|---|---|---| +| `app_tenant_id` | `str` | Application tenant id from the event context | +| `consumption_version_id` | `str` | The new consumption version id | + +**`FeatureActivationEvent`** + +| Attribute | Type | Description | +|---|---|---| +| `app_tenant_id` | `str` | Application tenant id from the event context | +| `features` | `list[FeatureActivationData]` | All feature activation states for the tenant | + +Each `FeatureActivationData` item has `feature_id: str` and `enabled: bool`. + + +## Feature status + +Alongside configuration, CBC tracks which AI features are enabled for an app +tenant. Query the current activation state with `get_feature_statuses`: + +```python +statuses = cbc_client.get_feature_statuses() + +if statuses.is_enabled("ai_assisted_supplier_bid_analysis"): + ... +``` + +`is_enabled(feature_id)` returns `False` for a feature that is disabled or not +present for the tenant. To inspect a feature directly, use `get`: + +```python +feature = statuses.get("ai_assisted_tco_analysis") # FeatureStatus | None +if feature is not None: + print(feature.feature_id, feature.enabled) +``` + +To fetch a single feature without pulling the whole list, use +`get_feature_status`, which filters server-side and returns one `FeatureStatus` +(or `None` if the tenant has no entry for it): + +```python +feature = cbc_client.get_feature_status("ai_assisted_lead_routing") +if feature is not None and feature.enabled: + ... +``` + + ## Error handling | Exception | When | @@ -283,7 +408,7 @@ client = cbc.create_client( `create_client` returns a concrete `CBCClient`. For tests, inject a mock `httpx.Client` when constructing one directly, or substitute your own stub object -that implements the three public methods: +that implements the public methods: ```python from sap_cloud_sdk import cbc @@ -302,6 +427,12 @@ class StubCBCClient: self, configuration_object_id, entity_id, consumption_version=None ): ... + def get_feature_statuses(self): + return cbc.FeatureStatuses(features=[]) + + def get_feature_status(self, feature_id): + return None + def test_my_service(): service = MyService(cbc_client=StubCBCClient()) diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 4ae1893d..a7d32ce7 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -143,6 +143,9 @@ class Operation(str, Enum): CBC_GET_CONSUMPTION_VERSIONS = "get_consumption_versions" CBC_GET_CONFIGURATION = "get_configuration" CBC_GET_ENTITY_DATA = "get_entity_data" + CBC_GET_FEATURE_STATUSES = "get_feature_statuses" + CBC_GET_FEATURE_STATUS = "get_feature_status" + CBC_HANDLE_EVENT = "handle_event" # Bootstrap Operations BOOTSTRAP = "bootstrap" diff --git a/tests/cbc/integration/cbc.feature b/tests/cbc/integration/cbc.feature index fbbae6b4..54253d9e 100644 --- a/tests/cbc/integration/cbc.feature +++ b/tests/cbc/integration/cbc.feature @@ -34,3 +34,15 @@ Feature: CBC (Central Business Configuration) Integration Scenario: Fetch a single entity directly returns EntityData When I call get_entity_data for the first entity in the first config object Then the result should be EntityData accessible as a list or object + + # ── Feature status ─────────────────────────────────────────────────────────── + + Scenario: Fetch feature statuses returns FeatureStatuses + When I call get_feature_statuses + Then the result should be FeatureStatuses + And every feature should have a non-empty feature_id + + Scenario: Look up a single feature status by id + When I call get_feature_statuses + And I call get_feature_status for the first feature id + Then the result should be a FeatureStatus for the requested feature id diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index 99d40c7e..c66e2828 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -20,7 +20,13 @@ import pytest from pytest_bdd import given, scenario, then, when -from sap_cloud_sdk.cbc import ConfigurationData, ConsumptionVersions, EntityData +from sap_cloud_sdk.cbc import ( + ConfigurationData, + ConsumptionVersions, + EntityData, + FeatureStatus, + FeatureStatuses, +) from sap_cloud_sdk.cbc.client import CBCClient pytestmark = pytest.mark.integration @@ -72,6 +78,16 @@ def test_get_entity_data_returns_entity_data(): pass +@scenario("cbc.feature", "Fetch feature statuses returns FeatureStatuses") +def test_get_feature_statuses_returns_feature_statuses(): + pass + + +@scenario("cbc.feature", "Look up a single feature status by id") +def test_get_feature_status_matches_full_list(): + pass + + # -- Steps --------------------------------------------------------------------- @@ -171,3 +187,36 @@ def assert_single_entity_data(ctx: dict): except ValueError: result = data.as_object() assert result is not None + + +@when("I call get_feature_statuses") +def call_get_feature_statuses(ctx: dict, cbc_client: CBCClient): + ctx["feature_statuses"] = cbc_client.get_feature_statuses() + + +@then("the result should be FeatureStatuses") +def assert_feature_statuses_type(ctx: dict): + assert isinstance(ctx["feature_statuses"], FeatureStatuses) + + +@then("every feature should have a non-empty feature_id") +def assert_feature_ids_non_empty(ctx: dict): + statuses: FeatureStatuses = ctx["feature_statuses"] + for f in statuses.features: + assert f.feature_id, "feature_id missing on a FeatureStatus" + + +@when("I call get_feature_status for the first feature id") +def call_get_feature_status(ctx: dict, cbc_client: CBCClient): + statuses: FeatureStatuses = ctx["feature_statuses"] + if not statuses.features: + pytest.skip("tenant has no features — cannot test get_feature_status") + ctx["first_feature_id"] = statuses.features[0].feature_id + ctx["single_feature"] = cbc_client.get_feature_status(ctx["first_feature_id"]) + + +@then("the result should be a FeatureStatus for the requested feature id") +def assert_single_feature_matches(ctx: dict): + single: FeatureStatus | None = ctx["single_feature"] + assert single is not None + assert single.feature_id == ctx["first_feature_id"] diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index e5b529c1..2bf71e51 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -383,6 +383,124 @@ def test_get_entity_data_when_no_versions_exist_raises_cbc_client_error(self): client.get_entity_data("payment-config", "payment-mode") +# --------------------------------------------------------------------------- +# CBCClient — _features_url +# --------------------------------------------------------------------------- + + +class TestFeaturesUrl: + def test_features_url_joins_base_feature_path_and_suffix(self): + client, _ = _make_client("https://my-tenant.cbc.example.ondemand.com") + url = client._features_url( + "https://my-tenant.cbc.example.ondemand.com", "/features" + ) + assert url == ( + "https://my-tenant.cbc.example.ondemand.com/feature/v1/features" + ) + + +# --------------------------------------------------------------------------- +# CBCClient — get_feature_statuses +# --------------------------------------------------------------------------- + + +class TestGetFeatureStatuses: + def test_get_feature_statuses_returns_parsed_features(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + ) + result = client.get_feature_statuses() + assert result.is_enabled("ai_assisted_supplier_bid_analysis") is True + assert result.is_enabled("ai_assisted_tco_analysis") is False + + def test_get_feature_statuses_sends_only_app_tenant_id(self): + client, mock_http = _make_client(app_tenant_id=_app_tid("tenant-9")) + mock_http.request.return_value = _mock_response(json_body={"features": []}) + client.get_feature_statuses() + called_url = mock_http.request.call_args.kwargs["url"] + assert called_url.endswith("/feature/v1/features?appTenantId=tenant-9") + + def test_get_feature_statuses_on_404_raises_cbc_client_error(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + status_code=404, + content=b'{"error":{"code":"NOT_FOUND","message":"not found"}}', + ) + with pytest.raises(CBCClientError): + client.get_feature_statuses() + + def test_get_feature_statuses_on_500_raises_cbc_server_error(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response(status_code=500, content=b"") + with pytest.raises(CBCServerError): + client.get_feature_statuses() + + def test_get_feature_statuses_on_connection_failure_raises_cbc_network_error(self): + client, mock_http = _make_client() + mock_http.request.side_effect = httpx.ConnectError("refused") + with pytest.raises(CBCNetworkError): + client.get_feature_statuses() + + +# --------------------------------------------------------------------------- +# CBCClient — get_feature_status +# --------------------------------------------------------------------------- + + +class TestGetFeatureStatus: + def test_get_feature_status_returns_matching_feature(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True} + ] + } + ) + result = client.get_feature_status("ai_assisted_supplier_bid_analysis") + assert result is not None + assert result.feature_id == "ai_assisted_supplier_bid_analysis" + assert result.enabled is True + + def test_get_feature_status_sends_both_query_params(self): + client, mock_http = _make_client(app_tenant_id=_app_tid("tenant-9")) + mock_http.request.return_value = _mock_response(json_body={"features": []}) + client.get_feature_status("ai_assisted_lead_routing") + called_url = mock_http.request.call_args.kwargs["url"] + assert called_url.endswith( + "/feature/v1/features" + "?appTenantId=tenant-9&featureId=ai_assisted_lead_routing" + ) + + def test_get_feature_status_returns_none_when_feature_absent(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response(json_body={"features": []}) + assert client.get_feature_status("ai_assisted_lead_routing") is None + + def test_get_feature_status_filters_to_requested_feature_from_full_list(self): + # If the server ignores featureId and returns all features, the client + # still extracts the requested one. + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + ) + result = client.get_feature_status("ai_assisted_tco_analysis") + assert result is not None + assert result.feature_id == "ai_assisted_tco_analysis" + assert result.enabled is False + + # --------------------------------------------------------------------------- # CBCClient — context manager # --------------------------------------------------------------------------- diff --git a/tests/cbc/unit/test_events.py b/tests/cbc/unit/test_events.py new file mode 100644 index 00000000..b9fd1ba9 --- /dev/null +++ b/tests/cbc/unit/test_events.py @@ -0,0 +1,314 @@ +"""Unit tests for CBCEventHandler and CBC event models.""" + +from __future__ import annotations + +import json + +import pytest + +from sap_cloud_sdk.cbc._events import CBCEventHandler +from sap_cloud_sdk.cbc._models import ( + ConsumptionVersionEvent, + FeatureActivationData, + FeatureActivationEvent, +) +from sap_cloud_sdk.cbc.exceptions import CBCEventError + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _structured_headers() -> dict[str, str]: + return {"Content-Type": "application/cloudevents+json"} + + +def _binary_headers(event_type: str) -> dict[str, str]: + return { + "ce-specversion": "1.0", + "ce-id": "0001", + "ce-source": "/test", + "ce-type": event_type, + "Content-Type": "application/json", + } + + +def _cv_body( + *, + consumption_version_id: str = "cv-1", + status: str = "FINETUNE", + app_tenant_id: str = "app-tenant-123", + cbc_tenant_id: str = "cbc-tenant-123", + include_context: bool = True, +) -> bytes: + data: dict = { + "consumption": { + "consumptionVersionId": consumption_version_id, + "status": status, + } + } + if include_context: + data["context"] = {"cbcTenantId": cbc_tenant_id, "appTenantId": app_tenant_id} + return json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/eu10/sap.alpha/cbc-tenant-123", + "type": "cbc.notification.consumptionversion.updated", + "datacontenttype": "application/json", + "data": data, + } + ).encode() + + +def _fa_body( + *, + features: list[dict] | None = None, + app_tenant_id: str = "app-tenant-123", + cbc_tenant_id: str = "cbc-tenant-123", + include_context: bool = True, +) -> bytes: + data: dict = { + "features": features + if features is not None + else [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + if include_context: + data["context"] = {"cbcTenantId": cbc_tenant_id, "appTenantId": app_tenant_id} + return json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.feature.activation.v1", + "data": data, + } + ).encode() + + +# --------------------------------------------------------------------------- +# TestHandleRequest — consumption version events +# --------------------------------------------------------------------------- + + +class TestHandleRequestConsumptionVersion: + def test_created_event_dispatches_to_cv_callback(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + body = json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.consumptionversion.created", + "data": { + "consumption": {"consumptionVersionId": "cv-1", "status": "INITIALDEPLOY"}, + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + }, + } + ).encode() + handler.handle_request(_structured_headers(), body) + + assert len(received) == 1 + assert received[0].app_tenant_id == "app-t1" + assert received[0].consumption_version_id == "cv-1" + + def test_updated_event_dispatches_to_cv_callback(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + handler.handle_request(_structured_headers(), _cv_body()) + + assert len(received) == 1 + assert received[0].app_tenant_id == "app-tenant-123" + assert received[0].consumption_version_id == "cv-1" + + def test_cv_event_without_callback_does_not_raise(self): + handler = CBCEventHandler() + handler.handle_request(_structured_headers(), _cv_body()) + + def test_cv_event_missing_context_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: None) + with pytest.raises(CBCEventError, match="context"): + handler.handle_request(_structured_headers(), _cv_body(include_context=False)) + + def test_cv_event_missing_consumption_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: None) + body = json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.consumptionversion.updated", + "data": { + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + }, + } + ).encode() + with pytest.raises(CBCEventError, match="consumption"): + handler.handle_request(_structured_headers(), body) + + def test_cv_event_empty_consumption_version_id_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: None) + with pytest.raises(CBCEventError): + handler.handle_request(_structured_headers(), _cv_body(consumption_version_id="")) + + def test_cv_event_binary_mode_dispatches_correctly(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + body = json.dumps( + { + "consumption": {"consumptionVersionId": "cv-binary", "status": "UPGRADE"}, + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + } + ).encode() + handler.handle_request( + _binary_headers("cbc.notification.consumptionversion.updated"), body + ) + + assert len(received) == 1 + assert received[0].consumption_version_id == "cv-binary" + + +# --------------------------------------------------------------------------- +# TestHandleRequest — feature activation events +# --------------------------------------------------------------------------- + + +class TestHandleRequestFeatureActivation: + def test_fa_event_dispatches_to_fa_callback(self): + handler = CBCEventHandler() + received: list[FeatureActivationEvent] = [] + handler.on_feature_activation_event(received.append) + + handler.handle_request(_structured_headers(), _fa_body()) + + assert len(received) == 1 + assert received[0].app_tenant_id == "app-tenant-123" + assert len(received[0].features) == 2 + + def test_fa_event_parses_feature_ids_and_enabled_flags(self): + handler = CBCEventHandler() + received: list[FeatureActivationEvent] = [] + handler.on_feature_activation_event(received.append) + + handler.handle_request(_structured_headers(), _fa_body()) + + features = received[0].features + assert features[0].feature_id == "ai_assisted_supplier_bid_analysis" + assert features[0].enabled is True + assert features[1].feature_id == "ai_assisted_tco_analysis" + assert features[1].enabled is False + + def test_fa_event_without_callback_does_not_raise(self): + handler = CBCEventHandler() + handler.handle_request(_structured_headers(), _fa_body()) + + def test_fa_event_missing_context_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_feature_activation_event(lambda e: None) + with pytest.raises(CBCEventError, match="context"): + handler.handle_request(_structured_headers(), _fa_body(include_context=False)) + + def test_fa_event_empty_features_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_feature_activation_event(lambda e: None) + with pytest.raises(CBCEventError, match="features"): + handler.handle_request(_structured_headers(), _fa_body(features=[])) + + def test_fa_event_binary_mode_dispatches_correctly(self): + handler = CBCEventHandler() + received: list[FeatureActivationEvent] = [] + handler.on_feature_activation_event(received.append) + + body = json.dumps( + { + "features": [{"featureId": "ai_assisted_lead_routing", "enabled": True}], + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + } + ).encode() + handler.handle_request(_binary_headers("cbc.notification.feature.activation.v1"), body) + + assert len(received) == 1 + assert received[0].features[0].feature_id == "ai_assisted_lead_routing" + + +# --------------------------------------------------------------------------- +# TestHandleRequest — unknown and malformed events +# --------------------------------------------------------------------------- + + +class TestHandleRequestUnknownAndMalformed: + def test_unknown_event_type_is_silently_ignored(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: (_ for _ in ()).throw(AssertionError())) + handler.on_feature_activation_event(lambda e: (_ for _ in ()).throw(AssertionError())) + + body = json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.some.unknown.event", + "data": {}, + } + ).encode() + handler.handle_request(_structured_headers(), body) + + def test_malformed_body_raises_cbc_event_error(self): + handler = CBCEventHandler() + with pytest.raises(CBCEventError): + handler.handle_request(_structured_headers(), b"not json at all") + + def test_body_as_str_is_accepted(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + handler.handle_request(_structured_headers(), _cv_body().decode()) + + assert len(received) == 1 + + +# --------------------------------------------------------------------------- +# TestEventModels +# --------------------------------------------------------------------------- + + +class TestConsumptionVersionEvent: + def test_repr_contains_key_fields(self): + e = ConsumptionVersionEvent(app_tenant_id="app-t1", consumption_version_id="cv-1") + assert "app-t1" in repr(e) + assert "cv-1" in repr(e) + + +class TestFeatureActivationData: + def test_enabled_returns_correct_value(self): + assert FeatureActivationData("f1", True).enabled is True + assert FeatureActivationData("f2", False).enabled is False + + def test_repr_contains_key_fields(self): + d = FeatureActivationData("ai_assisted_lead_routing", True) + assert "ai_assisted_lead_routing" in repr(d) + assert "True" in repr(d) + + +class TestFeatureActivationEvent: + def test_repr_contains_key_fields(self): + e = FeatureActivationEvent( + app_tenant_id="app-t1", + features=[FeatureActivationData("f1", True)], + ) + assert "app-t1" in repr(e) diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 5bfc9b90..33acc4cf 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -14,6 +14,8 @@ ConsumptionVersions, ConfigurationEntity, EntityData, + FeatureStatus, + FeatureStatuses, ) @@ -71,6 +73,70 @@ def test_latest_without_any_dates_returns_last_item(self): assert result.version == "v2" +# --------------------------------------------------------------------------- +# FeatureStatus / FeatureStatuses +# --------------------------------------------------------------------------- + + +class TestFeatureStatus: + def test_parses_camel_case_feature_id_alias(self): + f = FeatureStatus.model_validate( + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True} + ) + assert f.feature_id == "ai_assisted_supplier_bid_analysis" + assert f.enabled is True + + def test_enabled_defaults_to_false_when_absent(self): + f = FeatureStatus.model_validate({"featureId": "ai_assisted_tco_analysis"}) + assert f.enabled is False + + def test_ignores_extra_api_fields(self): + f = FeatureStatus.model_validate( + { + "featureId": "ai_assisted_supplier_bid_analysis", + "appTenantId": "b012b543-0374-4b3b-8597-c8c3a60bef22", + "status": "SUCCESS", + "decision": True, + "enabled": True, + "defaultValue": False, + "category": "ai", + "packageName": "Joule Premium for Sourcing", + "packageId": "SAP_JOULE_PREMIUM_SOURCING", + } + ) + assert f.feature_id == "ai_assisted_supplier_bid_analysis" + assert f.enabled is True + + +class TestFeatureStatuses: + def _statuses(self) -> FeatureStatuses: + return FeatureStatuses.model_validate( + { + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + ) + + def test_is_enabled_true_for_enabled_feature(self): + assert self._statuses().is_enabled("ai_assisted_supplier_bid_analysis") is True + + def test_is_enabled_false_for_disabled_feature(self): + assert self._statuses().is_enabled("ai_assisted_tco_analysis") is False + + def test_is_enabled_false_for_absent_feature(self): + assert self._statuses().is_enabled("ai_assisted_lead_routing") is False + + def test_get_returns_matching_feature(self): + f = self._statuses().get("ai_assisted_supplier_bid_analysis") + assert f is not None + assert f.feature_id == "ai_assisted_supplier_bid_analysis" + + def test_get_returns_none_for_absent_feature(self): + assert self._statuses().get("ai_assisted_lead_routing") is None + + # --------------------------------------------------------------------------- # EntityData # --------------------------------------------------------------------------- diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index cc42e94f..f8b24570 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -318,5 +318,5 @@ def test_operation_count(self): # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 7 aicore + 23 dms + 7 agentgateway + 13 agent_memory # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management - # + 3 cbc = 259 - assert len(all_operations) == 259 + # + 6 cbc = 262 + assert len(all_operations) == 262 diff --git a/uv.lock b/uv.lock index 3be64207..5e3a46d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -603,6 +603,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] +[[package]] +name = "cloudevents" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/fe/3bae2187abea2682a1079c5618e45baf4c9df7e35017e1b9b7940955db59/cloudevents-2.2.0.tar.gz", hash = "sha256:fc52440a1fecde882b3b745361e1c7dd4e044e71a6b719cfec2e2c0aa1a19fa3", size = 48380, upload-time = "2026-06-11T14:10:02.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/d4/901b17ea39f4f1125fc92529cb842896309075406a78e13a7c544f0c5600/cloudevents-2.2.0-py3-none-any.whl", hash = "sha256:9ff8adff01b997d251d0fb54076557daa9906330e16afc1712a6447122f8f7a4", size = 98239, upload-time = "2026-06-11T14:10:01.059Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -805,6 +818,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -4353,6 +4378,7 @@ name = "sap-cloud-sdk" version = "0.62.0" source = { editable = "." } dependencies = [ + { name = "cloudevents" }, { name = "cryptography" }, { name = "grpcio" }, { name = "hatchling" }, @@ -4443,6 +4469,7 @@ dev = [ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0,<1.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.9.0,<4" }, + { name = "cloudevents", specifier = ">=2.2.0,<3" }, { name = "cryptography", specifier = ">=48.0.1,<51" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0,<6" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0,<1" },