diff --git a/src/sap_cloud_sdk/agent_memory/__init__.py b/src/sap_cloud_sdk/agent_memory/__init__.py index 6177df68..5c7085fd 100644 --- a/src/sap_cloud_sdk/agent_memory/__init__.py +++ b/src/sap_cloud_sdk/agent_memory/__init__.py @@ -17,12 +17,10 @@ from typing import Optional -from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory.client import AgentMemoryClient from sap_cloud_sdk.agent_memory.config import ( AgentMemoryConfig, - _load_config_for_instance, - _load_config_from_env, + _make_agent_memory_factory, ) from sap_cloud_sdk.agent_memory.exceptions import ( AgentMemoryConfigError, @@ -40,6 +38,19 @@ SearchResult, ) from sap_cloud_sdk.agent_memory.utils._odata import FilterDefinition +from sap_cloud_sdk.core._http_client import HttpClient, XsuaaAuthProvider + + +def _build_agent_memory_http( + instance: str, config: Optional[AgentMemoryConfig] +) -> HttpClient: + if config is not None: + auth = XsuaaAuthProvider(lambda: config) if config.token_url else None + return HttpClient(config.base_url, auth, timeout=config.timeout) + factory = _make_agent_memory_factory(instance) + cfg = factory() + auth = XsuaaAuthProvider(factory) if cfg.token_url else None + return HttpClient(cfg.base_url, auth, timeout=cfg.timeout) def create_client( @@ -78,15 +89,13 @@ def create_client( """ try: if config is not None: - resolved_config = config + http = _build_agent_memory_http("default", config) elif access_strategy is AccessStrategy.SUBSCRIBER and tenant: - resolved_config = _load_config_for_instance(tenant) + http = _build_agent_memory_http(tenant, None) else: - resolved_config = _load_config_from_env() - - transport = HttpTransport(resolved_config) + http = _build_agent_memory_http("default", None) return AgentMemoryClient( - transport, + http, access_strategy=access_strategy, tenant=tenant, ) diff --git a/src/sap_cloud_sdk/agent_memory/_http.py b/src/sap_cloud_sdk/agent_memory/_http.py new file mode 100644 index 00000000..7ec3c155 --- /dev/null +++ b/src/sap_cloud_sdk/agent_memory/_http.py @@ -0,0 +1,71 @@ +"""Low-level HTTP helper for the Agent Memory service.""" + +from __future__ import annotations + +import logging +from typing import Any, Optional +from urllib.parse import quote, urlencode + +from requests.exceptions import RequestException, Timeout + +from sap_cloud_sdk.agent_memory.exceptions import ( + AgentMemoryHttpError, + AgentMemoryNotFoundError, +) +from sap_cloud_sdk.core._http_client import HttpClient, HttpMethod + +logger = logging.getLogger(__name__) + + +def _request( + http: HttpClient, + method: HttpMethod, + path: str, + *, + params: Optional[dict[str, Any]] = None, + tenant_subdomain: Optional[str] = None, + **kwargs: Any, +) -> dict[str, Any]: + """Execute an Agent Memory HTTP request and map errors to domain exceptions.""" + logger.debug("%s %s (tenant=%r)", method.value, path, tenant_subdomain) + + if params: + path = f"{path}?{urlencode(params, quote_via=quote)}" + + try: + response = http.request( + method, + path, + tenant_subdomain=tenant_subdomain, + headers={"Content-Type": "application/json"}, + **kwargs, + ) + except Timeout as exc: + raise AgentMemoryHttpError(f"Request timed out: {method.value} {path}") from exc + except RequestException as exc: + raise AgentMemoryHttpError( + f"Request failed: {method.value} {path} — {exc}" + ) from exc + except Exception as exc: + raise AgentMemoryHttpError(str(exc)) from exc + + if response.status_code == 204 or not response.content: + return {} + + if response.status_code == 404: + raise AgentMemoryNotFoundError( + f"Resource not found: {method.value} {path}", + status_code=404, + response_text=response.text, + ) + + if not response.ok: + raise AgentMemoryHttpError( + f"Agent Memory service request failed. " + f"Method: {method.value}, Path: {path}, " + f"Status: {response.status_code}, Response: {response.text}", + status_code=response.status_code, + response_text=response.text, + ) + + return response.json() diff --git a/src/sap_cloud_sdk/agent_memory/_http_transport.py b/src/sap_cloud_sdk/agent_memory/_http_transport.py deleted file mode 100644 index 9ec4daad..00000000 --- a/src/sap_cloud_sdk/agent_memory/_http_transport.py +++ /dev/null @@ -1,267 +0,0 @@ -"""HTTP transport for the Agent Memory service. - -Handles OAuth2 ``client_credentials`` token acquisition with lazy, -expiry-aware caching per tenant subdomain. If ``token_url`` is not configured, -requests are sent unauthenticated — expected for local development environments. -""" - -from __future__ import annotations - -import logging -from datetime import datetime, timedelta -from typing import Any, Optional -from urllib.parse import quote, urlencode - -import requests -from oauthlib.oauth2 import BackendApplicationClient -from requests.exceptions import RequestException, Timeout -from requests_oauthlib import OAuth2Session - -from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig -from sap_cloud_sdk.agent_memory.exceptions import ( - AgentMemoryHttpError, - AgentMemoryNotFoundError, -) - -logger = logging.getLogger(__name__) - -_TOKEN_EXPIRY_BUFFER_SECONDS = 60 - - -class HttpTransport: - """Internal HTTP transport for the Agent Memory service. - - Manages OAuth2 token lifecycle (lazy acquire + expiry-aware caching) per - tenant subdomain and attaches the ``Authorization`` header automatically. - In no-auth mode (no ``token_url``), a plain ``requests.Session`` is used. - - Args: - config: Service configuration. - """ - - def __init__(self, config: AgentMemoryConfig) -> None: - self._config = config - # Keyed by tenant_subdomain (None = provider token) - self._oauth_cache: dict[Optional[str], tuple[OAuth2Session, datetime]] = {} - self._plain_session: Optional[requests.Session] = None - - def close(self) -> None: - """Close all underlying HTTP sessions and release resources.""" - for oauth, _ in self._oauth_cache.values(): - oauth.close() - self._oauth_cache.clear() - if self._plain_session is not None: - self._plain_session.close() - self._plain_session = None - - # ── Public HTTP methods ──────────────────────────────────────────────────── - - def get( - self, - path: str, - params: Optional[dict[str, Any]] = None, - *, - tenant_subdomain: Optional[str] = None, - ) -> dict[str, Any]: - """Perform a GET request. - - Args: - path: API path (appended to ``base_url``). - params: Optional query parameters. - tenant_subdomain: Subscriber tenant subdomain for token derivation. - - Returns: - Parsed JSON response body. - - Raises: - AgentMemoryHttpError: On HTTP errors or network failures. - AgentMemoryNotFoundError: If the server returns 404. - """ - return self._request( - "GET", path, params=params, tenant_subdomain=tenant_subdomain - ) - - def post( - self, - path: str, - json: Optional[dict[str, Any]] = None, - *, - tenant_subdomain: Optional[str] = None, - ) -> dict[str, Any]: - """Perform a POST request. - - Args: - path: API path (appended to ``base_url``). - json: Optional request body dict (serialised to JSON). - tenant_subdomain: Subscriber tenant subdomain for token derivation. - - Returns: - Parsed JSON response body. Returns an empty dict for 204 responses. - - Raises: - AgentMemoryHttpError: On HTTP errors or network failures. - AgentMemoryNotFoundError: If the server returns 404. - """ - return self._request("POST", path, json=json, tenant_subdomain=tenant_subdomain) - - def patch( - self, - path: str, - json: Optional[dict[str, Any]] = None, - *, - tenant_subdomain: Optional[str] = None, - ) -> dict[str, Any]: - """Perform a PATCH request. - - Args: - path: API path (appended to ``base_url``). - json: Optional request body dict (serialised to JSON). - tenant_subdomain: Subscriber tenant subdomain for token derivation. - - Returns: - Parsed JSON response body. Returns an empty dict for 204 responses. - - Raises: - AgentMemoryHttpError: On HTTP errors or network failures. - AgentMemoryNotFoundError: If the server returns 404. - """ - return self._request( - "PATCH", path, json=json, tenant_subdomain=tenant_subdomain - ) - - def delete(self, path: str, *, tenant_subdomain: Optional[str] = None) -> None: - """Perform a DELETE request. - - Args: - path: API path (appended to ``base_url``). - tenant_subdomain: Subscriber tenant subdomain for token derivation. - - Raises: - AgentMemoryHttpError: On HTTP errors or network failures. - AgentMemoryNotFoundError: If the server returns 404. - """ - self._request("DELETE", path, tenant_subdomain=tenant_subdomain) - - # ── Internal helpers ─────────────────────────────────────────────────────── - - def _get_session(self, tenant_subdomain: Optional[str]) -> requests.Session: - """Return a session ready to make requests for the given tenant. - - In no-auth mode, returns a plain ``requests.Session`` (created once). - In OAuth2 mode, returns an ``OAuth2Session`` with a valid token, - fetching or refreshing per-tenant as needed. - """ - if not self._config.token_url: - if self._plain_session is None: - self._plain_session = requests.Session() - return self._plain_session - - cached = self._oauth_cache.get(tenant_subdomain) - if cached is not None: - oauth, expires_at = cached - if datetime.now() < expires_at: - return oauth - - return self._fetch_token(tenant_subdomain) - - def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: - """Acquire a new OAuth2 ``client_credentials`` token for the given tenant. - - When ``tenant_subdomain`` is provided and ``config.identityzone`` is set, - derives the subscriber token URL by replacing the provider identityzone - in ``token_url`` with ``tenant_subdomain``. - - Returns: - An ``OAuth2Session`` with a valid token attached. - - Raises: - AgentMemoryHttpError: If the token endpoint returns an error or is unreachable. - """ - token_url = self._config.token_url - if ( - tenant_subdomain is not None - and self._config.identityzone is not None - and token_url is not None - ): - token_url = token_url.replace(self._config.identityzone, tenant_subdomain) - - try: - client = BackendApplicationClient(client_id=self._config.client_id) - oauth = OAuth2Session(client=client) - token = oauth.fetch_token( - token_url=token_url, - client_id=self._config.client_id, - client_secret=self._config.client_secret, - timeout=self._config.timeout, - ) - except Exception as exc: - raise AgentMemoryHttpError(f"Failed to obtain OAuth2 token: {exc}") from exc - - expires_in: int = token.get("expires_in", 3600) - expires_at = datetime.now() + timedelta( - seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS - ) - - existing = self._oauth_cache.get(tenant_subdomain) - if existing is not None: - existing[0].close() - - self._oauth_cache[tenant_subdomain] = (oauth, expires_at) - - logger.debug( - "Obtained new Agent Memory OAuth2 token for tenant=%r (expires in %ds)", - tenant_subdomain, - expires_in, - ) - return oauth - - def _request( - self, - method: str, - path: str, - tenant_subdomain: Optional[str] = None, - **kwargs: Any, - ) -> dict[str, Any]: - """Execute an HTTP request using the appropriate session.""" - logger.debug("%s %s (tenant=%r)", method, path, tenant_subdomain) - - url = f"{self._config.base_url}{path}" - if "params" in kwargs: - raw_params: dict[str, Any] = kwargs.pop("params") - if raw_params: - url = f"{url}?{urlencode(raw_params, quote_via=quote)}" - - session = self._get_session(tenant_subdomain) - headers = {"Content-Type": "application/json"} - - try: - response = session.request( - method, url, headers=headers, timeout=self._config.timeout, **kwargs - ) - except Timeout as exc: - raise AgentMemoryHttpError(f"Request timed out: {method} {path}") from exc - except RequestException as exc: - raise AgentMemoryHttpError( - f"Request failed: {method} {path} — {exc}" - ) from exc - - if response.status_code == 204 or not response.content: - return {} - - if response.status_code == 404: - raise AgentMemoryNotFoundError( - f"Resource not found: {method} {path}", - status_code=404, - response_text=response.text, - ) - - if not response.ok: - raise AgentMemoryHttpError( - f"Agent Memory service request failed. " - f"Method: {method}, Path: {path}, " - f"Status: {response.status_code}, Response: {response.text}", - status_code=response.status_code, - response_text=response.text, - ) - - return response.json() diff --git a/src/sap_cloud_sdk/agent_memory/client.py b/src/sap_cloud_sdk/agent_memory/client.py index 9e28ac54..b8db66a5 100644 --- a/src/sap_cloud_sdk/agent_memory/client.py +++ b/src/sap_cloud_sdk/agent_memory/client.py @@ -18,7 +18,7 @@ MESSAGES, RETENTION_CONFIG, ) -from sap_cloud_sdk.agent_memory._http_transport import HttpTransport +from sap_cloud_sdk.agent_memory._http import _request from sap_cloud_sdk.agent_memory._models import ( AccessStrategy, Memory, @@ -37,6 +37,7 @@ from sap_cloud_sdk.agent_memory.exceptions import ( AgentMemoryValidationError, ) +from sap_cloud_sdk.core._http_client import HttpClient, HttpMethod from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics logger = logging.getLogger(__name__) @@ -77,9 +78,7 @@ class AgentMemoryClient: Do not instantiate directly — use :func:`sap_cloud_sdk.agent_memory.create_client`. Args: - transport: HTTP transport loaded from the binding for the configured - access strategy and tenant (resolved once at construction time by - :func:`sap_cloud_sdk.agent_memory.create_client`). + http: Configured HttpClient for the Agent Memory service. access_strategy: Tenant access strategy for all operations. Defaults to ``SUBSCRIBER``. tenant: Subscriber tenant subdomain. Required when @@ -88,7 +87,7 @@ class AgentMemoryClient: def __init__( self, - transport: HttpTransport, + http: HttpClient, *, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER, tenant: Optional[str] = None, @@ -102,11 +101,11 @@ def __init__( "AccessStrategy.PROVIDER is active: no tenant isolation will be applied. " "Only use this strategy for provider-owned operations." ) - self._transport = transport + self._http = http def close(self) -> None: """Close the underlying HTTP session and release resources.""" - self._transport.close() + self._http.close() def __enter__(self) -> AgentMemoryClient: return self @@ -148,7 +147,7 @@ def add_memory( } if metadata is not None: payload["metadata"] = metadata - data = self._transport.post(MEMORIES, json=payload) + data = _request(self._http, HttpMethod.POST, MEMORIES, json=payload) return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MEMORY) @@ -167,7 +166,7 @@ def get_memory(self, memory_id: str) -> Memory: AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - data = self._transport.get(f"{MEMORIES}({memory_id})") + data = _request(self._http, HttpMethod.GET, f"{MEMORIES}({memory_id})") return Memory.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_MEMORY) @@ -201,7 +200,7 @@ def update_memory( payload["content"] = content if metadata is not None: payload["metadata"] = metadata - self._transport.patch(f"{MEMORIES}({memory_id})", json=payload) + _request(self._http, HttpMethod.PATCH, f"{MEMORIES}({memory_id})", json=payload) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MEMORY) def delete_memory(self, memory_id: str) -> None: @@ -216,7 +215,7 @@ def delete_memory(self, memory_id: str) -> None: AgentMemoryHttpError: If the request fails. """ _require_non_empty(memory_id=memory_id) - self._transport.delete(f"{MEMORIES}({memory_id})") + _request(self._http, HttpMethod.DELETE, f"{MEMORIES}({memory_id})") @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MEMORIES) def list_memories( @@ -264,7 +263,7 @@ def list_memories( top=limit, skip=offset if offset else None, ) - response = self._transport.get(MEMORIES, params=params) + response = _request(self._http, HttpMethod.GET, MEMORIES, params=params) items, _ = extract_value_and_count(response) return [Memory.from_dict(item) for item in items] @@ -289,7 +288,7 @@ def count_memories( top=0, count=True, ) - response = self._transport.get(MEMORIES, params=params) + response = _request(self._http, HttpMethod.GET, MEMORIES, params=params) _, total = extract_value_and_count(response) return total or 0 @@ -336,7 +335,7 @@ def search_memories( "threshold": threshold, "top": limit, } - response = self._transport.post(MEMORY_SEARCH, json=payload) + response = _request(self._http, HttpMethod.POST, MEMORY_SEARCH, json=payload) items = response.get("value", []) return [SearchResult.from_dict(item) for item in items] @@ -388,7 +387,7 @@ def add_message( } if metadata is not None: payload["metadata"] = metadata - data = self._transport.post(MESSAGES, json=payload) + data = _request(self._http, HttpMethod.POST, MESSAGES, json=payload) return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_GET_MESSAGE) @@ -407,7 +406,7 @@ def get_message(self, message_id: str) -> Message: AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - data = self._transport.get(f"{MESSAGES}({message_id})") + data = _request(self._http, HttpMethod.GET, f"{MESSAGES}({message_id})") return Message.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_DELETE_MESSAGE) @@ -423,7 +422,7 @@ def delete_message(self, message_id: str) -> None: AgentMemoryHttpError: If the request fails. """ _require_non_empty(message_id=message_id) - self._transport.delete(f"{MESSAGES}({message_id})") + _request(self._http, HttpMethod.DELETE, f"{MESSAGES}({message_id})") @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_LIST_MESSAGES) def list_messages( @@ -477,7 +476,7 @@ def list_messages( top=limit, skip=offset if offset else None, ) - response = self._transport.get(MESSAGES, params=params) + response = _request(self._http, HttpMethod.GET, MESSAGES, params=params) items, _ = extract_value_and_count(response) return [Message.from_dict(item) for item in items] @@ -495,7 +494,7 @@ def get_retention_config(self) -> RetentionConfig: Raises: AgentMemoryHttpError: If the request fails. """ - data = self._transport.get(RETENTION_CONFIG) + data = _request(self._http, HttpMethod.GET, RETENTION_CONFIG) return RetentionConfig.from_dict(data) @record_metrics(Module.AGENT_MEMORY, Operation.AGENT_MEMORY_UPDATE_RETENTION_CONFIG) @@ -534,6 +533,7 @@ def update_retention_config( ): if value is not None and value < 0: raise AgentMemoryValidationError(f"'{name}' must be >= 0") + payload: dict[str, Any] = {} if message_days is not None: payload["messageDays"] = message_days @@ -541,4 +541,5 @@ def update_retention_config( payload["memoryDays"] = memory_days if usage_log_days is not None: payload["usageLogDays"] = usage_log_days - self._transport.patch(RETENTION_CONFIG, json=payload) + + _request(self._http, HttpMethod.PATCH, RETENTION_CONFIG, json=payload) diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index 37569002..b41b1f79 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -24,10 +24,13 @@ import json from dataclasses import dataclass -from typing import Optional +from typing import Optional, TYPE_CHECKING from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError +if TYPE_CHECKING: + from sap_cloud_sdk.core.secret_resolver import ConfigFactory + @dataclass class AgentMemoryConfig: @@ -163,24 +166,43 @@ def _load_config_for_instance(instance: str) -> AgentMemoryConfig: Raises: AgentMemoryConfigError: If configuration cannot be loaded or is incomplete. """ - from sap_cloud_sdk.core.secret_resolver import ( - read_from_mount_and_fallback_to_env_var, - ) - try: - binding = BindingData() - read_from_mount_and_fallback_to_env_var( - base_volume_mount="/etc/secrets/appfnd", - base_var_name="CLOUD_SDK_CFG", - module="hana-agent-memory", - instance=instance, - target=binding, - ) - binding.validate() - return binding.extract_config() + return _make_agent_memory_factory(instance)() except AgentMemoryConfigError: raise except Exception as exc: raise AgentMemoryConfigError( f"Failed to load Agent Memory configuration for instance '{instance}': {exc}" ) from exc + + +def _make_agent_memory_factory(instance: str) -> "ConfigFactory[AgentMemoryConfig]": + """Return a :class:`~sap_cloud_sdk.core.secret_resolver.ConfigFactory` for the given instance. + + The factory re-reads the binding on every call and tracks the secret + directory mtime for proactive rotation detection. + + Args: + instance: Binding instance name (``"default"`` or a tenant subdomain). + + Returns: + A callable that produces a fresh :class:`AgentMemoryConfig`. + """ + from sap_cloud_sdk.core.secret_resolver import ConfigFactory + + def _extract(binding: BindingData) -> AgentMemoryConfig: + try: + return binding.extract_config() + except AgentMemoryConfigError: + raise + except Exception as exc: + raise AgentMemoryConfigError( + f"Failed to load Agent Memory configuration for instance '{instance}': {exc}" + ) from exc + + return ConfigFactory( + module="hana-agent-memory", + instance=instance, + binding_cls=BindingData, + extract=_extract, + ) diff --git a/src/sap_cloud_sdk/core/_http_client.py b/src/sap_cloud_sdk/core/_http_client.py new file mode 100644 index 00000000..081fc3d7 --- /dev/null +++ b/src/sap_cloud_sdk/core/_http_client.py @@ -0,0 +1,242 @@ +"""Shared HTTP client with injectable authentication and rotation-resilient token management. + +Provides three building blocks used by all service modules: + +- :class:`AuthProvider` — abstract interface for authentication strategies. +- :class:`XsuaaAuthProvider` — OAuth2 client-credentials for XSUAA. Re-reads + credentials on every token refresh and detects secret rotation proactively + via filesystem mtime. +- :class:`HttpClient` — concrete HTTP client. Composes with an + :class:`AuthProvider` and retries once on 401 to recover from rotated tokens. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from typing import Any, Callable, Optional + +import requests +from enum import Enum +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session + +logger = logging.getLogger(__name__) + +_TOKEN_EXPIRY_BUFFER_SECONDS = 60 +_DEFAULT_TIMEOUT = 30.0 + + +class HttpMethod(Enum): + """Standard HTTP methods.""" + + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" + + +class AuthProvider(ABC): + """Abstract authentication provider.""" + + @abstractmethod + def get_session(self, tenant_subdomain: Optional[str] = None) -> requests.Session: + """Return a session ready to make authenticated requests.""" + + @abstractmethod + def invalidate(self, tenant_subdomain: Optional[str] = None) -> None: + """Evict the cached token for the given tenant.""" + + @abstractmethod + def invalidate_all(self) -> None: + """Evict all cached tokens.""" + + @abstractmethod + def close(self) -> None: + """Release all held resources.""" + + +class XsuaaAuthProvider(AuthProvider): + """OAuth2 client-credentials auth provider for XSUAA. + + Caches tokens per tenant subdomain with expiry-aware eviction. + Re-reads credentials from the binding on every token refresh so that + rotated secrets are picked up automatically. + Detects rotation proactively by checking the secret directory mtime before + each cache hit via :meth:`~ConfigFactory.has_changed`. + + Args: + config_factory: A :class:`~sap_cloud_sdk.core.secret_resolver.ConfigFactory` + (or any callable with a ``has_changed() -> bool`` method) that returns + fresh XSUAA credentials on every call. + timeout: Timeout in seconds for token-endpoint requests. + """ + + def __init__( + self, + config_factory: Callable[[], Any], + *, + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self._config_factory = config_factory + self._config = config_factory() + self._timeout = timeout + self._cache: dict[Optional[str], tuple[OAuth2Session, datetime]] = {} + + def get_session(self, tenant_subdomain: Optional[str] = None) -> OAuth2Session: + has_changed = getattr(self._config_factory, "has_changed", None) + if callable(has_changed) and has_changed(): + self.invalidate_all() + + cached = self._cache.get(tenant_subdomain) + if cached is not None: + oauth, expires_at = cached + if datetime.now() < expires_at: + return oauth + + return self._fetch_token(tenant_subdomain) + + def _fetch_token(self, tenant_subdomain: Optional[str]) -> OAuth2Session: + self._config = self._config_factory() + + token_url = self._config.token_url + identityzone = self._config.identityzone + if ( + tenant_subdomain is not None + and identityzone is not None + and token_url is not None + ): + token_url = str(token_url).replace(str(identityzone), tenant_subdomain) + + client = BackendApplicationClient(client_id=str(self._config.client_id)) + oauth = OAuth2Session(client=client) + try: + token = oauth.fetch_token( + token_url=token_url, + client_id=str(self._config.client_id), + client_secret=str(self._config.client_secret), + include_client_id=True, + timeout=self._timeout, + ) + except Exception as exc: + raise RuntimeError(f"Failed to obtain OAuth2 token: {exc}") from exc + + expires_in: int = token.get("expires_in", 3600) + expires_at = datetime.now() + timedelta( + seconds=expires_in - _TOKEN_EXPIRY_BUFFER_SECONDS + ) + + existing = self._cache.get(tenant_subdomain) + if existing is not None: + existing[0].close() + + self._cache[tenant_subdomain] = (oauth, expires_at) + + logger.debug( + "Obtained OAuth2 token for tenant=%r (expires in %ds)", + tenant_subdomain, + expires_in, + ) + return oauth + + def invalidate(self, tenant_subdomain: Optional[str] = None) -> None: + self._cache.pop(tenant_subdomain, None) + + def invalidate_all(self) -> None: + for oauth, _ in self._cache.values(): + oauth.close() + self._cache.clear() + + def close(self) -> None: + self.invalidate_all() + + +class HttpClient: + """Concrete HTTP client with injectable auth and single-retry on 401. + + Returns raw :class:`requests.Response` objects — callers are responsible + for error handling and domain-specific exception mapping. + + On a 401 response the client evicts the stale token via + :meth:`AuthProvider.invalidate` and retries the request exactly once. This + recovers from credentials that were revoked after secret rotation. + + Args: + base_url: Base URL for all requests (trailing slash is stripped). + auth_provider: Authentication provider. Pass ``None`` for unauthenticated + (plain :class:`requests.Session`) mode. + timeout: Timeout in seconds for resource-server requests. + """ + + def __init__( + self, + base_url: str, + auth_provider: Optional[AuthProvider] = None, + *, + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self._base_url = base_url.rstrip("/") + self._auth_provider = auth_provider + self._timeout = timeout + self._plain_session: Optional[requests.Session] = None + + def request( + self, + method: HttpMethod | str, + path: str, + *, + tenant_subdomain: Optional[str] = None, + **kwargs: Any, + ) -> requests.Response: + """Execute a request, retrying once on 401. + + Args: + method: HTTP verb (``"GET"``, ``"POST"``, etc.). + path: Path appended to ``base_url``. Should start with ``/``. + tenant_subdomain: Subscriber tenant subdomain forwarded to the auth + provider for per-tenant token derivation. + **kwargs: Forwarded verbatim to :meth:`requests.Session.request`. + + Returns: + Raw :class:`requests.Response`. Callers must check the status code. + """ + response = self._execute(method, path, tenant_subdomain, **kwargs) + if response.status_code == 401 and self._auth_provider is not None: + self._auth_provider.invalidate(tenant_subdomain) + response = self._execute(method, path, tenant_subdomain, **kwargs) + return response + + def _execute( + self, + method: HttpMethod | str, + path: str, + tenant_subdomain: Optional[str], + **kwargs: Any, + ) -> requests.Response: + method_str = ( + method.value if isinstance(method, HttpMethod) else str(method).upper() + ) + if self._auth_provider is not None: + session: requests.Session = self._auth_provider.get_session( + tenant_subdomain + ) + else: + if self._plain_session is None: + self._plain_session = requests.Session() + session = self._plain_session + return session.request( + method_str, + f"{self._base_url}{path}", + timeout=self._timeout, + **kwargs, + ) + + def close(self) -> None: + """Close all underlying sessions and release resources.""" + if self._auth_provider is not None: + self._auth_provider.close() + if self._plain_session is not None: + self._plain_session.close() + self._plain_session = None diff --git a/src/sap_cloud_sdk/core/secret_resolver/__init__.py b/src/sap_cloud_sdk/core/secret_resolver/__init__.py index 79cf79ff..61779d3c 100644 --- a/src/sap_cloud_sdk/core/secret_resolver/__init__.py +++ b/src/sap_cloud_sdk/core/secret_resolver/__init__.py @@ -22,5 +22,10 @@ class MyConfig: """ from .resolver import read_from_mount_and_fallback_to_env_var, resolve_base_mount +from ._config_factory import ConfigFactory -__all__ = ["read_from_mount_and_fallback_to_env_var", "resolve_base_mount"] +__all__ = [ + "read_from_mount_and_fallback_to_env_var", + "resolve_base_mount", + "ConfigFactory", +] diff --git a/src/sap_cloud_sdk/core/secret_resolver/_config_factory.py b/src/sap_cloud_sdk/core/secret_resolver/_config_factory.py new file mode 100644 index 00000000..569ad9da --- /dev/null +++ b/src/sap_cloud_sdk/core/secret_resolver/_config_factory.py @@ -0,0 +1,80 @@ +"""Generic config factory for re-reading service binding credentials on demand.""" + +from __future__ import annotations + +import os +from typing import Any, Callable, Generic, Optional, Type, TypeVar + +C = TypeVar("C") + +_BASE_VOLUME_MOUNT = "/etc/secrets/appfnd" +_BASE_VAR_NAME = "CLOUD_SDK_CFG" + + +class ConfigFactory(Generic[C]): + """Re-reads a service binding from mount or env on every invocation. + + Callers that need always-fresh credentials (e.g. after secret rotation) + call the factory before each token refresh. The factory also tracks the + filesystem mtime of the secret directory so callers can detect rotation + proactively via :meth:`has_changed`. + + Args: + module: Service module name (e.g. ``"hana-agent-memory"``). + instance: Binding instance name (e.g. ``"default"`` or tenant subdomain). + binding_cls: Dataclass type used by the secret resolver as ``target``. + extract: Callable that converts a populated ``binding_cls`` to ``C``. + base_volume_mount: Root path for mounted secrets. + base_var_name: Env-var prefix used by the secret resolver. + """ + + def __init__( + self, + module: str, + instance: str, + binding_cls: Type[Any], + extract: Callable[[Any], C], + *, + base_volume_mount: str = _BASE_VOLUME_MOUNT, + base_var_name: str = _BASE_VAR_NAME, + ) -> None: + self._module = module + self._instance = instance + self._binding_cls = binding_cls + self._extract = extract + self._base_volume_mount = base_volume_mount + self._base_var_name = base_var_name + self._watch_path = os.path.join(base_volume_mount, module, instance) + self._last_mtime: Optional[float] = None + + def __call__(self) -> C: + """Read the binding and return a fresh config instance.""" + from sap_cloud_sdk.core.secret_resolver import ( + read_from_mount_and_fallback_to_env_var, + ) + + binding = self._binding_cls() + read_from_mount_and_fallback_to_env_var( + base_volume_mount=self._base_volume_mount, + base_var_name=self._base_var_name, + module=self._module, + instance=self._instance, + target=binding, + ) + binding.validate() + return self._extract(binding) + + def has_changed(self) -> bool: + """Return ``True`` if the secret directory mtime changed since the last check. + + On the first call, records the baseline mtime and returns ``False`` to + avoid false positives. Returns ``False`` when the watch path does not + exist (env-var backed bindings). + """ + try: + mtime = os.stat(self._watch_path).st_mtime + except OSError: + return False + changed = self._last_mtime is not None and mtime != self._last_mtime + self._last_mtime = mtime + return changed diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index 6073a98e..e3972a62 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -47,8 +47,11 @@ PaginationInfo, PagedResult, ) -from sap_cloud_sdk.destination.config import load_from_env_or_mount, DestinationConfig -from sap_cloud_sdk.destination._http import TokenProvider, DestinationHttp +from sap_cloud_sdk.destination.config import ( + DestinationConfig, + _make_destination_factory, +) +from sap_cloud_sdk.core._http_client import HttpClient, XsuaaAuthProvider from sap_cloud_sdk.destination._destination_http_client import DestinationHttpClient from sap_cloud_sdk.destination.client import DestinationClient from sap_cloud_sdk.destination.fragment_client import FragmentClient @@ -74,6 +77,20 @@ logger = logging.getLogger(__name__) +def _build_destination_http( + instance: Optional[str], config: Optional[DestinationConfig] +) -> HttpClient: + if config is not None: + auth = XsuaaAuthProvider(lambda: config) + binding = config + else: + factory = _make_destination_factory(instance or "default") + binding = factory() + auth = XsuaaAuthProvider(factory) + base_url = f"{binding.url.rstrip('/')}/destination-configuration" + return HttpClient(base_url, auth) + + def _mock_file(name: str) -> str: """Return the absolute path to a mocks/ file relative to the working directory.""" return os.path.join(os.getcwd(), "mocks", name) @@ -117,9 +134,7 @@ def create_client( return LocalDevDestinationClient() # Cloud mode via secret resolver or explicit config - binding = config or load_from_env_or_mount(instance) - tp = TokenProvider(binding) - http = DestinationHttp(config=binding, token_provider=tp) + http = _build_destination_http(instance, config) return DestinationClient( http, use_default_proxy, _telemetry_source=_telemetry_source @@ -162,9 +177,7 @@ def create_fragment_client( return LocalDevFragmentClient() # Use provided config or load from environment/mount (cloud mode) - binding = config or load_from_env_or_mount(instance) - tp = TokenProvider(binding) - http = DestinationHttp(config=binding, token_provider=tp) + http = _build_destination_http(instance, config) return FragmentClient(http, _telemetry_source=_telemetry_source) @@ -205,9 +218,7 @@ def create_certificate_client( return LocalDevCertificateClient() # Use provided config or load from environment/mount (cloud mode) - binding = config or load_from_env_or_mount(instance) - tp = TokenProvider(binding) - http = DestinationHttp(config=binding, token_provider=tp) + http = _build_destination_http(instance, config) return CertificateClient(http, _telemetry_source=_telemetry_source) diff --git a/src/sap_cloud_sdk/destination/_http.py b/src/sap_cloud_sdk/destination/_http.py index 72a451f7..e055bbba 100644 --- a/src/sap_cloud_sdk/destination/_http.py +++ b/src/sap_cloud_sdk/destination/_http.py @@ -1,311 +1,53 @@ -"""Simplified HTTP and OAuth utilities for Destination Service. - -Initial version: no retries and no explicit timeouts. -- TokenProvider: OAuth2 client-credentials (no caching). -- DestinationHttp: Single-shot HTTP requests, adds Authorization header. -""" +"""HTTP utilities shared across all Destination Service clients.""" from __future__ import annotations -from typing import Any, Dict, Optional -from enum import Enum - -import requests from requests import Response from requests.exceptions import RequestException -from oauthlib.oauth2 import BackendApplicationClient -from requests_oauthlib import OAuth2Session -from sap_cloud_sdk.destination.config import DestinationConfig +from sap_cloud_sdk.core._http_client import HttpClient, HttpMethod from sap_cloud_sdk.destination.exceptions import HttpError -from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain -# API version constants API_V1 = "v1" API_V2 = "v2" -class HttpMethod(Enum): - """HTTP method enumeration for request verb selection.""" - - GET = "GET" - POST = "POST" - PUT = "PUT" - PATCH = "PATCH" - DELETE = "DELETE" - - -class TokenProvider: - """Provides OAuth2 access tokens with in-memory caching and proactive refresh.""" - - def __init__(self, config: DestinationConfig) -> None: - self._config = config - - client = BackendApplicationClient(client_id=config.client_id) - self._session = OAuth2Session(client=client) - - def get_token(self, tenant_subdomain: Optional[str] = None) -> str: - """Return a valid bearer token for the Destination Service. - - If tenant_subdomain is provided, - a subscriber token URL is derived by replacing the provider identity zone segment - in the base token URL with the tenant_subdomain. Otherwise the provider token URL is used. - - Args: - tenant_subdomain: Optional subscriber tenant subdomain. When provided, the token URL is adapted - for subscriber context. - - Returns: - A non-empty OAuth2 access token string. - - Raises: - HttpError: If the token response is missing an access_token or the underlying token - acquisition fails. - """ - token_url = self._config.token_url - identityzone = self._config.identityzone - - _validate_tenant_subdomain(tenant_subdomain) - - if tenant_subdomain: - token_url = token_url.replace(str(identityzone), tenant_subdomain) - - token: Dict[str, Any] = self._session.fetch_token( - token_url=token_url, - client_id=self._config.client_id, - client_secret=self._config.client_secret, - include_client_id=True, - ) - access_token = token.get("access_token") - if not access_token: - raise HttpError("token response missing access_token") - return str(access_token) - - -class DestinationHttp: - """HTTP client wrapper for Destination Service (single-shot, no retries/timeout).""" - - def __init__( - self, - config: DestinationConfig, - token_provider: TokenProvider, - session: Optional[requests.Session] = None, - ) -> None: - """Initialize DestinationHttp. - - Args: - config: Destination configuration with base URL for the service. - token_provider: Provider that supplies OAuth2 access tokens. - session: Optional requests.Session to reuse; if None, a new session is created. - """ - self._config = config - self._token_provider = token_provider - self._session = session or requests.Session() - - # Construct base URL: /destination-configuration - base = self._config.url.rstrip("/") - self._base_url = f"{base}/destination-configuration" - - @property - def base_url(self) -> str: - return self._base_url - - def _auth_headers(self, tenant_subdomain: Optional[str] = None) -> Dict[str, str]: - token = self._token_provider.get_token(tenant_subdomain) - return {"Authorization": f"Bearer {token}"} - - def _request( - self, - method: HttpMethod | str, - path: str, - *, - params: Optional[Dict[str, Any]] = None, - json: Optional[Any] = None, - extra_headers: Optional[Dict[str, str]] = None, - tenant_subdomain: Optional[str] = None, - ) -> Response: - url = f"{self._base_url}/{path.lstrip('/')}" - headers = {"Accept": "application/json"} - headers.update(self._auth_headers(tenant_subdomain)) - if extra_headers: - headers.update(extra_headers) - - # Normalize method to string - method_str = ( - method.value if isinstance(method, HttpMethod) else str(method).upper() - ) - - try: - resp = self._session.request( - method=method_str, - url=url, - headers=headers, - params=params, - json=json, - ) - except RequestException as e: - raise HttpError(f"request failed: {e}") - - if 200 <= resp.status_code < 300: - return resp - - text: str = "" - try: - text = resp.text - except Exception: - text = "" - - raise HttpError( - f"HTTP {resp.status_code} for {method_str} {url}", - status_code=resp.status_code, - response_text=text, - ) - - # Public helpers for REST verbs - - def get( - self, - path: str, - *, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - tenant_subdomain: Optional[str] = None, - ) -> Response: - """Send a GET request. - - Args: - path: Relative API path under destination-configuration/v1. - params: Optional query parameters. - headers: Optional additional request headers. - tenant_subdomain: Optional subscriber tenant subdomain for token acquisition. - - Returns: - requests.Response if the status code is 2xx. - - Raises: - HttpError: If the request fails or returns a non-2xx status. - """ - return self._request( - HttpMethod.GET, - path, - params=params, - extra_headers=headers, - tenant_subdomain=tenant_subdomain, - ) - - def post( - self, - path: str, - *, - body: Any, - headers: Optional[Dict[str, str]] = None, - tenant_subdomain: Optional[str] = None, - ) -> Response: - """Send a POST request. - - Args: - path: Relative API path under destination-configuration/v1. - body: JSON-serializable request body. - headers: Optional additional request headers. - tenant_subdomain: Optional subscriber tenant subdomain for token acquisition. - - Returns: - requests.Response if the status code is 2xx. - - Raises: - HttpError: If the request fails or returns a non-2xx status. - """ - return self._request( - HttpMethod.POST, - path, - json=body, - extra_headers=headers, +def _request( + http: HttpClient, + method: HttpMethod, + path: str, + *, + params=None, + json=None, + headers=None, + tenant_subdomain=None, +) -> Response: + + all_headers: dict = {"Accept": "application/json"} + if headers: + all_headers.update(headers) + + normalized_path = f"/{path.lstrip('/')}" + try: + resp = http.request( + method, + normalized_path, tenant_subdomain=tenant_subdomain, - ) - - def put( - self, - path: str, - *, - body: Any, - headers: Optional[Dict[str, str]] = None, - tenant_subdomain: Optional[str] = None, - ) -> Response: - """Send a PUT request. - - Args: - path: Relative API path under destination-configuration/v1. - body: JSON-serializable request body. - headers: Optional additional request headers. - tenant_subdomain: Optional subscriber tenant subdomain for token acquisition. - - Returns: - requests.Response if the status code is 2xx. - - Raises: - HttpError: If the request fails or returns a non-2xx status. - """ - return self._request( - HttpMethod.PUT, - path, - json=body, - extra_headers=headers, - tenant_subdomain=tenant_subdomain, - ) - - def patch( - self, - path: str, - *, - body: Any, - headers: Optional[Dict[str, str]] = None, - tenant_subdomain: Optional[str] = None, - ) -> Response: - """Send a PATCH request. - - Args: - path: Relative API path under destination-configuration/v1. - body: JSON-serializable request body. - headers: Optional additional request headers. - tenant_subdomain: Optional subscriber tenant subdomain for token acquisition. - - Returns: - requests.Response if the status code is 2xx. - - Raises: - HttpError: If the request fails or returns a non-2xx status. - """ - return self._request( - HttpMethod.PATCH, - path, - json=body, - extra_headers=headers, - tenant_subdomain=tenant_subdomain, - ) - - def delete( - self, - path: str, - *, - headers: Optional[Dict[str, str]] = None, - tenant_subdomain: Optional[str] = None, - ) -> Response: - """Send a DELETE request. - - Args: - path: Relative API path under destination-configuration/v1. - headers: Optional additional request headers. - tenant_subdomain: Optional subscriber tenant subdomain for token acquisition. - - Returns: - requests.Response if the status code is 2xx. - - Raises: - HttpError: If the request fails or returns a non-2xx status. - """ - return self._request( - HttpMethod.DELETE, - path, - extra_headers=headers, - tenant_subdomain=tenant_subdomain, - ) + params=params, + json=json, + headers=all_headers, + ) + except RequestException as e: + raise HttpError(f"request failed: {e}") + if isinstance(resp.status_code, int) and 200 <= resp.status_code < 300: + return resp + text: str = "" + try: + text = resp.text + except Exception: + text = "" + raise HttpError( + f"HTTP {resp.status_code} for {method.value} {normalized_path}", + status_code=resp.status_code, + response_text=text, + ) diff --git a/src/sap_cloud_sdk/destination/certificate_client.py b/src/sap_cloud_sdk/destination/certificate_client.py index aec3cfb7..883ede0e 100644 --- a/src/sap_cloud_sdk/destination/certificate_client.py +++ b/src/sap_cloud_sdk/destination/certificate_client.py @@ -5,7 +5,8 @@ from typing import List, Optional, TypeVar, Callable from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics -from sap_cloud_sdk.destination._http import DestinationHttp, API_V1 +from sap_cloud_sdk.core._http_client import HttpClient +from sap_cloud_sdk.destination._http import API_V1, HttpMethod, _request from sap_cloud_sdk.destination._models import ( AccessStrategy, Certificate, @@ -63,7 +64,7 @@ class CertificateClient: def __init__( self, - http: DestinationHttp, + http: HttpClient, _telemetry_source: Optional[Module] = None, ) -> None: """Initialize CertificateClient with dependency injection. @@ -241,7 +242,13 @@ def create_certificate( body = certificate.to_dict() try: - self._http.post(f"{API_V1}/{coll}", body=body, tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.POST, + f"{API_V1}/{coll}", + json=body, + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -276,7 +283,13 @@ def update_certificate( body = certificate.to_dict() try: - self._http.put(f"{API_V1}/{coll}", body=body, tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.PUT, + f"{API_V1}/{coll}", + json=body, + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -306,7 +319,12 @@ def delete_certificate( coll = self._sub_path_for_level(level) try: - self._http.delete(f"{API_V1}/{coll}/{name}", tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.DELETE, + f"{API_V1}/{coll}/{name}", + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -338,8 +356,11 @@ def get_certificate_labels( """ try: path = self._sub_path_for_level(level) - resp = self._http.get( - f"{API_V1}/{path}/{name}/labels", tenant_subdomain=tenant + resp = _request( + self._http, + HttpMethod.GET, + f"{API_V1}/{path}/{name}/labels", + tenant_subdomain=tenant, ) data = resp.json() if not isinstance(data, list): @@ -379,9 +400,11 @@ def update_certificate_labels( resolved_level = level or Level.SUB_ACCOUNT try: path = self._sub_path_for_level(resolved_level) - self._http.put( + _request( + self._http, + HttpMethod.PUT, f"{API_V1}/{path}/{name}/labels", - body=[lbl.to_dict() for lbl in labels], + json=[lbl.to_dict() for lbl in labels], tenant_subdomain=tenant, ) except HttpError: @@ -414,9 +437,11 @@ def patch_certificate_labels( resolved_level = level or Level.SUB_ACCOUNT try: path = self._sub_path_for_level(resolved_level) - self._http.patch( + _request( + self._http, + HttpMethod.PATCH, f"{API_V1}/{path}/{name}/labels", - body=patch.to_dict(), + json=patch.to_dict(), tenant_subdomain=tenant, ) except HttpError: @@ -450,8 +475,11 @@ def _get_certificate( """ try: path = self._sub_path_for_level(level) - resp = self._http.get( - f"{API_V1}/{path}/{name}", tenant_subdomain=tenant_subdomain + resp = _request( + self._http, + HttpMethod.GET, + f"{API_V1}/{path}/{name}", + tenant_subdomain=tenant_subdomain, ) data = resp.json() @@ -490,8 +518,12 @@ def _list_certificates( try: path = self._sub_path_for_level(level) params = filter.to_query_params() if filter else {} - resp = self._http.get( - f"{API_V1}/{path}", tenant_subdomain=tenant_subdomain, params=params + resp = _request( + self._http, + HttpMethod.GET, + f"{API_V1}/{path}", + tenant_subdomain=tenant_subdomain, + params=params, ) data = resp.json() diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index 0bb7e630..d9345854 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -8,7 +8,8 @@ from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var -from sap_cloud_sdk.destination._http import DestinationHttp, API_V1, API_V2 +from sap_cloud_sdk.core._http_client import HttpClient +from sap_cloud_sdk.destination._http import API_V1, API_V2, HttpMethod, _request from sap_cloud_sdk.destination._models import ( AccessStrategy, ConsumptionLevel, @@ -44,7 +45,7 @@ class DestinationClient: """Client for SAP Destination Service operations. This class exposes read and write operations for destinations at both - subaccount and instance levels. It expects a configured DestinationHttp + subaccount and instance levels. It expects a configured HttpClient instance injected via the constructor. Note: @@ -97,7 +98,7 @@ class DestinationClient: def __init__( self, - http: DestinationHttp, + http: HttpClient, use_default_proxy: bool = False, _telemetry_source: Optional[Module] = None, ) -> None: @@ -109,7 +110,7 @@ def __init__( the HTTP transport and handles environment detection. Args: - http: Configured HTTP transport for the Destination Service. + http: Configured HttpClient for the Destination Service. use_default_proxy: Whether to use the default transparent proxy for all get operations. When True, will attempt to load transparent proxy configuration from APPFND_CONHOS_TRANSP_PROXY environment variable. Defaults to False. @@ -442,8 +443,13 @@ def get_destination( if options and options.skip_token_retrieval: params["$skipTokenRetrieval"] = "true" - resp = self._http.get( - path, headers=headers, tenant_subdomain=tenant, params=params or None + resp = _request( + self._http, + HttpMethod.GET, + path, + headers=headers, + tenant_subdomain=tenant, + params=params or None, ) data = resp.json() @@ -498,7 +504,13 @@ def create_destination( body = dest.to_dict() try: - self._http.post(f"{API_V1}/{coll}", body=body, tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.POST, + f"{API_V1}/{coll}", + json=body, + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -533,7 +545,13 @@ def update_destination( body = dest.to_dict() try: - self._http.put(f"{API_V1}/{coll}", body=body, tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.PUT, + f"{API_V1}/{coll}", + json=body, + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -563,7 +581,12 @@ def delete_destination( coll = self._sub_path_for_level(level) try: - self._http.delete(f"{API_V1}/{coll}/{name}", tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.DELETE, + f"{API_V1}/{coll}/{name}", + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -595,8 +618,11 @@ def get_destination_labels( """ try: path = self._sub_path_for_level(level) - resp = self._http.get( - f"{API_V1}/{path}/{name}/labels", tenant_subdomain=tenant + resp = _request( + self._http, + HttpMethod.GET, + f"{API_V1}/{path}/{name}/labels", + tenant_subdomain=tenant, ) data = resp.json() if not isinstance(data, list): @@ -636,9 +662,11 @@ def update_destination_labels( resolved_level = level or Level.SUB_ACCOUNT try: path = self._sub_path_for_level(resolved_level) - self._http.put( + _request( + self._http, + HttpMethod.PUT, f"{API_V1}/{path}/{name}/labels", - body=[lbl.to_dict() for lbl in labels], + json=[lbl.to_dict() for lbl in labels], tenant_subdomain=tenant, ) except HttpError: @@ -671,9 +699,11 @@ def patch_destination_labels( resolved_level = level or Level.SUB_ACCOUNT try: path = self._sub_path_for_level(resolved_level) - self._http.patch( + _request( + self._http, + HttpMethod.PATCH, f"{API_V1}/{path}/{name}/labels", - body=patch.to_dict(), + json=patch.to_dict(), tenant_subdomain=tenant, ) except HttpError: @@ -736,8 +766,11 @@ def _get_destination( """ try: path = self._sub_path_for_level(level) - resp = self._http.get( - f"{API_V1}/{path}/{name}", tenant_subdomain=tenant_subdomain + resp = _request( + self._http, + HttpMethod.GET, + f"{API_V1}/{path}/{name}", + tenant_subdomain=tenant_subdomain, ) data = resp.json() @@ -776,7 +809,9 @@ def _list_destinations( try: path = self._sub_path_for_level(level) query_params = filter.to_query_params() if filter else {} - resp = self._http.get( + resp = _request( + self._http, + HttpMethod.GET, f"{API_V1}/{path}", tenant_subdomain=tenant_subdomain, params=query_params, diff --git a/src/sap_cloud_sdk/destination/config.py b/src/sap_cloud_sdk/destination/config.py index e0fce1c9..a17ff10b 100644 --- a/src/sap_cloud_sdk/destination/config.py +++ b/src/sap_cloud_sdk/destination/config.py @@ -28,7 +28,7 @@ """ from dataclasses import dataclass -from typing import Optional +from typing import Optional, TYPE_CHECKING import os from sap_cloud_sdk.core.secret_resolver.resolver import ( @@ -37,6 +37,9 @@ from sap_cloud_sdk.destination.exceptions import ConfigError from sap_cloud_sdk.destination._models import TransparentProxy +if TYPE_CHECKING: + from sap_cloud_sdk.core.secret_resolver import ConfigFactory + _TRANSPARENT_PROXY_ENV_VAR = "APPFND_CONHOS_TRANSP_PROXY" _TRANSPARENT_PROXY_ENV_VAR = "APPFND_CONHOS_TRANSP_PROXY" @@ -149,6 +152,26 @@ def load_from_env_or_mount(instance: Optional[str] = None) -> DestinationConfig: ) +def _make_destination_factory(instance: str) -> "ConfigFactory[DestinationConfig]": + """Return a :class:`~sap_cloud_sdk.core.secret_resolver.ConfigFactory` for the given instance.""" + from sap_cloud_sdk.core.secret_resolver import ConfigFactory + + def _extract(binding: BindingData) -> DestinationConfig: + try: + return binding.to_binding() + except Exception as exc: + raise ConfigError( + f"Failed to load Destination configuration for instance '{instance}': {exc}" + ) from exc + + return ConfigFactory( + module="destination", + instance=instance, + binding_cls=BindingData, + extract=_extract, + ) + + def load_transparent_proxy() -> Optional[TransparentProxy]: """Load transparent proxy configuration from environment variable. The environment variable APPFND_CONHOS_TRANSP_PROXY should be in the format: diff --git a/src/sap_cloud_sdk/destination/fragment_client.py b/src/sap_cloud_sdk/destination/fragment_client.py index a377ce16..4312c193 100644 --- a/src/sap_cloud_sdk/destination/fragment_client.py +++ b/src/sap_cloud_sdk/destination/fragment_client.py @@ -5,7 +5,8 @@ from typing import Callable, List, Optional, TypeVar from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics -from sap_cloud_sdk.destination._http import DestinationHttp, API_V1 +from sap_cloud_sdk.core._http_client import HttpClient +from sap_cloud_sdk.destination._http import API_V1, HttpMethod, _request from sap_cloud_sdk.destination._models import ( AccessStrategy, Fragment, @@ -59,7 +60,7 @@ class FragmentClient: def __init__( self, - http: DestinationHttp, + http: HttpClient, _telemetry_source: Optional[Module] = None, ) -> None: """Initialize FragmentClient with dependency injection. @@ -226,7 +227,13 @@ def create_fragment( body = fragment.to_dict() try: - self._http.post(f"{API_V1}/{coll}", body=body, tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.POST, + f"{API_V1}/{coll}", + json=body, + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -261,7 +268,13 @@ def update_fragment( body = fragment.to_dict() try: - self._http.put(f"{API_V1}/{coll}", body=body, tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.PUT, + f"{API_V1}/{coll}", + json=body, + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -291,7 +304,12 @@ def delete_fragment( coll = self._sub_path_for_level(level) try: - self._http.delete(f"{API_V1}/{coll}/{name}", tenant_subdomain=tenant) + _request( + self._http, + HttpMethod.DELETE, + f"{API_V1}/{coll}/{name}", + tenant_subdomain=tenant, + ) except HttpError: raise except Exception as e: @@ -321,8 +339,11 @@ def get_fragment_labels( """ try: path = self._sub_path_for_level(level) - resp = self._http.get( - f"{API_V1}/{path}/{name}/labels", tenant_subdomain=tenant + resp = _request( + self._http, + HttpMethod.GET, + f"{API_V1}/{path}/{name}/labels", + tenant_subdomain=tenant, ) data = resp.json() if not isinstance(data, list): @@ -362,9 +383,11 @@ def update_fragment_labels( resolved_level = level or Level.SUB_ACCOUNT try: path = self._sub_path_for_level(resolved_level) - self._http.put( + _request( + self._http, + HttpMethod.PUT, f"{API_V1}/{path}/{name}/labels", - body=[lbl.to_dict() for lbl in labels], + json=[lbl.to_dict() for lbl in labels], tenant_subdomain=tenant, ) except HttpError: @@ -397,9 +420,11 @@ def patch_fragment_labels( resolved_level = level or Level.SUB_ACCOUNT try: path = self._sub_path_for_level(resolved_level) - self._http.patch( + _request( + self._http, + HttpMethod.PATCH, f"{API_V1}/{path}/{name}/labels", - body=patch.to_dict(), + json=patch.to_dict(), tenant_subdomain=tenant, ) except HttpError: @@ -433,8 +458,11 @@ def _get_fragment( """ try: path = self._sub_path_for_level(level) - resp = self._http.get( - f"{API_V1}/{path}/{name}", tenant_subdomain=tenant_subdomain + resp = _request( + self._http, + HttpMethod.GET, + f"{API_V1}/{path}/{name}", + tenant_subdomain=tenant_subdomain, ) data = resp.json() @@ -471,7 +499,9 @@ def _list_fragments( try: path = self._sub_path_for_level(level) query_params = filter.to_query_params() if filter else {} - resp = self._http.get( + resp = _request( + self._http, + HttpMethod.GET, f"{API_V1}/{path}", tenant_subdomain=tenant_subdomain, params=query_params, diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index 959ed315..ba21ecb1 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -1,7 +1,9 @@ """Unit tests for AgentMemoryClient operations (v1 API).""" import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch +from requests import Response +from urllib.parse import parse_qs, urlparse from sap_cloud_sdk.agent_memory._endpoints import ( MEMORIES, @@ -9,7 +11,6 @@ MESSAGES, RETENTION_CONFIG, ) -from sap_cloud_sdk.agent_memory._http_transport import HttpTransport from sap_cloud_sdk.agent_memory._models import ( AccessStrategy, Memory, @@ -25,26 +26,49 @@ AgentMemoryConfigError, AgentMemoryValidationError, ) +from sap_cloud_sdk.core._http_client import HttpClient, HttpMethod -def _make_client() -> tuple[AgentMemoryClient, MagicMock]: - """Return an AgentMemoryClient with a mocked provider transport.""" - transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) - return client, transport +def _parse_call_params(call_args) -> dict: + """Extract query-string params from the URL path in a mock_http.request call.""" + path = call_args[0][1] + parsed = urlparse(path) + if not parsed.query: + return {} + return {k: v[0] for k, v in parse_qs(parsed.query, keep_blank_values=True).items()} + + +def _make_response(status=200, json_data=None, text="", content=b"..."): + resp = Mock(spec=Response) + resp.status_code = status + resp.text = text + resp.content = content if status != 204 else b"" + resp.ok = 200 <= status < 300 + if json_data is not None: + resp.json.return_value = json_data + return resp + + +def _make_client() -> tuple[AgentMemoryClient, Mock]: + """Return an AgentMemoryClient with a mocked HttpClient (PROVIDER strategy).""" + http = Mock(spec=HttpClient) + http.request.return_value = _make_response(200) + client = AgentMemoryClient(http, access_strategy=AccessStrategy.PROVIDER) + return client, http def _make_subscriber_client( tenant: str = "default-sub", -) -> tuple[AgentMemoryClient, MagicMock]: - """Return a client with SUBSCRIBER default and a pre-warmed mock transport.""" - transport = MagicMock(spec=HttpTransport) +) -> tuple[AgentMemoryClient, Mock]: + """Return a client with SUBSCRIBER strategy and a mocked HttpClient.""" + http = Mock(spec=HttpClient) + http.request.return_value = _make_response(200) client = AgentMemoryClient( - transport, + http, access_strategy=AccessStrategy.SUBSCRIBER, tenant=tenant, ) - return client, transport + return client, http # ── create_client factory ───────────────────────────────────────────────────── @@ -55,11 +79,11 @@ class TestCreateClient: def test_uses_provided_config(self): """Factory with explicit config creates a client successfully.""" config = AgentMemoryConfig(base_url="http://localhost:3000") - with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: - MockTransport.return_value = MagicMock(spec=HttpTransport) + with patch("sap_cloud_sdk.agent_memory._build_agent_memory_http") as mock_build: + mock_build.return_value = Mock(spec=HttpClient) client = create_client(config=config, access_strategy=AccessStrategy.PROVIDER) assert isinstance(client, AgentMemoryClient) - assert client._transport is not None + assert client._http is not None def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): """Factory with SUBSCRIBER loads the tenant binding.""" @@ -72,14 +96,14 @@ def test_subscriber_strategy_loads_tenant_binding(self, monkeypatch): "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_ACME_CORP_UAA", json.dumps({"url": "http://acme.auth.example.com", "clientid": "c", "clientsecret": "s"}), ) - with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: - MockTransport.return_value = MagicMock(spec=HttpTransport) + with patch("sap_cloud_sdk.agent_memory._build_agent_memory_http") as mock_build: + mock_build.return_value = Mock(spec=HttpClient) client = create_client( access_strategy=AccessStrategy.SUBSCRIBER, tenant="acme-corp", ) assert isinstance(client, AgentMemoryClient) - assert client._transport is not None + assert client._http is not None def test_provider_strategy_loads_default_binding(self, monkeypatch): """Factory with PROVIDER loads the default binding.""" @@ -92,11 +116,11 @@ def test_provider_strategy_loads_default_binding(self, monkeypatch): "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", json.dumps({"url": "http://auth.example.com", "clientid": "c", "clientsecret": "s"}), ) - with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: - MockTransport.return_value = MagicMock(spec=HttpTransport) + with patch("sap_cloud_sdk.agent_memory._build_agent_memory_http") as mock_build: + mock_build.return_value = Mock(spec=HttpClient) client = create_client(access_strategy=AccessStrategy.PROVIDER) assert isinstance(client, AgentMemoryClient) - assert client._transport is not None + assert client._http is not None # ── Access strategy and per-tenant transport routing ───────────────────────── @@ -108,39 +132,39 @@ class TestAccessStrategy: def test_subscriber_without_tenant_raises_at_construction(self): """SUBSCRIBER without tenant raises AgentMemoryValidationError at construction.""" - transport = MagicMock(spec=HttpTransport) + http = Mock(spec=HttpClient) with pytest.raises(AgentMemoryValidationError, match="tenant"): - AgentMemoryClient(transport, access_strategy=AccessStrategy.SUBSCRIBER) + AgentMemoryClient(http, access_strategy=AccessStrategy.SUBSCRIBER) def test_subscriber_with_tenant_constructs_successfully(self): """SUBSCRIBER with tenant constructs without error.""" client, _ = _make_subscriber_client("acme") - assert client._transport is not None + assert client._http is not None def test_provider_constructs_without_tenant(self): """PROVIDER constructs without tenant.""" client, _ = _make_client() - assert client._transport is not None + assert client._http is not None # ── Transport routing ────────────────────────────────────────────────────── def test_client_default_subscriber_uses_subscriber_transport(self): - """Client with SUBSCRIBER default uses the provided transport.""" - client, sub_transport = _make_subscriber_client("acme") - sub_transport.post.return_value = { + """Client with SUBSCRIBER default uses the provided HttpClient.""" + client, mock_http = _make_subscriber_client("acme") + mock_http.request.return_value = _make_response(200, json_data={ "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", - } + }) client.add_memory("a", "u", "x") - sub_transport.post.assert_called_once() + mock_http.request.assert_called_once() def test_provider_only_uses_provider_transport(self): - """PROVIDER uses the provided transport.""" - client, provider_transport = _make_client() - provider_transport.post.return_value = { + """PROVIDER uses the provided HttpClient.""" + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "m1", "agentID": "a", "invokerID": "u", "content": "x", - } + }) client.add_memory("a", "u", "x") - provider_transport.post.assert_called_once() + mock_http.request.assert_called_once() # ── Memory CRUD operations ──────────────────────────────────────────────────── @@ -150,112 +174,113 @@ class TestMemoryCRUD: def test_add_memory_posts_correct_payload(self): """add_memory sends required and optional fields in the POST body.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "mem-1", "agentID": "agent-a", "invokerID": "user-b", "content": "some memory", "createType": "DIRECT", - } + }) memory = client.add_memory("agent-a", "user-b", "some memory") assert isinstance(memory, Memory) assert memory.id == "mem-1" - payload = mock_transport.post.call_args[1]["json"] - assert payload["agentID"] == "agent-a" - assert payload["invokerID"] == "user-b" - assert payload["content"] == "some memory" + call = mock_http.request.call_args + assert call[0][0] == HttpMethod.POST + assert call[1]["json"]["agentID"] == "agent-a" + assert call[1]["json"]["invokerID"] == "user-b" + assert call[1]["json"]["content"] == "some memory" def test_add_memory_with_metadata(self): """Optional metadata is included in the POST body when provided.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", - } + }) client.add_memory("a", "u", "x", metadata={"key": "val"}) - payload = mock_transport.post.call_args[1]["json"] - assert payload["metadata"] == {"key": "val"} + assert mock_http.request.call_args[1]["json"]["metadata"] == {"key": "val"} def test_add_memory_excludes_none_optionals(self): """None-valued optional fields are omitted from the POST body.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", - } + }) client.add_memory("a", "u", "x") - payload = mock_transport.post.call_args[1]["json"] + payload = mock_http.request.call_args[1]["json"] assert "metadata" not in payload assert "createType" not in payload def test_add_memory_posts_to_memories_endpoint(self): """add_memory sends the POST to the MEMORIES endpoint.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "x", - } + }) client.add_memory("a", "u", "x") - call_path = mock_transport.post.call_args[0][0] - assert call_path == MEMORIES + assert mock_http.request.call_args[0][0] == HttpMethod.POST + assert mock_http.request.call_args[0][1] == MEMORIES def test_get_memory_calls_get_with_memory_id(self): """get_memory constructs the correct path with the memory ID.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "mem-1", "agentID": "a", "invokerID": "u", "content": "hello", - } + }) memory = client.get_memory("mem-1") assert memory.id == "mem-1" - call_path = mock_transport.get.call_args[0][0] - assert call_path == f"{MEMORIES}(mem-1)" + assert mock_http.request.call_args[0][0] == HttpMethod.GET + assert mock_http.request.call_args[0][1] == f"{MEMORIES}(mem-1)" def test_update_memory_calls_patch(self): """update_memory sends a PATCH with the updated fields.""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.update_memory("mem-1", content="updated") - mock_transport.patch.assert_called_once() - payload = mock_transport.patch.call_args[1]["json"] - assert payload["content"] == "updated" + assert mock_http.request.call_args[0][0] == HttpMethod.PATCH + assert mock_http.request.call_args[1]["json"]["content"] == "updated" def test_update_memory_excludes_none_fields(self): """update_memory omits None-valued optional fields from the PATCH body.""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.update_memory("mem-1", content="x") - payload = mock_transport.patch.call_args[1]["json"] - assert "metadata" not in payload + assert "metadata" not in mock_http.request.call_args[1]["json"] def test_update_memory_with_metadata_only(self): """update_memory supports updating metadata without content.""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.update_memory("mem-1", metadata={"key": "new-meta"}) - payload = mock_transport.patch.call_args[1]["json"] + payload = mock_http.request.call_args[1]["json"] assert payload["metadata"] == {"key": "new-meta"} assert "content" not in payload def test_delete_memory_calls_delete(self): """delete_memory sends a DELETE to the correct path.""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.delete_memory("mem-1") - mock_transport.delete.assert_called_once() - call_path = mock_transport.delete.call_args[0][0] - assert call_path == f"{MEMORIES}(mem-1)" + assert mock_http.request.call_args[0][0] == HttpMethod.DELETE + assert mock_http.request.call_args[0][1] == f"{MEMORIES}(mem-1)" # ── Memory listing ──────────────────────────────────────────────────────────── @@ -265,12 +290,12 @@ class TestListMemories: def test_returns_list_of_memories(self): """list_memories returns a list of Memory objects.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "value": [ {"id": "m1", "agentID": "a", "invokerID": "u", "content": "memory 1"}, ], - } + }) memories = client.list_memories(agent_id="a", invoker_id="u") @@ -279,39 +304,39 @@ def test_returns_list_of_memories(self): def test_passes_filter_for_agent_and_invoker(self): """Convenience agent_id/invoker_id args are converted to $filter.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories(agent_id="agent-x", invoker_id="user-y") - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "agentID eq 'agent-x'" in params["$filter"] assert "invokerID eq 'user-y'" in params["$filter"] def test_default_limit_is_50(self): """Default limit is 50 ($top=50).""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories() - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$top"] == "50" def test_custom_limit(self): """Custom limit is forwarded as $top.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories(limit=5) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$top"] == "5" def test_empty_list(self): """list_memories handles empty responses correctly.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) memories = client.list_memories() @@ -319,52 +344,52 @@ def test_empty_list(self): def test_offset_passes_skip_param(self): """Non-zero offset is forwarded as $skip.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories(offset=50) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$skip"] == "50" def test_zero_offset_omits_skip_param(self): """Default offset of 0 does not add $skip to the request.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories() - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "$skip" not in params def test_filter_metadata_contains_adds_contains_clause(self): """A metadata FilterDefinition produces a contains(metadata, ...) expression.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories( filters=[FilterDefinition(target="metadata", contains="john")], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "contains(metadata, 'john')" in params["$filter"] def test_filter_content_contains_adds_contains_clause(self): """A content FilterDefinition produces a contains(content, ...) expression.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories( filters=[FilterDefinition(target="content", contains="dark mode")], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "contains(content, 'dark mode')" in params["$filter"] def test_filter_multiple_clauses_joined_with_and(self): """Multiple FilterDefinitions are joined with 'and' in $filter.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories( filters=[ @@ -373,7 +398,7 @@ def test_filter_multiple_clauses_joined_with_and(self): ], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) f = params["$filter"] assert "contains(metadata, 'john')" in f assert "contains(content, 'user prefers')" in f @@ -381,8 +406,8 @@ def test_filter_multiple_clauses_joined_with_and(self): def test_filter_combines_with_agent_and_invoker_filters(self): """FilterDefinitions are combined with agent_id/invoker_id eq predicates.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories( agent_id="my-agent", @@ -390,7 +415,7 @@ def test_filter_combines_with_agent_and_invoker_filters(self): filters=[FilterDefinition(target="content", contains="dark mode")], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) f = params["$filter"] assert "agentID eq 'my-agent'" in f assert "invokerID eq 'user-1'" in f @@ -398,12 +423,12 @@ def test_filter_combines_with_agent_and_invoker_filters(self): def test_filter_none_does_not_change_behaviour(self): """filter=None produces the same $filter as before (no regression).""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_memories(agent_id="a", invoker_id="u", filters=None) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" @@ -411,8 +436,8 @@ class TestCountMemories: def test_returns_count_from_response(self): """count_memories returns the @odata.count value.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": [], "@odata.count": 42} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": [], "@odata.count": 42}) total = client.count_memories(agent_id="a", invoker_id="u") @@ -420,30 +445,30 @@ def test_returns_count_from_response(self): def test_sends_top_0_and_count_true(self): """count_memories uses $top=0 and $count=true.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": [], "@odata.count": 0} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": [], "@odata.count": 0}) client.count_memories() - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$top"] == "0" assert params["$count"] == "true" def test_passes_filter_when_agent_and_invoker_provided(self): """count_memories forwards agent_id and invoker_id as $filter.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": [], "@odata.count": 3} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": [], "@odata.count": 3}) client.count_memories(agent_id="agt", invoker_id="usr") - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "agentID eq 'agt'" in params["$filter"] assert "invokerID eq 'usr'" in params["$filter"] def test_returns_zero_when_count_missing(self): """count_memories returns 0 when count is absent from response.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) total = client.count_memories() @@ -457,13 +482,13 @@ class TestSearchMemories: def test_returns_results_in_api_order(self): """search_memories returns results in the order returned by the API.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "value": [ {"id": "m1", "agentID": "a", "invokerID": "u", "content": "first", "similarity": 0.5}, {"id": "m2", "agentID": "a", "invokerID": "u", "content": "second", "similarity": 0.9}, ] - } + }) results = client.search_memories("a", "u", "test query") @@ -474,14 +499,14 @@ def test_returns_results_in_api_order(self): def test_posts_correct_payload(self): """search_memories sends the correct payload to the search endpoint.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.search_memories("agent-a", "user-b", "my query", threshold=0.7, limit=5) - call_path = mock_transport.post.call_args[0][0] - assert call_path == MEMORY_SEARCH - payload = mock_transport.post.call_args[1]["json"] + assert mock_http.request.call_args[0][0] == HttpMethod.POST + assert mock_http.request.call_args[0][1] == MEMORY_SEARCH + payload = mock_http.request.call_args[1]["json"] assert payload["agentID"] == "agent-a" assert payload["invokerID"] == "user-b" assert payload["query"] == "my query" @@ -490,8 +515,8 @@ def test_posts_correct_payload(self): def test_empty_results(self): """search_memories handles empty search results.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) results = client.search_memories("a", "u", "empty query") @@ -499,12 +524,12 @@ def test_empty_results(self): def test_uses_default_threshold_and_limit(self): """search_memories uses default threshold=0.6 and limit=10.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.search_memories("a", "u", "query") - payload = mock_transport.post.call_args[1]["json"] + payload = mock_http.request.call_args[1]["json"] assert payload["threshold"] == 0.6 assert payload["top"] == 10 assert "skip" not in payload @@ -517,15 +542,15 @@ class TestMessageCRUD: def test_add_message_posts_correct_payload(self): """add_message sends required fields in the POST body.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "msg-1", "agentID": "agent-a", "invokerID": "user-b", "messageGroup": "conv-1", "role": "USER", "content": "Hello!", - } + }) message = client.add_message( "agent-a", "user-b", "conv-1", MessageRole.USER, "Hello!", @@ -534,7 +559,7 @@ def test_add_message_posts_correct_payload(self): assert isinstance(message, Message) assert message.id == "msg-1" assert message.role == "USER" - payload = mock_transport.post.call_args[1]["json"] + payload = mock_http.request.call_args[1]["json"] assert payload["agentID"] == "agent-a" assert payload["invokerID"] == "user-b" assert payload["messageGroup"] == "conv-1" @@ -543,67 +568,65 @@ def test_add_message_posts_correct_payload(self): def test_add_message_posts_to_messages_endpoint(self): """add_message sends the POST to the MESSAGES endpoint.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "msg-1", "agentID": "a", "invokerID": "u", "messageGroup": "g", "role": "USER", "content": "hi", - } + }) client.add_message("a", "u", "g", MessageRole.USER, "hi") - call_path = mock_transport.post.call_args[0][0] - assert call_path == MESSAGES + assert mock_http.request.call_args[0][0] == HttpMethod.POST + assert mock_http.request.call_args[0][1] == MESSAGES def test_add_message_with_metadata(self): """Optional metadata is included when provided.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "msg-1", "agentID": "a", "invokerID": "u", "messageGroup": "g", "role": "USER", "content": "hi", "metadata": {"key": "val"}, - } + }) client.add_message("a", "u", "g", MessageRole.USER, "hi", metadata={"key": "val"}) - payload = mock_transport.post.call_args[1]["json"] - assert payload["metadata"] == {"key": "val"} + assert mock_http.request.call_args[1]["json"]["metadata"] == {"key": "val"} def test_add_message_excludes_none_metadata(self): """None-valued metadata is omitted from the POST body.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "msg-1", "agentID": "a", "invokerID": "u", "messageGroup": "g", "role": "USER", "content": "hi", - } + }) client.add_message("a", "u", "g", MessageRole.USER, "hi") - payload = mock_transport.post.call_args[1]["json"] - assert "metadata" not in payload + assert "metadata" not in mock_http.request.call_args[1]["json"] def test_get_message_calls_get_with_message_id(self): """get_message constructs the correct path with the message ID.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": "msg-1", "agentID": "a", "invokerID": "u", "messageGroup": "g", "role": "USER", "content": "hi", - } + }) message = client.get_message("msg-1") assert message.id == "msg-1" - call_path = mock_transport.get.call_args[0][0] - assert call_path == f"{MESSAGES}(msg-1)" + assert mock_http.request.call_args[0][0] == HttpMethod.GET + assert mock_http.request.call_args[0][1] == f"{MESSAGES}(msg-1)" def test_delete_message_calls_delete(self): """delete_message sends a DELETE to the correct path.""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.delete_message("msg-1") - mock_transport.delete.assert_called_once() - call_path = mock_transport.delete.call_args[0][0] - assert call_path == f"{MESSAGES}(msg-1)" + assert mock_http.request.call_args[0][0] == HttpMethod.DELETE + assert mock_http.request.call_args[0][1] == f"{MESSAGES}(msg-1)" # ── Message listing ─────────────────────────────────────────────────────────── @@ -613,15 +636,15 @@ class TestListMessages: def test_returns_list_of_messages(self): """list_messages returns a list of Message objects.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "value": [ { "id": "msg-1", "agentID": "a", "invokerID": "u", "messageGroup": "g", "role": "USER", "content": "hi", }, ], - } + }) messages = client.list_messages(agent_id="a", invoker_id="u") @@ -630,15 +653,15 @@ def test_returns_list_of_messages(self): def test_passes_convenience_filters(self): """Convenience filters are converted to $filter.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages( agent_id="a", invoker_id="u", message_group="conv-1", role="USER", ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) f = params["$filter"] assert "agentID eq 'a'" in f assert "invokerID eq 'u'" in f @@ -647,28 +670,28 @@ def test_passes_convenience_filters(self): def test_default_limit_is_50(self): """Default limit is 50 ($top=50).""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages() - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$top"] == "50" def test_custom_limit(self): """Custom limit is forwarded as $top.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages(limit=20) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$top"] == "20" def test_empty_list(self): """list_messages handles empty responses correctly.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) messages = client.list_messages() @@ -676,52 +699,52 @@ def test_empty_list(self): def test_offset_passes_skip_param(self): """Non-zero offset is forwarded as $skip.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages(offset=100) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$skip"] == "100" def test_zero_offset_omits_skip_param(self): """Default offset of 0 does not add $skip to the request.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages() - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "$skip" not in params def test_filter_metadata_contains_adds_contains_clause(self): """A metadata FilterDefinition produces a contains(metadata, ...) expression.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages( filters=[FilterDefinition(target="metadata", contains="demo-app")], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "contains(metadata, 'demo-app')" in params["$filter"] def test_filter_content_contains_adds_contains_clause(self): """A content FilterDefinition produces a contains(content, ...) expression.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages( filters=[FilterDefinition(target="content", contains="invoice")], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert "contains(content, 'invoice')" in params["$filter"] def test_filter_multiple_clauses_joined_with_and(self): """Multiple FilterDefinitions are joined with 'and' in $filter.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages( filters=[ @@ -730,7 +753,7 @@ def test_filter_multiple_clauses_joined_with_and(self): ], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) f = params["$filter"] assert "contains(metadata, 'john')" in f assert "contains(content, 'user prefers')" in f @@ -738,8 +761,8 @@ def test_filter_multiple_clauses_joined_with_and(self): def test_filter_combines_with_convenience_filters(self): """FilterDefinitions are combined with all convenience filter predicates.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages( agent_id="a", @@ -749,7 +772,7 @@ def test_filter_combines_with_convenience_filters(self): filters=[FilterDefinition(target="content", contains="hello")], ) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) f = params["$filter"] assert "agentID eq 'a'" in f assert "invokerID eq 'u'" in f @@ -759,12 +782,12 @@ def test_filter_combines_with_convenience_filters(self): def test_filter_none_does_not_change_behaviour(self): """filter=None produces the same $filter as before (no regression).""" - client, mock_transport = _make_client() - mock_transport.get.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.list_messages(agent_id="a", invoker_id="u", filters=None) - params = mock_transport.get.call_args[1]["params"] + params = _parse_call_params(mock_http.request.call_args) assert params["$filter"] == "agentID eq 'a' and invokerID eq 'u'" @@ -775,13 +798,13 @@ class TestRetentionConfig: def test_get_retention_config(self): """get_retention_config sends GET to the retentionConfig endpoint.""" - client, mock_transport = _make_client() - mock_transport.get.return_value = { + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={ "id": 1, "messageDays": 30, "memoryDays": 90, "usageLogDays": 180, "createTimestamp": "2025-01-01T00:00:00Z", "updateTimestamp": "2025-01-02T00:00:00Z", - } + }) rc = client.get_retention_config() @@ -790,29 +813,30 @@ def test_get_retention_config(self): assert rc.message_days == 30 assert rc.memory_days == 90 assert rc.usage_log_days == 180 - call_path = mock_transport.get.call_args[0][0] - assert call_path == RETENTION_CONFIG + assert mock_http.request.call_args[0][0] == HttpMethod.GET + assert mock_http.request.call_args[0][1] == RETENTION_CONFIG def test_update_retention_config(self): """update_retention_config sends PATCH with updated fields.""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.update_retention_config(message_days=60) - mock_transport.patch.assert_called_once() - call_path = mock_transport.patch.call_args[0][0] - assert call_path == RETENTION_CONFIG - payload = mock_transport.patch.call_args[1]["json"] + assert mock_http.request.call_args[0][0] == HttpMethod.PATCH + assert mock_http.request.call_args[0][1] == RETENTION_CONFIG + payload = mock_http.request.call_args[1]["json"] assert payload["messageDays"] == 60 assert "memoryDays" not in payload def test_update_retention_config_excludes_none_fields(self): """update_retention_config omits None-valued fields from PATCH body.""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.update_retention_config(memory_days=90, usage_log_days=180) - payload = mock_transport.patch.call_args[1]["json"] + payload = mock_http.request.call_args[1]["json"] assert "messageDays" not in payload assert payload["memoryDays"] == 90 assert payload["usageLogDays"] == 180 @@ -823,23 +847,24 @@ def test_update_retention_config_excludes_none_fields(self): class TestContextManager: - def test_close_delegates_to_transport(self): - """close() delegates to the transport's close method.""" - client, mock_transport = _make_client() + def test_close_delegates_to_http(self): + """close() delegates to the HttpClient's close method.""" + client, mock_http = _make_client() client.close() - mock_transport.close.assert_called_once() + mock_http.close.assert_called_once() def test_context_manager_closes_on_exit(self): """Using the client as a context manager closes it on __exit__.""" - transport = MagicMock(spec=HttpTransport) - client = AgentMemoryClient(transport, access_strategy=AccessStrategy.PROVIDER) + http = Mock(spec=HttpClient) + http.request.return_value = _make_response(200) + client = AgentMemoryClient(http, access_strategy=AccessStrategy.PROVIDER) with client: pass - transport.close.assert_called_once() + http.close.assert_called_once() # ── Validation ──────────────────────────────────────────────────────────────── @@ -848,55 +873,46 @@ def test_context_manager_closes_on_exit(self): class TestMemoryValidation: def test_add_memory_raises_for_empty_agent_id(self): - """add_memory raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): client.add_memory("", "user-1", "content") def test_add_memory_raises_for_empty_invoker_id(self): - """add_memory raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): client.add_memory("agent-1", "", "content") def test_add_memory_raises_for_empty_content(self): - """add_memory raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): client.add_memory("agent-1", "user-1", "") def test_get_memory_raises_for_empty_id(self): - """get_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): client.get_memory("") def test_update_memory_raises_for_empty_id(self): - """update_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): client.update_memory("", content="new content") def test_update_memory_raises_when_no_fields_provided(self): - """update_memory raises AgentMemoryValidationError when neither content nor metadata is provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): client.update_memory("uuid-123") def test_delete_memory_raises_for_empty_id(self): - """delete_memory raises AgentMemoryValidationError when memory_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_id"): client.delete_memory("") def test_list_memories_raises_for_zero_limit(self): - """list_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): client.list_memories(limit=0) def test_list_memories_raises_for_negative_offset(self): - """list_memories raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): client.list_memories(offset=-1) @@ -905,110 +921,94 @@ def test_list_memories_raises_for_negative_offset(self): class TestSearchMemoriesValidation: def test_raises_for_empty_agent_id(self): - """search_memories raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): client.search_memories("", "user-1", "what do I know about Python?") def test_raises_for_empty_invoker_id(self): - """search_memories raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): client.search_memories("agent-1", "", "what do I know about Python?") def test_raises_for_query_too_short(self): - """search_memories raises AgentMemoryValidationError when query has fewer than 5 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): client.search_memories("agent-1", "user-1", "hi") def test_raises_for_query_too_long(self): - """search_memories raises AgentMemoryValidationError when query exceeds 5000 chars.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="query"): client.search_memories("agent-1", "user-1", "x" * 5001) def test_raises_for_threshold_below_zero(self): - """search_memories raises AgentMemoryValidationError when threshold < 0.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): client.search_memories("a", "u", "valid query here", threshold=-0.1) def test_raises_for_threshold_above_one(self): - """search_memories raises AgentMemoryValidationError when threshold > 1.0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="threshold"): client.search_memories("a", "u", "valid query here", threshold=1.1) def test_raises_for_limit_zero(self): - """search_memories raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): client.search_memories("a", "u", "valid query here", limit=0) def test_raises_for_limit_above_fifty(self): - """search_memories raises AgentMemoryValidationError when limit exceeds 50.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): client.search_memories("a", "u", "valid query here", limit=51) def test_boundary_values_are_accepted(self): """search_memories accepts boundary values: 5-char query, threshold 0.0/1.0, limit 1/50.""" - client, mock_transport = _make_client() - mock_transport.post.return_value = {"value": []} + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(200, json_data={"value": []}) client.search_memories("a", "u", "hello", threshold=0.0, limit=1) client.search_memories("a", "u", "x" * 5000, threshold=1.0, limit=50) - assert mock_transport.post.call_count == 2 + assert mock_http.request.call_count == 2 class TestMessageValidation: def test_add_message_raises_for_empty_agent_id(self): - """add_message raises AgentMemoryValidationError when agent_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="agent_id"): client.add_message("", "u", "grp", MessageRole.USER, "hi") def test_add_message_raises_for_empty_invoker_id(self): - """add_message raises AgentMemoryValidationError when invoker_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="invoker_id"): client.add_message("a", "", "grp", MessageRole.USER, "hi") def test_add_message_raises_for_empty_message_group(self): - """add_message raises AgentMemoryValidationError when message_group is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_group"): client.add_message("a", "u", "", MessageRole.USER, "hi") def test_add_message_raises_for_empty_content(self): - """add_message raises AgentMemoryValidationError when content is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="content"): client.add_message("a", "u", "grp", MessageRole.USER, "") def test_get_message_raises_for_empty_id(self): - """get_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): client.get_message("") def test_delete_message_raises_for_empty_id(self): - """delete_message raises AgentMemoryValidationError when message_id is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_id"): client.delete_message("") def test_list_messages_raises_for_zero_limit(self): - """list_messages raises AgentMemoryValidationError when limit is 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="limit"): client.list_messages(limit=0) def test_list_messages_raises_for_negative_offset(self): - """list_messages raises AgentMemoryValidationError when offset is negative.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="offset"): client.list_messages(offset=-1) @@ -1017,36 +1017,33 @@ def test_list_messages_raises_for_negative_offset(self): class TestRetentionConfigValidation: def test_update_raises_when_no_fields_provided(self): - """update_retention_config raises AgentMemoryValidationError when no fields are provided.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="At least one"): client.update_retention_config() def test_update_raises_for_negative_message_days(self): - """update_retention_config raises AgentMemoryValidationError when message_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="message_days"): client.update_retention_config(message_days=-1) def test_update_raises_for_negative_memory_days(self): - """update_retention_config raises AgentMemoryValidationError when memory_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="memory_days"): client.update_retention_config(memory_days=-1) def test_update_raises_for_negative_usage_log_days(self): - """update_retention_config raises AgentMemoryValidationError when usage_log_days < 0.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="usage_log_days"): client.update_retention_config(usage_log_days=-1) def test_update_accepts_zero_values(self): """update_retention_config accepts 0 as a valid value (disables cleanup).""" - client, mock_transport = _make_client() + client, mock_http = _make_client() + mock_http.request.return_value = _make_response(204, content=b"") client.update_retention_config(memory_days=0) - mock_transport.patch.assert_called_once() + mock_http.request.assert_called_once() # ── FilterDefinition validation ─────────────────────────────────────────────────── @@ -1055,7 +1052,6 @@ def test_update_accepts_zero_values(self): class TestFilterDefinitionValidation: def test_list_memories_raises_for_unsupported_target(self): - """list_memories raises AgentMemoryValidationError for an unknown target.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="target"): client.list_memories( @@ -1063,7 +1059,6 @@ def test_list_memories_raises_for_unsupported_target(self): ) def test_list_memories_raises_for_empty_contains(self): - """list_memories raises AgentMemoryValidationError when contains is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_memories( @@ -1071,7 +1066,6 @@ def test_list_memories_raises_for_empty_contains(self): ) def test_list_messages_raises_for_unsupported_target(self): - """list_messages raises AgentMemoryValidationError for an unknown target.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="target"): client.list_messages( @@ -1079,7 +1073,6 @@ def test_list_messages_raises_for_unsupported_target(self): ) def test_list_messages_raises_for_empty_contains(self): - """list_messages raises AgentMemoryValidationError when contains is empty.""" client, _ = _make_client() with pytest.raises(AgentMemoryValidationError, match="contains"): client.list_messages( diff --git a/tests/agent_memory/unit/test_http_transport.py b/tests/agent_memory/unit/test_http_transport.py deleted file mode 100644 index 74008c44..00000000 --- a/tests/agent_memory/unit/test_http_transport.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Unit tests for HttpTransport.""" - -from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch - -import pytest - -from sap_cloud_sdk.agent_memory._http_transport import ( - HttpTransport, - _TOKEN_EXPIRY_BUFFER_SECONDS, -) -from sap_cloud_sdk.agent_memory.config import AgentMemoryConfig -from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryHttpError, AgentMemoryNotFoundError - - -def _config( - with_auth: bool = True, - identityzone: str | None = None, - token_url: str = "http://auth.example.com/oauth/token", -) -> AgentMemoryConfig: - if with_auth: - return AgentMemoryConfig( - base_url="http://localhost:8080", - token_url=token_url, - client_id="client-id", - client_secret="client-secret", - identityzone=identityzone, - ) - return AgentMemoryConfig(base_url="http://localhost:8080") - - -def _mock_response( - status_code: int, - json_data: dict | None = None, - text: str = "", -) -> MagicMock: - response = MagicMock() - response.status_code = status_code - response.ok = 200 <= status_code < 300 - response.content = b"content" if json_data is not None else b"" - response.text = text - response.json.return_value = json_data or {} - return response - - -# ── No-auth local dev mode ──────────────────────────────────────────────────── - - -class TestNoAuthMode: - - def test_sends_request_without_authorization_header(self): - """No-auth mode does not send an Authorization header.""" - transport = HttpTransport(_config(with_auth=False)) - mock_session = MagicMock() - transport._plain_session = mock_session - mock_session.request.return_value = _mock_response(200, {"data": []}) - - transport.get("/test") - - _, kwargs = mock_session.request.call_args - assert "Authorization" not in kwargs.get("headers", {}) - - def test_uses_plain_session_when_no_token_url(self): - """No-auth mode uses a plain requests.Session, not OAuth2Session.""" - with patch("sap_cloud_sdk.agent_memory._http_transport.requests") as mock_requests: - mock_session = MagicMock() - mock_requests.Session.return_value = mock_session - mock_session.request.return_value = _mock_response(200, {}) - - transport = HttpTransport(_config(with_auth=False)) - transport.get("/test") - - mock_requests.Session.assert_called_once() - - -# ── Token acquisition ───────────────────────────────────────────────────────── - - -class TestTokenAcquisition: - - def test_token_is_fetched_and_cached(self): - """fetch_token is called only once across multiple requests with the same tenant.""" - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.return_value = { - "access_token": "my-token", - "expires_in": 3600, - } - mock_oauth.request.return_value = _mock_response(200, {"data": []}) - - transport = HttpTransport(_config()) - transport.get("/test") - transport.get("/test") - - assert mock_oauth.fetch_token.call_count == 1 - - def test_expired_token_triggers_refetch(self): - """Expired token causes a new fetch_token call.""" - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.return_value = { - "access_token": "token", - "expires_in": 3600, - } - mock_oauth.request.return_value = _mock_response(200, {}) - - transport = HttpTransport(_config()) - # Force the cache to have an expired entry - past = datetime.now() - timedelta(seconds=1) - transport._oauth_cache[None] = (mock_oauth, past) - transport.get("/test") - - assert mock_oauth.fetch_token.call_count >= 1 - - def test_token_expiry_uses_buffer(self): - """Token expiry is set with _TOKEN_EXPIRY_BUFFER_SECONDS subtracted.""" - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.return_value = { - "access_token": "tok", - "expires_in": 3600, - } - mock_oauth.request.return_value = _mock_response(200, {}) - - transport = HttpTransport(_config()) - transport.get("/test") - - _, expires_at = transport._oauth_cache[None] - expected_max = datetime.now() + timedelta( - seconds=3600 - _TOKEN_EXPIRY_BUFFER_SECONDS + 5 - ) - assert expires_at < expected_max - - def test_token_fetch_failure_raises_http_error(self): - """Failed token fetch raises AgentMemoryHttpError.""" - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.side_effect = Exception("connection refused") - - transport = HttpTransport(_config()) - with pytest.raises(AgentMemoryHttpError, match="OAuth2 token"): - transport.get("/test") - - -# ── Per-tenant token derivation ─────────────────────────────────────────────── - - -class TestTenantTokenDerivation: - - def test_subscriber_token_url_replaces_identityzone(self): - """When tenant_subdomain is provided, identityzone is replaced in the token URL.""" - token_url = "http://provider-zone.auth.example.com/oauth/token" - cfg = _config(with_auth=True, identityzone="provider-zone", token_url=token_url) - - captured_urls = [] - - def fake_fetch_token(**kwargs): - captured_urls.append(kwargs["token_url"]) - return {"access_token": "tok", "expires_in": 3600} - - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.side_effect = fake_fetch_token - mock_oauth.request.return_value = _mock_response(200, {}) - - transport = HttpTransport(cfg) - transport.get("/test", tenant_subdomain="subscriber-zone") - - assert len(captured_urls) == 1 - assert "subscriber-zone" in captured_urls[0] - assert "provider-zone" not in captured_urls[0] - - def test_provider_token_url_unchanged_when_no_tenant(self): - """Without tenant_subdomain, the provider token URL is used as-is.""" - token_url = "http://provider-zone.auth.example.com/oauth/token" - cfg = _config(with_auth=True, identityzone="provider-zone", token_url=token_url) - - captured_urls = [] - - def fake_fetch_token(**kwargs): - captured_urls.append(kwargs["token_url"]) - return {"access_token": "tok", "expires_in": 3600} - - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.side_effect = fake_fetch_token - mock_oauth.request.return_value = _mock_response(200, {}) - - transport = HttpTransport(cfg) - transport.get("/test") # no tenant_subdomain → None - - assert captured_urls[0] == token_url - - def test_tokens_cached_independently_per_tenant(self): - """Provider and subscriber tokens are cached under separate keys.""" - token_url = "http://prov.auth.example.com/oauth/token" - cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) - - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} - mock_oauth.request.return_value = _mock_response(200, {}) - - transport = HttpTransport(cfg) - transport.get("/test") # provider (None) - transport.get("/test", tenant_subdomain="sub") # subscriber - - assert None in transport._oauth_cache - assert "sub" in transport._oauth_cache - assert mock_oauth.fetch_token.call_count == 2 - - def test_subscriber_token_reused_on_second_call(self): - """Subscriber token is cached and not re-fetched on a second call.""" - token_url = "http://prov.auth.example.com/oauth/token" - cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) - - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} - mock_oauth.request.return_value = _mock_response(200, {}) - - transport = HttpTransport(cfg) - transport.get("/test", tenant_subdomain="sub") - transport.get("/test", tenant_subdomain="sub") - - assert mock_oauth.fetch_token.call_count == 1 - - -# ── HTTP methods ────────────────────────────────────────────────────────────── - - -class TestHttpMethods: - - def _transport_no_auth(self) -> tuple[HttpTransport, MagicMock]: - transport = HttpTransport(_config(with_auth=False)) - mock_session = MagicMock() - transport._plain_session = mock_session - return transport, mock_session - - def test_get_sends_get_request(self): - """GET request is constructed with the correct method and URL.""" - transport, mock_session = self._transport_no_auth() - mock_session.request.return_value = _mock_response(200, {"key": "value"}) - - result = transport.get("/memories", params={"$top": "10"}) - - mock_session.request.assert_called_once() - call_args = mock_session.request.call_args - assert call_args[0][0] == "GET" - assert call_args[0][1].startswith("http://localhost:8080/memories") - assert "%24top=10" in call_args[0][1] - assert result == {"key": "value"} - - def test_post_sends_post_request(self): - """POST request is constructed with the correct method.""" - transport, mock_session = self._transport_no_auth() - mock_session.request.return_value = _mock_response(201, {"id": "new-memory"}) - - result = transport.post("/memories", json={"agentID": "a"}) - - assert mock_session.request.call_args[0][0] == "POST" - assert result == {"id": "new-memory"} - - def test_patch_sends_patch_request(self): - """PATCH request is constructed with the correct method.""" - transport, mock_session = self._transport_no_auth() - mock_session.request.return_value = _mock_response(200, {"id": "mem-1"}) - - result = transport.patch("/memories(mem-1)", json={"content": "updated"}) - - assert mock_session.request.call_args[0][0] == "PATCH" - assert result == {"id": "mem-1"} - - def test_delete_sends_delete_and_returns_none(self): - """DELETE sends correct method and returns None.""" - transport, mock_session = self._transport_no_auth() - mock_session.request.return_value = _mock_response(204) - - result = transport.delete("/memories/abc") - - assert mock_session.request.call_args[0][0] == "DELETE" - assert result is None - - def test_404_raises_not_found_error(self): - """404 responses raise AgentMemoryNotFoundError.""" - transport, mock_session = self._transport_no_auth() - mock_resp = _mock_response(404, text="Not Found") - mock_resp.content = b"Not Found" - mock_session.request.return_value = mock_resp - - with pytest.raises(AgentMemoryNotFoundError) as exc_info: - transport.get("/memories/nonexistent") - - assert exc_info.value.status_code == 404 - - def test_server_error_raises_http_error(self): - """500 responses raise AgentMemoryHttpError with the status code.""" - transport, mock_session = self._transport_no_auth() - mock_resp = _mock_response(500, text="Internal Server Error") - mock_resp.content = b"Internal Server Error" - mock_session.request.return_value = mock_resp - - with pytest.raises(AgentMemoryHttpError) as exc_info: - transport.get("/memories") - - assert exc_info.value.status_code == 500 - - -# ── Close ───────────────────────────────────────────────────────────────────── - - -class TestClose: - - def test_close_clears_all_oauth_sessions(self): - """close() closes all cached OAuth sessions.""" - with patch( - "sap_cloud_sdk.agent_memory._http_transport.OAuth2Session" - ) as MockOAuth, patch( - "sap_cloud_sdk.agent_memory._http_transport.BackendApplicationClient" - ): - mock_oauth = MagicMock() - MockOAuth.return_value = mock_oauth - mock_oauth.fetch_token.return_value = {"access_token": "tok", "expires_in": 3600} - mock_oauth.request.return_value = _mock_response(200, {}) - - token_url = "http://prov.auth.example.com/oauth/token" - cfg = _config(with_auth=True, identityzone="prov", token_url=token_url) - transport = HttpTransport(cfg) - transport.get("/test") # provider - transport.get("/test", tenant_subdomain="sub") # subscriber - transport.close() - - assert mock_oauth.close.call_count == 2 - assert len(transport._oauth_cache) == 0 - - def test_close_clears_plain_session(self): - """close() clears the plain session in no-auth mode.""" - transport = HttpTransport(_config(with_auth=False)) - mock_session = MagicMock() - transport._plain_session = mock_session - - transport.close() - - mock_session.close.assert_called_once() - assert transport._plain_session is None diff --git a/tests/destination/integration/conftest.py b/tests/destination/integration/conftest.py index 6eb9e90a..32aa4718 100644 --- a/tests/destination/integration/conftest.py +++ b/tests/destination/integration/conftest.py @@ -20,6 +20,12 @@ from sap_cloud_sdk.destination.config import DestinationConfig +@pytest.fixture(scope="session", autouse=True) +def cleanup_mocks_folder(): + """Remove the mocks folder before integration tests run to prevent local dev mode.""" + _ensure_mocks_folder_not_exists() + + @pytest.fixture(scope="session") def destination_client(): """Create a Destination client for cloud testing using secret resolver.""" @@ -168,6 +174,13 @@ def _setup_cloud_mode(): load_dotenv(env_file) +def _ensure_mocks_folder_not_exists(): + """Ensure that the mocks folder does not exist before running tests.""" + mocks_dir = Path(os.getcwd()) / "mocks" + if mocks_dir.exists(): + import shutil + shutil.rmtree(mocks_dir) + # Configure pytest markers for integration tests def pytest_configure(config): """Configure pytest markers.""" diff --git a/tests/destination/unit/test_certificate_client.py b/tests/destination/unit/test_certificate_client.py index b9ef0ea3..f4aa61a9 100644 --- a/tests/destination/unit/test_certificate_client.py +++ b/tests/destination/unit/test_certificate_client.py @@ -1,10 +1,11 @@ """Unit tests for CertificateClient.""" import pytest -from unittest.mock import Mock +from unittest.mock import Mock, call from requests import Response from sap_cloud_sdk.destination.certificate_client import CertificateClient +from sap_cloud_sdk.core._http_client import HttpMethod from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Label, Level, ListOptions, PatchLabels from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import ( @@ -13,1121 +14,565 @@ ) +def _make_response(status=200, json_data=None, text="", headers=None): + resp = Mock(spec=Response) + resp.status_code = status + resp.text = text + resp.headers = headers or {} + if json_data is not None: + resp.json.return_value = json_data + return resp + + @pytest.fixture def mock_http(): - """Create a mock DestinationHttp instance.""" - return Mock() + http = Mock() + http.request.return_value = _make_response(200) + return http @pytest.fixture def certificate_client(mock_http): - """Create a CertificateClient with mocked HTTP.""" return CertificateClient(http=mock_http) class TestCertificateClientInit: - """Tests for CertificateClient initialization.""" def test_init_with_http(self, mock_http): - """Test CertificateClient initialization with HTTP transport.""" client = CertificateClient(http=mock_http) assert client._http is mock_http class TestCertificateClientRead: - """Tests for CertificateClient read operations.""" def test_get_instance_certificate_success(self, certificate_client, mock_http): - """Test successful retrieval of instance certificate.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = { + mock_http.request.return_value = _make_response(200, { "Name": "test-cert.pem", "Content": "base64-encoded-content", - "Type": "PEM" - } - mock_http.get.return_value = mock_response - - # Execute + "Type": "PEM", + }) certificate = certificate_client.get_instance_certificate("test-cert.pem") - - # Verify assert certificate is not None assert certificate.name == "test-cert.pem" assert certificate.content == "base64-encoded-content" assert certificate.type == "PEM" - mock_http.get.assert_called_once_with("v1/instanceCertificates/test-cert.pem", tenant_subdomain=None) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.GET + assert args[1] == "/v1/instanceCertificates/test-cert.pem" + assert kwargs["tenant_subdomain"] is None def test_get_subaccount_certificate_success(self, certificate_client, mock_http): - """Test successful retrieval of subaccount certificate.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = { + mock_http.request.return_value = _make_response(200, { "Name": "test-cert.pem", "Content": "base64-encoded-content", - "Type": "PEM" - } - mock_http.get.return_value = mock_response - - # Execute + "Type": "PEM", + }) certificate = certificate_client.get_subaccount_certificate("test-cert.pem", access_strategy=AccessStrategy.PROVIDER_ONLY) - - # Verify assert certificate is not None assert certificate.name == "test-cert.pem" - assert certificate.content == "base64-encoded-content" - mock_http.get.assert_called_once_with("v1/subaccountCertificates/test-cert.pem", tenant_subdomain=None) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountCertificates/test-cert.pem" + assert kwargs["tenant_subdomain"] is None def test_get_certificate_not_found(self, certificate_client, mock_http): - """Test certificate retrieval when certificate doesn't exist (404).""" - # Setup mock to raise 404 - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = http_error - - # Execute + mock_http.request.return_value = _make_response(404, text="Not Found") certificate = certificate_client.get_instance_certificate("nonexistent.pem") - - # Verify assert certificate is None def test_get_certificate_http_error(self, certificate_client, mock_http): - """Test certificate retrieval with HTTP error (non-404).""" - # Setup mock to raise 500 - http_error = HttpError("Internal Server Error") - http_error.status_code = 500 - mock_http.get.side_effect = http_error - - # Execute & Verify + mock_http.request.return_value = _make_response(500, text="Internal Server Error") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.get_instance_certificate("test-cert.pem") - assert "failed to get certificate 'test-cert.pem'" in str(exc_info.value) def test_get_certificate_invalid_json(self, certificate_client, mock_http): - """Test certificate retrieval with invalid JSON response.""" - # Setup mock response with invalid JSON - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_http.get.return_value = mock_response - - # Execute & Verify + resp = _make_response(200) + resp.json.side_effect = ValueError("Invalid JSON") + mock_http.request.return_value = resp with pytest.raises(DestinationOperationError) as exc_info: certificate_client.get_instance_certificate("test-cert.pem") - assert "invalid JSON in get certificate response" in str(exc_info.value) def test_get_subaccount_certificate_access_strategies(self, certificate_client, mock_http): - """Test subaccount certificate retrieval with different access strategies.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = { + mock_http.request.return_value = _make_response(200, { "Name": "test-cert.pem", "Content": "base64-encoded-content", - "Type": "PEM" - } - mock_http.get.return_value = mock_response - - # Test PROVIDER_ONLY + "Type": "PEM", + }) certificate = certificate_client.get_subaccount_certificate("test-cert.pem", access_strategy=AccessStrategy.PROVIDER_ONLY) assert certificate is not None - mock_http.get.assert_called_with("v1/subaccountCertificates/test-cert.pem", tenant_subdomain=None) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None - # Reset mock mock_http.reset_mock() - - # Test SUBSCRIBER_ONLY with tenant + mock_http.request.return_value = _make_response(200, { + "Name": "test-cert.pem", + "Content": "base64-encoded-content", + "Type": "PEM", + }) certificate = certificate_client.get_subaccount_certificate("test-cert.pem", access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="test-tenant") assert certificate is not None - mock_http.get.assert_called_with("v1/subaccountCertificates/test-cert.pem", tenant_subdomain="test-tenant") + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_get_subaccount_certificate_requires_tenant_for_subscriber_access(self, certificate_client, mock_http): - """Test that subscriber access strategies require tenant parameter.""" - # Test SUBSCRIBER_ONLY without tenant with pytest.raises(DestinationOperationError) as exc_info: certificate_client.get_subaccount_certificate("test-cert.pem", access_strategy=AccessStrategy.SUBSCRIBER_ONLY) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) - # Test SUBSCRIBER_FIRST without tenant with pytest.raises(DestinationOperationError) as exc_info: certificate_client.get_subaccount_certificate("test-cert.pem", access_strategy=AccessStrategy.SUBSCRIBER_FIRST) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) - # Test PROVIDER_FIRST without tenant with pytest.raises(DestinationOperationError) as exc_info: certificate_client.get_subaccount_certificate("test-cert.pem", access_strategy=AccessStrategy.PROVIDER_FIRST) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) def test_get_subaccount_certificate_fallback_strategies(self, certificate_client, mock_http): - """Test fallback behavior for SUBSCRIBER_FIRST and PROVIDER_FIRST strategies.""" - # Setup mock to return None for first call, certificate for second call - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = { - "Name": "test-cert.pem", - "Content": "base64-encoded-content", - "Type": "PEM" - } - - # Test SUBSCRIBER_FIRST fallback (subscriber fails, provider succeeds) - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = [ - http_error, # Subscriber call fails - mock_response # Provider call succeeds + mock_http.request.side_effect = [ + _make_response(404, text="Not Found"), + _make_response(200, {"Name": "test-cert.pem", "Content": "base64-encoded-content", "Type": "PEM"}), ] - certificate = certificate_client.get_subaccount_certificate( "test-cert.pem", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" + tenant="test-tenant", ) assert certificate is not None - assert mock_http.get.call_count == 2 - - # Verify calls were made in correct order - calls = mock_http.get.call_args_list - assert calls[0] == (("v1/subaccountCertificates/test-cert.pem",), {"tenant_subdomain": "test-tenant"}) - assert calls[1] == (("v1/subaccountCertificates/test-cert.pem",), {"tenant_subdomain": None}) + assert mock_http.request.call_count == 2 + calls = mock_http.request.call_args_list + assert calls[0][1]["tenant_subdomain"] == "test-tenant" + assert calls[1][1]["tenant_subdomain"] is None class TestCertificateClientWrite: - """Tests for CertificateClient write operations.""" def test_create_certificate_subaccount(self, certificate_client, mock_http): - """Test creating a certificate at subaccount level.""" - # Setup - certificate = Certificate( - name="new-cert.pem", - content="base64-encoded-content", - type="PEM" - ) - - # Execute + certificate = Certificate(name="new-cert.pem", content="base64-encoded-content", type="PEM") certificate_client.create_certificate(certificate, level=Level.SUB_ACCOUNT) - - # Verify - mock_http.post.assert_called_once() - call_args = mock_http.post.call_args - assert call_args[0][0] == "v1/subaccountCertificates" - assert call_args[1]["body"]["Name"] == "new-cert.pem" - assert call_args[1]["body"]["Content"] == "base64-encoded-content" - assert call_args[1]["body"]["Type"] == "PEM" + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.POST + assert args[1] == "/v1/subaccountCertificates" + assert kwargs["json"]["Name"] == "new-cert.pem" + assert kwargs["json"]["Content"] == "base64-encoded-content" + assert kwargs["json"]["Type"] == "PEM" def test_create_certificate_instance(self, certificate_client, mock_http): - """Test creating a certificate at instance level.""" - # Setup - certificate = Certificate( - name="new-cert.jks", - content="base64-encoded-jks-content", - type="JKS" - ) - - # Execute + certificate = Certificate(name="new-cert.jks", content="base64-encoded-jks-content", type="JKS") certificate_client.create_certificate(certificate, level=Level.SERVICE_INSTANCE) - - # Verify - mock_http.post.assert_called_once() - call_args = mock_http.post.call_args - assert call_args[0][0] == "v1/instanceCertificates" + args, _ = mock_http.request.call_args + assert args[1] == "/v1/instanceCertificates" def test_create_certificate_with_tenant(self, certificate_client, mock_http): - """Test creating a certificate with a subscriber tenant.""" certificate = Certificate(name="new-cert.pem", content="base64-encoded-content", type="PEM") - certificate_client.create_certificate(certificate, level=Level.SUB_ACCOUNT, tenant="test-tenant") - - call_args = mock_http.post.call_args - assert call_args[1]["tenant_subdomain"] == "test-tenant" + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_create_certificate_without_tenant_uses_provider_context(self, certificate_client, mock_http): - """Test creating a certificate without tenant uses provider context (tenant_subdomain=None).""" certificate = Certificate(name="new-cert.pem", content="base64-encoded-content") - certificate_client.create_certificate(certificate) - - call_args = mock_http.post.call_args - assert call_args[1]["tenant_subdomain"] is None + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_create_certificate_http_error(self, certificate_client, mock_http): - """Test create certificate with HTTP error.""" - # Setup + mock_http.request.return_value = _make_response(409, text="Conflict") certificate = Certificate(name="test-cert.pem", content="content") - mock_http.post.side_effect = HttpError("Conflict") - - # Execute & Verify with pytest.raises(HttpError): certificate_client.create_certificate(certificate) def test_update_certificate_success(self, certificate_client, mock_http): - """Test updating a certificate.""" - # Setup - certificate = Certificate( - name="existing-cert.pem", - content="updated-base64-content", - type="PEM" - ) - - # Execute + certificate = Certificate(name="existing-cert.pem", content="updated-base64-content", type="PEM") certificate_client.update_certificate(certificate, level=Level.SUB_ACCOUNT) - - # Verify - mock_http.put.assert_called_once() - call_args = mock_http.put.call_args - assert call_args[0][0] == "v1/subaccountCertificates" - assert call_args[1]["body"]["Name"] == "existing-cert.pem" + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PUT + assert args[1] == "/v1/subaccountCertificates" + assert kwargs["json"]["Name"] == "existing-cert.pem" def test_update_certificate_with_tenant(self, certificate_client, mock_http): - """Test updating a certificate with a subscriber tenant.""" certificate = Certificate(name="existing-cert.pem", content="updated-content", type="PEM") - certificate_client.update_certificate(certificate, level=Level.SUB_ACCOUNT, tenant="test-tenant") - - call_args = mock_http.put.call_args - assert call_args[1]["tenant_subdomain"] == "test-tenant" + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_update_certificate_without_tenant_uses_provider_context(self, certificate_client, mock_http): - """Test updating a certificate without tenant uses provider context (tenant_subdomain=None).""" certificate = Certificate(name="existing-cert.pem", content="content") - certificate_client.update_certificate(certificate) - - call_args = mock_http.put.call_args - assert call_args[1]["tenant_subdomain"] is None + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_update_certificate_http_error(self, certificate_client, mock_http): - """Test update certificate with HTTP error.""" - # Setup + mock_http.request.return_value = _make_response(404, text="Not Found") certificate = Certificate(name="test-cert.pem", content="content") - mock_http.put.side_effect = HttpError("Not Found") - - # Execute & Verify with pytest.raises(HttpError): certificate_client.update_certificate(certificate) def test_delete_certificate_with_tenant(self, certificate_client, mock_http): - """Test deleting a certificate with a subscriber tenant.""" certificate_client.delete_certificate("test-cert.pem", level=Level.SUB_ACCOUNT, tenant="test-tenant") - - mock_http.delete.assert_called_once_with( - "v1/subaccountCertificates/test-cert.pem", tenant_subdomain="test-tenant" - ) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.DELETE + assert args[1] == "/v1/subaccountCertificates/test-cert.pem" + assert kwargs["tenant_subdomain"] == "test-tenant" def test_delete_certificate_without_tenant_uses_provider_context(self, certificate_client, mock_http): - """Test deleting a certificate without tenant uses provider context (tenant_subdomain=None).""" certificate_client.delete_certificate("test-cert.pem") - - mock_http.delete.assert_called_once_with( - "v1/subaccountCertificates/test-cert.pem", tenant_subdomain=None - ) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_delete_certificate_success(self, certificate_client, mock_http): - """Test deleting a certificate.""" certificate_client.delete_certificate("test-cert.pem", level=Level.SUB_ACCOUNT) - - mock_http.delete.assert_called_once_with( - "v1/subaccountCertificates/test-cert.pem", tenant_subdomain=None - ) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/subaccountCertificates/test-cert.pem" def test_delete_certificate_instance_level(self, certificate_client, mock_http): - """Test deleting a certificate at instance level.""" - # Execute certificate_client.delete_certificate("test-cert.pem", level=Level.SERVICE_INSTANCE) - - # Verify - mock_http.delete.assert_called_once_with( - "v1/instanceCertificates/test-cert.pem", tenant_subdomain=None - ) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/instanceCertificates/test-cert.pem" def test_delete_certificate_http_error(self, certificate_client, mock_http): - """Test delete certificate with HTTP error.""" - # Setup - mock_http.delete.side_effect = HttpError("Not Found") - - # Execute & Verify + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(HttpError): certificate_client.delete_certificate("test-cert.pem") class TestCertificateClientHelpers: - """Tests for CertificateClient helper methods.""" def test_sub_path_for_level_instance(self): - """Test sub-path generation for instance level.""" path = CertificateClient._sub_path_for_level(Level.SERVICE_INSTANCE) assert path == "instanceCertificates" def test_sub_path_for_level_subaccount(self): - """Test sub-path generation for subaccount level.""" path = CertificateClient._sub_path_for_level(Level.SUB_ACCOUNT) assert path == "subaccountCertificates" class TestCertificateClientListOperations: - """Tests for CertificateClient list operations.""" def test_list_instance_certificates_success(self, certificate_client, mock_http): - """Test successful listing of instance certificates.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [ + mock_http.request.return_value = _make_response(200, [ {"Name": "cert1.pem", "Content": "content1", "Type": "PEM"}, - {"Name": "cert2.jks", "Content": "content2", "Type": "JKS"} - ] - mock_http.get.return_value = mock_response - - # Execute + {"Name": "cert2.jks", "Content": "content2", "Type": "JKS"}, + ]) certificates = certificate_client.list_instance_certificates() - - # Verify assert len(certificates.items) == 2 assert certificates.items[0].name == "cert1.pem" assert certificates.items[1].name == "cert2.jks" - mock_http.get.assert_called_once_with("v1/instanceCertificates", tenant_subdomain=None, params={}) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/instanceCertificates" + assert kwargs["tenant_subdomain"] is None def test_list_instance_certificates_empty(self, certificate_client, mock_http): - """Test listing instance certificates when none exist.""" - # Setup mock response with empty array - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [] - mock_http.get.return_value = mock_response - - # Execute + mock_http.request.return_value = _make_response(200, []) certificates = certificate_client.list_instance_certificates() - - # Verify assert certificates == PagedResult(items=[]) - mock_http.get.assert_called_once() def test_list_instance_certificates_with_filter(self, certificate_client, mock_http): - """Test listing instance certificates with filter.""" - - # Setup - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]) filter_obj = ListOptions(filter_names=["cert1.pem", "cert2.pem"]) - - # Execute certificates = certificate_client.list_instance_certificates(filter=filter_obj) - - # Verify assert len(certificates.items) == 1 - call_args = mock_http.get.call_args - assert "params" in call_args[1] - assert "$filter" in call_args[1]["params"] + _, kwargs = mock_http.request.call_args + assert "$filter" in kwargs["params"] def test_list_instance_certificates_http_error_wrapped(self, certificate_client, mock_http): - """Test that HTTP errors (non-404) are wrapped in DestinationOperationError.""" - # Setup - http_error = HttpError("Internal Server Error") - http_error.status_code = 500 - mock_http.get.side_effect = http_error - - # Execute & Verify + mock_http.request.return_value = _make_response(500, text="Internal Server Error") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_instance_certificates() - assert "failed to list instance certificates" in str(exc_info.value) def test_list_instance_certificates_invalid_json_wrapped(self, certificate_client, mock_http): - """Test that invalid JSON responses are wrapped properly.""" - # Setup - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_http.get.return_value = mock_response - - # Execute & Verify + resp = _make_response(200) + resp.json.side_effect = ValueError("Invalid JSON") + mock_http.request.return_value = resp with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_instance_certificates() - assert "invalid JSON in list certificates response" in str(exc_info.value) def test_list_instance_certificates_with_tenant(self, certificate_client, mock_http): - """Test listing instance certificates with a tenant subdomain.""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [ - {"Name": "cert1.pem", "Content": "content1", "Type": "PEM"} - ] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"Name": "cert1.pem", "Content": "content1", "Type": "PEM"}]) certificates = certificate_client.list_instance_certificates(tenant="my-tenant") - assert len(certificates.items) == 1 - assert certificates.items[0].name == "cert1.pem" - mock_http.get.assert_called_once_with( - "v1/instanceCertificates", tenant_subdomain="my-tenant", params={} - ) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "my-tenant" def test_list_subaccount_certificates_requires_tenant_for_subscriber_access(self, certificate_client, mock_http): - """Test that subscriber access strategies require tenant parameter.""" - # Test SUBSCRIBER_ONLY with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.SUBSCRIBER_ONLY) assert "tenant subdomain must be provided" in str(exc_info.value) - # Test SUBSCRIBER_FIRST with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.SUBSCRIBER_FIRST) assert "tenant subdomain must be provided" in str(exc_info.value) def test_list_subaccount_certificates_provider_only_no_tenant_required(self, certificate_client, mock_http): - """Test PROVIDER_ONLY doesn't require tenant.""" - # Setup - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - # Execute (no tenant needed) + mock_http.request.return_value = _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]) certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.PROVIDER_ONLY) - - # Verify assert len(certificates.items) == 1 - mock_http.get.assert_called_once_with("v1/subaccountCertificates", tenant_subdomain=None, params={}) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_list_subaccount_certificates_subscriber_only_with_tenant(self, certificate_client, mock_http): - """Test SUBSCRIBER_ONLY with tenant.""" - # Setup - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - # Execute - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, - tenant="test-tenant" - ) - - # Verify + mock_http.request.return_value = _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]) + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="test-tenant") assert len(certificates.items) == 1 - mock_http.get.assert_called_once_with("v1/subaccountCertificates", tenant_subdomain="test-tenant", params={}) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_list_subaccount_certificates_subscriber_first_no_fallback(self, certificate_client, mock_http): - """Test SUBSCRIBER_FIRST when subscriber returns certificates (no fallback needed).""" - # Setup - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - # Execute - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - - # Verify - should only call subscriber, not provider + mock_http.request.return_value = _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]) + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert len(certificates.items) == 1 - assert mock_http.get.call_count == 1 - mock_http.get.assert_called_with("v1/subaccountCertificates", tenant_subdomain="test-tenant", params={}) + assert mock_http.request.call_count == 1 + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_list_subaccount_certificates_subscriber_first_fallback_to_provider(self, certificate_client, mock_http): - """Test SUBSCRIBER_FIRST falls back to provider when subscriber returns empty list.""" - # Setup - subscriber returns empty, provider returns certificates - empty_response = Mock(spec=Response) - empty_response.headers = {} - empty_response.json.return_value = [] - provider_response = Mock(spec=Response) - provider_response.headers = {} - provider_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - - mock_http.get.side_effect = [empty_response, provider_response] - - # Execute - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - - # Verify - should call both subscriber then provider + mock_http.request.side_effect = [ + _make_response(200, []), + _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]), + ] + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert len(certificates.items) == 1 - assert mock_http.get.call_count == 2 - calls = mock_http.get.call_args_list - assert calls[0] == (("v1/subaccountCertificates",), {"tenant_subdomain": "test-tenant", "params": {}}) - assert calls[1] == (("v1/subaccountCertificates",), {"tenant_subdomain": None, "params": {}}) + assert mock_http.request.call_count == 2 + calls = mock_http.request.call_args_list + assert calls[0][1]["tenant_subdomain"] == "test-tenant" + assert calls[1][1]["tenant_subdomain"] is None def test_list_subaccount_certificates_provider_first_no_fallback(self, certificate_client, mock_http): - """Test PROVIDER_FIRST when provider returns certificates (no fallback needed).""" - # Setup - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - # Execute - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - - # Verify - should only call provider, not subscriber + mock_http.request.return_value = _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]) + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert len(certificates.items) == 1 - assert mock_http.get.call_count == 1 - mock_http.get.assert_called_with("v1/subaccountCertificates", tenant_subdomain=None, params={}) + assert mock_http.request.call_count == 1 + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_list_subaccount_certificates_provider_first_fallback_to_subscriber(self, certificate_client, mock_http): - """Test PROVIDER_FIRST falls back to subscriber when provider returns empty list.""" - # Setup - provider returns empty, subscriber returns certificates - empty_response = Mock(spec=Response) - empty_response.headers = {} - empty_response.json.return_value = [] - subscriber_response = Mock(spec=Response) - subscriber_response.headers = {} - subscriber_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - - mock_http.get.side_effect = [empty_response, subscriber_response] - - # Execute - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - - # Verify - should call both provider then subscriber + mock_http.request.side_effect = [ + _make_response(200, []), + _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]), + ] + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert len(certificates.items) == 1 - assert mock_http.get.call_count == 2 - calls = mock_http.get.call_args_list - assert calls[0] == (("v1/subaccountCertificates",), {"tenant_subdomain": None, "params": {}}) - assert calls[1] == (("v1/subaccountCertificates",), {"tenant_subdomain": "test-tenant", "params": {}}) + assert mock_http.request.call_count == 2 + calls = mock_http.request.call_args_list + assert calls[0][1]["tenant_subdomain"] is None + assert calls[1][1]["tenant_subdomain"] == "test-tenant" def test_list_subaccount_certificates_with_filter(self, certificate_client, mock_http): - """Test listing subaccount certificates with filter.""" - # Setup - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"Name": "cert1.pem", "Content": "content1"}]) filter_obj = ListOptions(filter_names=["cert1.pem"]) - - # Execute - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.PROVIDER_ONLY, - filter=filter_obj - ) - - # Verify + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.PROVIDER_ONLY, filter=filter_obj) assert len(certificates.items) == 1 - call_args = mock_http.get.call_args - assert "params" in call_args[1] - assert "$filter" in call_args[1]["params"] + _, kwargs = mock_http.request.call_args + assert "$filter" in kwargs["params"] def test_list_subaccount_certificates_http_error_wrapped(self, certificate_client, mock_http): - """Test that HTTP errors are wrapped in DestinationOperationError.""" - # Setup - http_error = HttpError("Internal Server Error") - http_error.status_code = 500 - mock_http.get.side_effect = http_error - - # Execute & Verify + mock_http.request.return_value = _make_response(500, text="Internal Server Error") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.PROVIDER_ONLY) - assert "failed to list subaccount certificates" in str(exc_info.value) -class TestCertificateClientAccessStrategy: - """Tests for CertificateClient access strategy helper.""" - - def test_apply_access_strategy_subscriber_only(self, certificate_client, mock_http): - """Test _apply_access_strategy with SUBSCRIBER_ONLY.""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - certificates = certificate_client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, - tenant="test-tenant", - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) - ) - - assert len(certificates.items) == 1 - mock_http.get.assert_called_once_with("v1/subaccountCertificates", tenant_subdomain="test-tenant", params={}) - - def test_apply_access_strategy_provider_only(self, certificate_client, mock_http): - """Test _apply_access_strategy with PROVIDER_ONLY.""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - certificates = certificate_client._apply_access_strategy( - access_strategy=AccessStrategy.PROVIDER_ONLY, - tenant=None, - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) - ) - - assert len(certificates.items) == 1 - mock_http.get.assert_called_once_with("v1/subaccountCertificates", tenant_subdomain=None, params={}) - - def test_apply_access_strategy_subscriber_first_no_fallback(self, certificate_client, mock_http): - """Test SUBSCRIBER_FIRST when subscriber has certificates (no fallback).""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - certificates = certificate_client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant", - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) - ) - - assert len(certificates.items) == 1 - assert mock_http.get.call_count == 1 - - def test_apply_access_strategy_subscriber_first_with_fallback(self, certificate_client, mock_http): - """Test SUBSCRIBER_FIRST falls back to provider when subscriber is empty.""" - empty_response = Mock(spec=Response) - empty_response.headers = {} - empty_response.json.return_value = [] - provider_response = Mock(spec=Response) - provider_response.headers = {} - provider_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - - mock_http.get.side_effect = [empty_response, provider_response] - - certificates = certificate_client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant", - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) - ) - - assert len(certificates.items) == 1 - assert mock_http.get.call_count == 2 - - def test_apply_access_strategy_provider_first_no_fallback(self, certificate_client, mock_http): - """Test PROVIDER_FIRST when provider has certificates (no fallback).""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - mock_http.get.return_value = mock_response - - certificates = certificate_client._apply_access_strategy( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant", - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) - ) - - assert len(certificates.items) == 1 - assert mock_http.get.call_count == 1 - - def test_apply_access_strategy_provider_first_with_fallback(self, certificate_client, mock_http): - """Test PROVIDER_FIRST falls back to subscriber when provider is empty.""" - empty_response = Mock(spec=Response) - empty_response.headers = {} - empty_response.json.return_value = [] - subscriber_response = Mock(spec=Response) - subscriber_response.headers = {} - subscriber_response.json.return_value = [{"Name": "cert1.pem", "Content": "content1"}] - - mock_http.get.side_effect = [empty_response, subscriber_response] - - certificates = certificate_client._apply_access_strategy( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant", - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) - ) - - assert len(certificates.items) == 1 - assert mock_http.get.call_count == 2 - - def test_apply_access_strategy_with_list_empty_value(self, certificate_client, mock_http): - """Test that empty lists from fallback scenarios work correctly.""" - empty_response = Mock(spec=Response) - empty_response.headers = {} - empty_response.json.return_value = [] - - mock_http.get.return_value = empty_response - - certificates = certificate_client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant", - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) - ) - - # Both subscriber and provider return empty, final result is empty - assert certificates == PagedResult(items=[]) - assert mock_http.get.call_count == 2 - - class TestCertificateClientEdgeCases: - """Tests for edge cases and error handling.""" - - def test_get_subaccount_certificate_unknown_access_strategy(self, certificate_client, mock_http): - """Test that unknown access strategy raises appropriate error.""" - # Create an invalid access strategy by mocking - from unittest.mock import patch - - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = {"Name": "cert1.pem", "Content": "content1"} - mock_http.get.return_value = mock_response - - # Patch AccessStrategy to add an unknown value - with patch('sap_cloud_sdk.destination.certificate_client.AccessStrategy') as mock_strategy: - unknown_strategy = Mock() - unknown_strategy.value = "UNKNOWN_STRATEGY" - - with pytest.raises(DestinationOperationError) as exc_info: - # Directly call with a value that won't match any case - certificate_client.get_subaccount_certificate( - "test-cert", - access_strategy=unknown_strategy, - tenant="test-tenant" - ) - - assert "unknown access strategy" in str(exc_info.value).lower() def test_create_certificate_unexpected_exception(self, certificate_client, mock_http): - """Test create certificate with unexpected exception (not HttpError).""" certificate = Certificate(name="test-cert.pem", content="content") - mock_http.post.side_effect = RuntimeError("Unexpected error") - + mock_http.request.side_effect = RuntimeError("Unexpected error") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.create_certificate(certificate) - assert "failed to create certificate 'test-cert.pem'" in str(exc_info.value) assert "Unexpected error" in str(exc_info.value) def test_update_certificate_unexpected_exception(self, certificate_client, mock_http): - """Test update certificate with unexpected exception (not HttpError).""" certificate = Certificate(name="test-cert.pem", content="content") - mock_http.put.side_effect = ValueError("Unexpected error") - + mock_http.request.side_effect = ValueError("Unexpected error") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.update_certificate(certificate) - assert "failed to update certificate 'test-cert.pem'" in str(exc_info.value) def test_delete_certificate_unexpected_exception(self, certificate_client, mock_http): - """Test delete certificate with unexpected exception (not HttpError).""" - mock_http.delete.side_effect = ConnectionError("Network error") - + mock_http.request.side_effect = ConnectionError("Network error") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.delete_certificate("test-cert.pem") - assert "failed to delete certificate 'test-cert.pem'" in str(exc_info.value) def test_list_certificates_non_list_response(self, certificate_client, mock_http): - """Test list certificates when response is not a list.""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - mock_response.json.return_value = {"error": "not a list"} - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, {"error": "not a list"}) with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_instance_certificates() - assert "expected JSON array in list certificates response" in str(exc_info.value) - def test_list_certificates_404_returns_none(self, certificate_client, mock_http): - """Test that 404 on list operations returns empty list.""" - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = http_error - + def test_list_certificates_404_returns_empty(self, certificate_client, mock_http): + mock_http.request.return_value = _make_response(404, text="Not Found") certificates = certificate_client.list_instance_certificates() - assert certificates.items == [] def test_list_subaccount_certificates_both_empty_subscriber_first(self, certificate_client, mock_http): - """Test SUBSCRIBER_FIRST when both subscriber and provider return empty.""" - empty_response = Mock(spec=Response) - empty_response.headers = {} - empty_response.json.return_value = [] - mock_http.get.return_value = empty_response - - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(200, []) + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert certificates == PagedResult(items=[]) - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_list_subaccount_certificates_both_empty_provider_first(self, certificate_client, mock_http): - """Test PROVIDER_FIRST when both provider and subscriber return empty.""" - empty_response = Mock(spec=Response) - empty_response.headers = {} - empty_response.json.return_value = [] - mock_http.get.return_value = empty_response - - certificates = certificate_client.list_subaccount_certificates( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(200, []) + certificates = certificate_client.list_subaccount_certificates(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert certificates == PagedResult(items=[]) - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_get_certificate_malformed_certificate_data(self, certificate_client, mock_http): - """Test get certificate with malformed Certificate data in response.""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - # Missing required fields for Certificate.from_dict - mock_response.json.return_value = {"Name": "", "Content": ""} - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, {"Name": "", "Content": ""}) with pytest.raises(DestinationOperationError) as exc_info: certificate_client.get_instance_certificate("test-cert") - assert "invalid JSON in get certificate response" in str(exc_info.value) def test_list_certificates_invalid_certificate_in_array(self, certificate_client, mock_http): - """Test list certificates with invalid certificate object in array.""" - mock_response = Mock(spec=Response) - mock_response.headers = {} - # One valid, one invalid certificate - mock_response.json.return_value = [ + mock_http.request.return_value = _make_response(200, [ {"Name": "cert1.pem", "Content": "content1"}, - {"Name": "", "Content": ""} # Invalid - will cause from_dict to fail - ] - mock_http.get.return_value = mock_response - + {"Name": "", "Content": ""}, + ]) with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_instance_certificates() - - # The error message includes the specific validation error from Certificate.from_dict assert "certificate is missing required fields" in str(exc_info.value) def test_apply_access_strategy_unknown_strategy(self, certificate_client, mock_http): - """Test _apply_access_strategy with unknown strategy.""" - from unittest.mock import Mock as MockStrategy - - unknown_strategy = MockStrategy() + unknown_strategy = Mock() unknown_strategy.value = "UNKNOWN" - with pytest.raises(DestinationOperationError) as exc_info: certificate_client._apply_access_strategy( access_strategy=unknown_strategy, tenant="test-tenant", - fetch_func=lambda t: certificate_client._list_certificates( - level=Level.SUB_ACCOUNT, tenant_subdomain=t, filter=None - ) + fetch_func=lambda t: certificate_client._list_certificates(level=Level.SUB_ACCOUNT, tenant_subdomain=t), ) - assert "unknown access strategy" in str(exc_info.value).lower() def test_get_subaccount_certificate_provider_first_both_none(self, certificate_client, mock_http): - """Test PROVIDER_FIRST when both provider and subscriber return None.""" - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = http_error - - certificate = certificate_client.get_subaccount_certificate( - "test-cert", - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(404, text="Not Found") + certificate = certificate_client.get_subaccount_certificate("test-cert", access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert certificate is None - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_get_subaccount_certificate_subscriber_first_both_none(self, certificate_client, mock_http): - """Test SUBSCRIBER_FIRST when both subscriber and provider return None.""" - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = http_error - - certificate = certificate_client.get_subaccount_certificate( - "test-cert", - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(404, text="Not Found") + certificate = certificate_client.get_subaccount_certificate("test-cert", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert certificate is None - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_list_certificates_with_http_403_error(self, certificate_client, mock_http): - """Test list certificates with 403 Forbidden error.""" - http_error = HttpError("Forbidden") - http_error.status_code = 403 - mock_http.get.side_effect = http_error - + mock_http.request.return_value = _make_response(403, text="Forbidden") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.list_instance_certificates() - assert "failed to list instance certificates" in str(exc_info.value) def test_get_certificate_with_http_401_error(self, certificate_client, mock_http): - """Test get certificate with 401 Unauthorized error.""" - http_error = HttpError("Unauthorized") - http_error.status_code = 401 - mock_http.get.side_effect = http_error - + mock_http.request.return_value = _make_response(401, text="Unauthorized") with pytest.raises(DestinationOperationError) as exc_info: certificate_client.get_instance_certificate("test-cert") - assert "failed to get certificate 'test-cert'" in str(exc_info.value) class TestCertificateClientLabels: - """Tests for CertificateClient label operations.""" def test_get_certificate_labels_instance(self, certificate_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = [{"key": "env", "values": ["prod"]}] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"key": "env", "values": ["prod"]}]) labels = certificate_client.get_certificate_labels("cert1", Level.SERVICE_INSTANCE) - assert len(labels) == 1 assert labels[0].key == "env" - mock_http.get.assert_called_once_with("v1/instanceCertificates/cert1/labels", tenant_subdomain=None) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/instanceCertificates/cert1/labels" + assert kwargs["tenant_subdomain"] is None def test_get_certificate_labels_subaccount(self, certificate_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = [{"key": "team", "values": ["platform"]}] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"key": "team", "values": ["platform"]}]) labels = certificate_client.get_certificate_labels("cert1", Level.SUB_ACCOUNT) - assert labels[0].key == "team" - mock_http.get.assert_called_once_with("v1/subaccountCertificates/cert1/labels", tenant_subdomain=None) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/subaccountCertificates/cert1/labels" def test_get_certificate_labels_default_level_is_subaccount(self, certificate_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = [] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, []) certificate_client.get_certificate_labels("cert1") - - mock_http.get.assert_called_once_with("v1/subaccountCertificates/cert1/labels", tenant_subdomain=None) + args, _ = mock_http.request.call_args + assert "subaccountCertificates" in args[1] def test_get_certificate_labels_non_list_response_raises(self, certificate_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = {"key": "env"} - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, {"key": "env"}) with pytest.raises(DestinationOperationError): certificate_client.get_certificate_labels("cert1") def test_get_certificate_labels_http_error_raises_operation_error(self, certificate_client, mock_http): - mock_http.get.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(DestinationOperationError, match="failed to get labels for certificate"): certificate_client.get_certificate_labels("cert1") def test_update_certificate_labels_instance(self, certificate_client, mock_http): labels = [Label(key="env", values=["prod"])] - certificate_client.update_certificate_labels("cert1", labels, Level.SERVICE_INSTANCE) - - mock_http.put.assert_called_once_with( - "v1/instanceCertificates/cert1/labels", - body=[{"key": "env", "values": ["prod"]}], - tenant_subdomain=None, - ) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PUT + assert args[1] == "/v1/instanceCertificates/cert1/labels" + assert kwargs["json"] == [{"key": "env", "values": ["prod"]}] + assert kwargs["tenant_subdomain"] is None def test_update_certificate_labels_subaccount(self, certificate_client, mock_http): labels = [Label(key="env", values=["staging"])] - certificate_client.update_certificate_labels("cert1", labels, Level.SUB_ACCOUNT) - - mock_http.put.assert_called_once_with( - "v1/subaccountCertificates/cert1/labels", - body=[{"key": "env", "values": ["staging"]}], - tenant_subdomain=None, - ) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountCertificates/cert1/labels" + assert kwargs["json"] == [{"key": "env", "values": ["staging"]}] def test_update_certificate_labels_http_error_propagates(self, certificate_client, mock_http): - mock_http.put.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(HttpError): certificate_client.update_certificate_labels("cert1", [], Level.SUB_ACCOUNT) def test_patch_certificate_labels_instance(self, certificate_client, mock_http): patch = PatchLabels(action="ADD", labels=[Label(key="env", values=["prod"])]) - certificate_client.patch_certificate_labels("cert1", patch, Level.SERVICE_INSTANCE) - - mock_http.patch.assert_called_once_with( - "v1/instanceCertificates/cert1/labels", - body={"action": "ADD", "labels": [{"key": "env", "values": ["prod"]}]}, - tenant_subdomain=None, - ) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PATCH + assert args[1] == "/v1/instanceCertificates/cert1/labels" + assert kwargs["json"]["action"] == "ADD" + assert kwargs["tenant_subdomain"] is None def test_patch_certificate_labels_subaccount(self, certificate_client, mock_http): patch = PatchLabels(action="DELETE", labels=[Label(key="env", values=[])]) - certificate_client.patch_certificate_labels("cert1", patch, Level.SUB_ACCOUNT) - - mock_http.patch.assert_called_once_with( - "v1/subaccountCertificates/cert1/labels", - body={"action": "DELETE", "labels": [{"key": "env", "values": []}]}, - tenant_subdomain=None, - ) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/subaccountCertificates/cert1/labels" def test_patch_certificate_labels_http_error_propagates(self, certificate_client, mock_http): - mock_http.patch.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(HttpError): certificate_client.patch_certificate_labels("cert1", PatchLabels(action="ADD", labels=[]), Level.SUB_ACCOUNT) def test_get_certificate_labels_with_tenant(self, certificate_client, mock_http): - mock_http.get.return_value.json.return_value = [] - + mock_http.request.return_value = _make_response(200, []) certificate_client.get_certificate_labels("cert1", tenant="test-tenant") - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" def test_get_certificate_labels_without_tenant_uses_provider_context(self, certificate_client, mock_http): - mock_http.get.return_value.json.return_value = [] - + mock_http.request.return_value = _make_response(200, []) certificate_client.get_certificate_labels("cert1") - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None def test_update_certificate_labels_with_tenant(self, certificate_client, mock_http): certificate_client.update_certificate_labels("cert1", [], tenant="test-tenant") - - _, kwargs = mock_http.put.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" def test_update_certificate_labels_without_tenant_uses_provider_context(self, certificate_client, mock_http): certificate_client.update_certificate_labels("cert1", []) - - _, kwargs = mock_http.put.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None def test_patch_certificate_labels_with_tenant(self, certificate_client, mock_http): certificate_client.patch_certificate_labels("cert1", PatchLabels(action="ADD", labels=[]), tenant="test-tenant") - - _, kwargs = mock_http.patch.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" def test_patch_certificate_labels_without_tenant_uses_provider_context(self, certificate_client, mock_http): certificate_client.patch_certificate_labels("cert1", PatchLabels(action="ADD", labels=[])) - - _, kwargs = mock_http.patch.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None diff --git a/tests/destination/unit/test_client.py b/tests/destination/unit/test_client.py index 05acb4f2..78ed139a 100644 --- a/tests/destination/unit/test_client.py +++ b/tests/destination/unit/test_client.py @@ -1,10 +1,11 @@ """Unit tests for DestinationClient operations and behaviors.""" import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import Mock, patch from requests import Response from sap_cloud_sdk.destination.client import DestinationClient +from sap_cloud_sdk.core._http_client import HttpMethod from sap_cloud_sdk.destination._models import ( Destination, Label, @@ -25,793 +26,451 @@ ) -class TestDestinationClientReadOperations: +def _make_response(status=200, json_data=None, text="", headers=None): + resp = Mock(spec=Response) + resp.status_code = status + resp.text = text + resp.headers = headers or {} + if json_data is not None: + resp.json.return_value = json_data + return resp + + +def _make_simple_resp(mock_http): + """Configure mock_http with a minimal valid v2 get_destination response.""" + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, + "authTokens": [], + "certificates": [], + }) + - def test_get_instance_destination_success(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = {"name": "my-dest", "type": "HTTP"} - mock_http.get.return_value = resp +@pytest.fixture +def mock_http(): + http = Mock() + http.request.return_value = _make_response(200) + return http - client = DestinationClient(mock_http) - dest = client.get_instance_destination("my-dest") + +@pytest.fixture +def destination_client(mock_http): + return DestinationClient(mock_http) + + +class TestDestinationClientReadOperations: + + def test_get_instance_destination_success(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data={"name": "my-dest", "type": "HTTP"}) + dest = destination_client.get_instance_destination("my-dest") assert isinstance(dest, Destination) assert dest.name == "my-dest" assert dest.type == DestinationType.HTTP - - # Verify HTTP was called with instance path and no tenant - args, kwargs = mock_http.get.call_args - assert "instanceDestinations/my-dest" in args[0] + args, kwargs = mock_http.request.call_args + assert "instanceDestinations/my-dest" in args[1] assert kwargs.get("tenant_subdomain") is None - def test_get_instance_destination_not_found_returns_none(self): - mock_http = MagicMock() - mock_http.get.side_effect = HttpError("not found", status_code=404, response_text="Not Found") - - client = DestinationClient(mock_http) - result = client.get_instance_destination("unknown") + def test_get_instance_destination_not_found_returns_none(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(404, text="Not Found") + result = destination_client.get_instance_destination("unknown") assert result is None - def test_get_instance_destination_http_error_wrapped(self): - mock_http = MagicMock() - mock_http.get.side_effect = HttpError("boom", status_code=500, response_text="err") - - client = DestinationClient(mock_http) + def test_get_instance_destination_http_error_wrapped(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(500, text="err") with pytest.raises(DestinationOperationError, match="failed to get destination 'my-dest'"): - client.get_instance_destination("my-dest") + destination_client.get_instance_destination("my-dest") def test_get_subaccount_destination_requires_tenant_for_subscriber_access(self): - client = DestinationClient(MagicMock()) - + client = DestinationClient(Mock()) for strat in [AccessStrategy.SUBSCRIBER_ONLY, AccessStrategy.SUBSCRIBER_FIRST, AccessStrategy.PROVIDER_FIRST]: with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): client.get_subaccount_destination("my-dest", access_strategy=strat, tenant=None) def test_get_subaccount_destination_provider_only_no_tenant_required(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) dest = Destination(name="prov-dest", type="HTTP") - with patch.object(client, "_get_destination", return_value=dest) as mock_get: result = client.get_subaccount_destination("prov-dest", access_strategy=AccessStrategy.PROVIDER_ONLY, tenant=None) assert result is dest - # Called once with provider context (no tenant) mock_get.assert_called_once() called_kwargs = mock_get.call_args.kwargs assert called_kwargs.get("tenant_subdomain") is None def test_get_subaccount_destination_subscriber_first_fallback_to_provider(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) dest = Destination(name="my-dest", type="HTTP") - with patch.object(client, "_get_destination", side_effect=[None, dest]) as mock_get: result = client.get_subaccount_destination("my-dest", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-1") assert result is dest - # First subscriber, then provider fallback assert mock_get.call_count == 2 def test_get_subaccount_destination_provider_first_fallback_to_subscriber(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) dest = Destination(name="my-dest", type="HTTP") - with patch.object(client, "_get_destination", side_effect=[None, dest]) as mock_get: result = client.get_subaccount_destination("my-dest", access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="tenant-1") assert result is dest - # First provider, then subscriber fallback assert mock_get.call_count == 2 def test_get_subaccount_destination_http_error_wrapped(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) with patch.object(client, "_get_destination", side_effect=HttpError("bad", status_code=500)): with pytest.raises(DestinationOperationError, match="failed to get destination 'name'"): client.get_subaccount_destination("name", access_strategy=AccessStrategy.PROVIDER_ONLY) - def test_get_destination_success(self): + def test_get_destination_success(self, mock_http, destination_client): """Test successful destination consumption.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [ { "type": "Bearer", "value": "dG9rZW4xMjM=", - "http_header": { - "key": "Authorization", - "value": "Bearer token123" - } + "http_header": {"key": "Authorization", "value": "Bearer token123"}, } ], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api") - + "certificates": [], + }) + result = destination_client.get_destination("my-api") assert isinstance(result, Destination) assert result.name == "my-api" assert result.url == "https://api.example.com" assert len(result.auth_tokens) == 1 assert result.auth_tokens[0].type == "Bearer" assert result.auth_tokens[0].http_header["key"] == "Authorization" - - # Verify HTTP was called with v2 path - args, kwargs = mock_http.get.call_args - assert args[0] == "v2/destinations/my-api" + args, kwargs = mock_http.request.call_args + assert args[1] == "/v2/destinations/my-api" assert kwargs.get("tenant_subdomain") is None - assert kwargs.get("headers") == {} + assert kwargs.get("headers") == {"Accept": "application/json"} - def test_get_destination_with_fragment_name(self): + def test_get_destination_with_fragment_name(self, mock_http, destination_client): """Test consumption with fragment merging.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - options = ConsumptionOptions(fragment_name="production") - result = client.get_destination("my-api", options=options) - + _make_simple_resp(mock_http) + result = destination_client.get_destination("my-api", options=ConsumptionOptions(fragment_name="production")) assert result is not None - # Verify X-fragment-name header was sent - args, kwargs = mock_http.get.call_args + args, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-name"] == "production" - def test_get_destination_with_tenant_context(self): + def test_get_destination_with_tenant_context(self, mock_http, destination_client): """Test consumption with tenant context for user token exchange.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [ { "type": "Bearer", "value": "dXNlcnRva2Vu", - "http_header": { - "key": "Authorization", - "value": "Bearer usertoken" - }, - "scope": "read write" + "http_header": {"key": "Authorization", "value": "Bearer usertoken"}, + "scope": "read write", } ], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - options = ConsumptionOptions(tenant="tenant-1") - result = client.get_destination("my-api", options=options) - + "certificates": [], + }) + result = destination_client.get_destination("my-api", options=ConsumptionOptions(tenant="tenant-1")) assert isinstance(result, Destination) assert len(result.auth_tokens) == 1 assert result.auth_tokens[0].scope == "read write" - - # Verify X-tenant header was passed - args, kwargs = mock_http.get.call_args + args, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-tenant"] == "tenant-1" - def test_get_destination_with_fragment_and_tenant(self): + def test_get_destination_with_fragment_and_tenant(self, mock_http, destination_client): """Test consumption with both fragment and tenant.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - options = ConsumptionOptions(fragment_name="prod", tenant="tenant-1") - result = client.get_destination("my-api", options=options) - + _make_simple_resp(mock_http) + result = destination_client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", tenant="tenant-1")) assert result is not None - - # Verify both fragment and tenant headers were passed - args, kwargs = mock_http.get.call_args + args, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-name"] == "prod" assert kwargs["headers"]["X-tenant"] == "tenant-1" - def test_get_destination_not_found_returns_none(self): + def test_get_destination_not_found_returns_none(self, mock_http, destination_client): """Test consumption returns None when destination not found.""" - mock_http = MagicMock() - mock_http.get.side_effect = HttpError("not found", status_code=404, response_text="Not Found") - - client = DestinationClient(mock_http) - result = client.get_destination("unknown") + mock_http.request.return_value = _make_response(404, text="Not Found") + assert destination_client.get_destination("unknown") is None - assert result is None - - def test_get_destination_http_error_wrapped(self): + def test_get_destination_http_error_wrapped(self, mock_http, destination_client): """Test non-404 HTTP errors are wrapped.""" - mock_http = MagicMock() - mock_http.get.side_effect = HttpError("boom", status_code=500, response_text="Internal Error") - - client = DestinationClient(mock_http) + mock_http.request.return_value = _make_response(500, text="Internal Error") with pytest.raises(DestinationOperationError, match="failed to consume destination 'my-api'"): - client.get_destination("my-api") + destination_client.get_destination("my-api") - def test_get_destination_invalid_json_wrapped(self): + def test_get_destination_invalid_json_wrapped(self, mock_http, destination_client): """Test invalid JSON response is wrapped.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 + resp = _make_response(200) resp.json.side_effect = ValueError("Invalid JSON") - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + mock_http.request.return_value = resp with pytest.raises(DestinationOperationError, match="failed to parse consume destination response"): - client.get_destination("my-api") + destination_client.get_destination("my-api") - def test_get_destination_missing_destination_configuration(self): + def test_get_destination_missing_destination_configuration(self, mock_http, destination_client): """Test response missing destinationConfiguration field.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "authTokens": [], - "certificates": [] - # Missing destinationConfiguration - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + mock_http.request.return_value = _make_response(200, json_data={"authTokens": [], "certificates": []}) with pytest.raises(DestinationOperationError, match="failed to parse consume destination response"): - client.get_destination("my-api") + destination_client.get_destination("my-api") - def test_get_destination_with_multiple_auth_tokens(self): + def test_get_destination_with_multiple_auth_tokens(self, mock_http, destination_client): """Test consumption with multiple auth tokens.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [ - { - "type": "Bearer", - "value": "dG9rZW4x", - "http_header": {"key": "Authorization", "value": "Bearer token1"} - }, - { - "type": "ApiKey", - "value": "YXBpa2V5", - "http_header": {"key": "X-API-Key", "value": "apikey123"} - } + {"type": "Bearer", "value": "dG9rZW4x", "http_header": {"key": "Authorization", "value": "Bearer token1"}}, + {"type": "ApiKey", "value": "YXBpa2V5", "http_header": {"key": "X-API-Key", "value": "apikey123"}}, ], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api") - + "certificates": [], + }) + result = destination_client.get_destination("my-api") assert isinstance(result, Destination) assert len(result.auth_tokens) == 2 assert result.auth_tokens[0].type == "Bearer" assert result.auth_tokens[1].type == "ApiKey" - def test_get_destination_with_certificates(self): + def test_get_destination_with_certificates(self, mock_http, destination_client): """Test consumption returns certificates.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [], - "certificates": [ - { - "Name": "client-cert", - "Content": "Y2VydGNvbnRlbnQ=", - "Type": "PEM" - } - ] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api") - + "certificates": [{"Name": "client-cert", "Content": "Y2VydGNvbnRlbnQ=", "Type": "PEM"}], + }) + result = destination_client.get_destination("my-api") assert isinstance(result, Destination) assert len(result.certificates) == 1 assert result.certificates[0].name == "client-cert" assert result.certificates[0].type == "PEM" - def test_get_destination_with_refresh_token(self): + def test_get_destination_with_refresh_token(self, mock_http, destination_client): """Test auth token includes refresh token.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [ { "type": "Bearer", "value": "dG9rZW4=", "http_header": {"key": "Authorization", "value": "Bearer token"}, "refresh_token": "cmVmcmVzaA==", - "scope": "openid profile" + "scope": "openid profile", } ], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api") - + "certificates": [], + }) + result = destination_client.get_destination("my-api") assert isinstance(result, Destination) assert result.auth_tokens[0].refresh_token == "cmVmcmVzaA==" assert result.auth_tokens[0].scope == "openid profile" - def test_get_destination_with_level_instance(self): + def test_get_destination_with_level_instance(self, mock_http, destination_client): """Test get_destination with level=INSTANCE uses @instance in path.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api", level=ConsumptionLevel.INSTANCE) - + _make_simple_resp(mock_http) + result = destination_client.get_destination("my-api", level=ConsumptionLevel.INSTANCE) assert result is not None assert result.name == "my-api" + args, kwargs = mock_http.request.call_args + assert args[1] == "/v2/destinations/my-api@instance" - # Verify path includes @instance - args, kwargs = mock_http.get.call_args - assert args[0] == "v2/destinations/my-api@instance" - - def test_get_destination_with_level_subaccount(self): + def test_get_destination_with_level_subaccount(self, mock_http, destination_client): """Test get_destination with level=SUBACCOUNT uses @subaccount in path.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api", level=ConsumptionLevel.SUBACCOUNT) - + _make_simple_resp(mock_http) + result = destination_client.get_destination("my-api", level=ConsumptionLevel.SUBACCOUNT) assert result is not None assert result.name == "my-api" + args, kwargs = mock_http.request.call_args + assert args[1] == "/v2/destinations/my-api@subaccount" - # Verify path includes @subaccount - args, kwargs = mock_http.get.call_args - assert args[0] == "v2/destinations/my-api@subaccount" - - def test_get_destination_with_level_and_options(self): + def test_get_destination_with_level_and_options(self, mock_http, destination_client): """Test get_destination combining level parameter with ConsumptionOptions.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + _make_simple_resp(mock_http) options = ConsumptionOptions(fragment_name="production", tenant="tenant-1") - result = client.get_destination("my-api", level=ConsumptionLevel.SUBACCOUNT, options=options) - + result = destination_client.get_destination("my-api", level=ConsumptionLevel.SUBACCOUNT, options=options) assert result is not None - - # Verify both level in path and options in headers - args, kwargs = mock_http.get.call_args - assert args[0] == "v2/destinations/my-api@subaccount" + args, kwargs = mock_http.request.call_args + assert args[1] == "/v2/destinations/my-api@subaccount" assert kwargs["headers"]["X-fragment-name"] == "production" assert kwargs["headers"]["X-tenant"] == "tenant-1" - def test_get_destination_with_level_provider_subaccount(self): + def test_get_destination_with_level_provider_subaccount(self, mock_http, destination_client): """Test get_destination with level=PROVIDER_SUBACCOUNT uses @provider_subaccount in path.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api", level=ConsumptionLevel.PROVIDER_SUBACCOUNT) - + _make_simple_resp(mock_http) + result = destination_client.get_destination("my-api", level=ConsumptionLevel.PROVIDER_SUBACCOUNT) assert result is not None - args, kwargs = mock_http.get.call_args - assert args[0] == "v2/destinations/my-api@provider_subaccount" + args, kwargs = mock_http.request.call_args + assert args[1] == "/v2/destinations/my-api@provider_subaccount" - def test_get_destination_with_level_provider_instance(self): + def test_get_destination_with_level_provider_instance(self, mock_http, destination_client): """Test get_destination with level=PROVIDER_INSTANCE uses @provider_instance in path.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api", level=ConsumptionLevel.PROVIDER_INSTANCE) - + _make_simple_resp(mock_http) + result = destination_client.get_destination("my-api", level=ConsumptionLevel.PROVIDER_INSTANCE) assert result is not None - args, kwargs = mock_http.get.call_args - assert args[0] == "v2/destinations/my-api@provider_instance" + args, kwargs = mock_http.request.call_args + assert args[1] == "/v2/destinations/my-api@provider_instance" - def test_get_destination_with_fragment_level(self): + def test_get_destination_with_fragment_level(self, mock_http, destination_client): """Test that fragment_level appends @level to X-fragment-name header.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - options = ConsumptionOptions( - fragment_name="my-frag", - fragment_level=ConsumptionLevel.PROVIDER_SUBACCOUNT, - ) - result = client.get_destination("my-api", options=options) - - assert result is not None - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + options = ConsumptionOptions(fragment_name="my-frag", fragment_level=ConsumptionLevel.PROVIDER_SUBACCOUNT) + destination_client.get_destination("my-api", options=options) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-name"] == "my-frag@provider_subaccount" - def test_get_destination_with_fragment_name_and_level_combined(self): + def test_get_destination_with_fragment_name_and_level_combined(self, mock_http, destination_client): """Test fragment_name and fragment_level combine correctly into the header.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - options = ConsumptionOptions( - fragment_name="prod-frag", - fragment_level=ConsumptionLevel.INSTANCE, - ) - result = client.get_destination("my-api", options=options) - - assert result is not None - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + options = ConsumptionOptions(fragment_name="prod-frag", fragment_level=ConsumptionLevel.INSTANCE) + destination_client.get_destination("my-api", options=options) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-name"] == "prod-frag@instance" - def test_get_destination_fragment_level_without_fragment_name_has_no_effect(self): + def test_get_destination_fragment_level_without_fragment_name_has_no_effect(self, mock_http, destination_client): """Test that fragment_level alone (no fragment_name) does not add X-fragment-name header.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, - "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + _make_simple_resp(mock_http) options = ConsumptionOptions(fragment_level=ConsumptionLevel.PROVIDER_INSTANCE) - result = client.get_destination("my-api", options=options) - - assert result is not None - _, kwargs = mock_http.get.call_args + destination_client.get_destination("my-api", options=options) + _, kwargs = mock_http.request.call_args assert "X-fragment-name" not in kwargs["headers"] - def test_get_destination_empty_auth_tokens_and_certificates(self): + def test_get_destination_empty_auth_tokens_and_certificates(self, mock_http, destination_client): """Test consumption with no auth tokens or certificates.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { + mock_http.request.return_value = _make_response(200, json_data={ "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com", - "authentication": "NoAuthentication" + "name": "my-api", "type": "HTTP", "url": "https://api.example.com", "authentication": "NoAuthentication", }, "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination("my-api") - + "certificates": [], + }) + result = destination_client.get_destination("my-api") assert isinstance(result, Destination) assert len(result.auth_tokens) == 0 assert len(result.certificates) == 0 - def _make_simple_resp(self, mock_http): - """Helper: configure mock_http to return a minimal valid v2 response.""" - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, - "authTokens": [], - "certificates": [], - } - mock_http.get.return_value = resp - - def test_get_destination_with_fragment_optional_true(self): + def test_get_destination_with_fragment_optional_true(self, mock_http, destination_client): """X-fragment-optional: true is sent when fragment_optional=True.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", fragment_optional=True)) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", fragment_optional=True)) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-optional"] == "true" - def test_get_destination_with_fragment_optional_false(self): + def test_get_destination_with_fragment_optional_false(self, mock_http, destination_client): """X-fragment-optional: false is sent when fragment_optional=False.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", fragment_optional=False)) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", fragment_optional=False)) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-optional"] == "false" - def test_get_destination_fragment_optional_not_sent_when_none(self): + def test_get_destination_fragment_optional_not_sent_when_none(self, mock_http, destination_client): """X-fragment-optional header is omitted when fragment_optional is not set.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod")) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod")) + _, kwargs = mock_http.request.call_args assert "X-fragment-optional" not in kwargs["headers"] - def test_get_destination_with_user_token(self): + def test_get_destination_with_user_token(self, mock_http, destination_client): """X-user-token header is sent for OAuth2UserTokenExchange flows.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(user_token="my.jwt.token")) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(user_token="my.jwt.token")) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-user-token"] == "my.jwt.token" - def test_get_destination_with_subject_token_and_type(self): + def test_get_destination_with_subject_token_and_type(self, mock_http, destination_client): """X-subject-token and X-subject-token-type are sent for OAuth2TokenExchange.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination( + _make_simple_resp(mock_http) + destination_client.get_destination( "my-api", options=ConsumptionOptions( subject_token="subj-token", subject_token_type="urn:ietf:params:oauth:token-type:access_token", ), ) - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-subject-token"] == "subj-token" assert kwargs["headers"]["X-subject-token-type"] == "urn:ietf:params:oauth:token-type:access_token" - def test_get_destination_with_actor_token_and_type(self): + def test_get_destination_with_actor_token_and_type(self, mock_http, destination_client): """X-actor-token and X-actor-token-type are sent for OAuth2TokenExchange.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination( + _make_simple_resp(mock_http) + destination_client.get_destination( "my-api", options=ConsumptionOptions( actor_token="actor-token", actor_token_type="urn:ietf:params:oauth:token-type:access_token", ), ) - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-actor-token"] == "actor-token" assert kwargs["headers"]["X-actor-token-type"] == "urn:ietf:params:oauth:token-type:access_token" - def test_get_destination_with_saml_assertion(self): + def test_get_destination_with_saml_assertion(self, mock_http, destination_client): """X-samlAssertion is sent for OAuth2SAMLBearerAssertion with ClientProvided.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(saml_assertion="base64saml==")) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(saml_assertion="base64saml==")) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-samlAssertion"] == "base64saml==" - def test_get_destination_with_refresh_token(self): + def test_get_destination_with_refresh_token_option(self, mock_http, destination_client): """X-refresh-token is sent for OAuth2RefreshToken destinations.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(refresh_token="my-refresh-token")) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(refresh_token="my-refresh-token")) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-refresh-token"] == "my-refresh-token" - def test_get_destination_with_code(self): + def test_get_destination_with_code(self, mock_http, destination_client): """X-code is sent for OAuth2AuthorizationCode destinations.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(code="auth-code-123")) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(code="auth-code-123")) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-code"] == "auth-code-123" - def test_get_destination_with_redirect_uri(self): + def test_get_destination_with_redirect_uri(self, mock_http, destination_client): """X-redirect-uri is sent for OAuth2AuthorizationCode destinations.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination( + _make_simple_resp(mock_http) + destination_client.get_destination( "my-api", options=ConsumptionOptions(code="auth-code-123", redirect_uri="https://app/callback"), ) - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-redirect-uri"] == "https://app/callback" - def test_get_destination_with_code_verifier(self): + def test_get_destination_with_code_verifier(self, mock_http, destination_client): """X-code-verifier is sent for PKCE-enabled OAuth2AuthorizationCode destinations.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination( + _make_simple_resp(mock_http) + destination_client.get_destination( "my-api", options=ConsumptionOptions(code="auth-code-123", code_verifier="pkce-verifier-abc"), ) - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-code-verifier"] == "pkce-verifier-abc" - def test_get_destination_with_chain_name(self): + def test_get_destination_with_chain_name(self, mock_http, destination_client): """X-chain-name is sent when chain_name is provided.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination("my-api", options=ConsumptionOptions(chain_name="my-chain")) - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api", options=ConsumptionOptions(chain_name="my-chain")) + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-chain-name"] == "my-chain" - def test_get_destination_with_chain_vars(self): + def test_get_destination_with_chain_vars(self, mock_http, destination_client): """X-chain-var- headers are sent for each chain variable.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination( + _make_simple_resp(mock_http) + destination_client.get_destination( "my-api", options=ConsumptionOptions( chain_name="my-chain", chain_vars={"subject_token": "tok123", "subject_token_type": "access_token"}, ), ) - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-chain-name"] == "my-chain" assert kwargs["headers"]["X-chain-var-subject_token"] == "tok123" assert kwargs["headers"]["X-chain-var-subject_token_type"] == "access_token" - def test_get_destination_chain_vars_without_chain_name(self): + def test_get_destination_chain_vars_without_chain_name(self, mock_http, destination_client): """chain_vars without chain_name: headers are still forwarded (API enforces pairing).""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination( + _make_simple_resp(mock_http) + destination_client.get_destination( "my-api", options=ConsumptionOptions(chain_vars={"subject_token": "tok"}), ) - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-chain-var-subject_token"] == "tok" assert "X-chain-name" not in kwargs["headers"] - def test_get_destination_all_headers_combined(self): + def test_get_destination_all_headers_combined(self, mock_http, destination_client): """Multiple unrelated headers can be sent simultaneously.""" - mock_http = MagicMock() - self._make_simple_resp(mock_http) - - client = DestinationClient(mock_http) - client.get_destination( + _make_simple_resp(mock_http) + destination_client.get_destination( "my-api", options=ConsumptionOptions( fragment_name="prod", @@ -820,575 +479,329 @@ def test_get_destination_all_headers_combined(self): user_token="user.jwt", ), ) - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-name"] == "prod" assert kwargs["headers"]["X-fragment-optional"] == "true" assert kwargs["headers"]["X-tenant"] == "tenant-1" assert kwargs["headers"]["X-user-token"] == "user.jwt" - - - """Test suite for DestinationClient operations with transparent proxy enabled.""" + # --- Transparent proxy tests --- @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_instance_destination_with_proxy_enabled(self, mock_load_proxy): + def test_get_instance_destination_with_proxy_enabled(self, mock_load_proxy, mock_http): """Test get_instance_destination with proxy_enabled=True returns TransparentProxyDestination.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=True) - result = client.get_instance_destination("my-dest", proxy_enabled=True) - assert isinstance(result, TransparentProxyDestination) assert result.name == "my-dest" assert result.url == "http://test-proxy.test-ns" assert result.headers == {"X-destination-name": "my-dest"} - - # Verify HTTP was NOT called (bypassed by proxy) - mock_http.get.assert_not_called() + mock_http.request.assert_not_called() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_instance_destination_with_proxy_disabled(self, mock_load_proxy): + def test_get_instance_destination_with_proxy_disabled(self, mock_load_proxy, mock_http): """Test get_instance_destination with proxy_enabled=False uses normal HTTP flow.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = {"name": "my-dest", "type": "HTTP"} - mock_http.get.return_value = resp - + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") + mock_http.request.return_value = _make_response(200, json_data={"name": "my-dest", "type": "HTTP"}) client = DestinationClient(mock_http, use_default_proxy=True) result = client.get_instance_destination("my-dest", proxy_enabled=False) - assert isinstance(result, Destination) assert result.name == "my-dest" - - # Verify HTTP was called (normal flow) - mock_http.get.assert_called_once() + mock_http.request.assert_called_once() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_subaccount_destination_with_proxy_enabled(self, mock_load_proxy): + def test_get_subaccount_destination_with_proxy_enabled(self, mock_load_proxy, mock_http): """Test get_subaccount_destination with proxy_enabled=True returns TransparentProxyDestination.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=True) - result = client.get_subaccount_destination( - "my-dest", - access_strategy=AccessStrategy.PROVIDER_ONLY, - proxy_enabled=True + "my-dest", access_strategy=AccessStrategy.PROVIDER_ONLY, proxy_enabled=True ) - assert isinstance(result, TransparentProxyDestination) assert result.name == "my-dest" assert result.url == "http://test-proxy.test-ns" assert result.headers == {"X-destination-name": "my-dest"} - - # Verify HTTP was NOT called (bypassed by proxy) - mock_http.get.assert_not_called() + mock_http.request.assert_not_called() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_subaccount_destination_with_proxy_disabled(self, mock_load_proxy): + def test_get_subaccount_destination_with_proxy_disabled(self, mock_load_proxy, mock_http): """Test get_subaccount_destination with proxy_enabled=False uses normal HTTP flow.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=True) - dest = Destination(name="my-dest", type="HTTP") with patch.object(client, "_get_destination", return_value=dest) as mock_get: result = client.get_subaccount_destination( - "my-dest", - access_strategy=AccessStrategy.PROVIDER_ONLY, - proxy_enabled=False + "my-dest", access_strategy=AccessStrategy.PROVIDER_ONLY, proxy_enabled=False ) - assert isinstance(result, Destination) assert result.name == "my-dest" - - # Verify _get_destination was called (normal flow) mock_get.assert_called_once() - def test_get_subaccount_destination_proxy_enabled_no_proxy_configured_uses_normal_flow(self): - """Test get_subaccount_destination with proxy_enabled=True but no proxy uses normal flow.""" - mock_http = MagicMock() + def test_get_subaccount_destination_proxy_enabled_no_proxy_configured_uses_normal_flow(self, mock_http): client = DestinationClient(mock_http, use_default_proxy=False) - dest = Destination(name="my-dest", type="HTTP") with patch.object(client, "_get_destination", return_value=dest) as mock_get: result = client.get_subaccount_destination( - "my-dest", - access_strategy=AccessStrategy.PROVIDER_ONLY, - proxy_enabled=True + "my-dest", access_strategy=AccessStrategy.PROVIDER_ONLY, proxy_enabled=True ) - - # Should fall back to normal flow when proxy is not configured assert isinstance(result, Destination) mock_get.assert_called_once() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_subaccount_destination_proxy_with_subscriber_strategy(self, mock_load_proxy): + def test_get_subaccount_destination_proxy_with_subscriber_strategy(self, mock_load_proxy, mock_http): """Test get_subaccount_destination with proxy_enabled and SUBSCRIBER_FIRST strategy.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=True) - result = client.get_subaccount_destination( - "my-dest", - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant", - proxy_enabled=True + "my-dest", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant", proxy_enabled=True ) - assert isinstance(result, TransparentProxyDestination) assert result.name == "my-dest" - - # Even with tenant specified, proxy bypasses HTTP call - mock_http.get.assert_not_called() + mock_http.request.assert_not_called() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_client_initialization_loads_proxy(self, mock_load_proxy): + def test_client_initialization_loads_proxy(self, mock_load_proxy, mock_http): """Test that DestinationClient initialization calls load_transparent_proxy.""" proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") mock_load_proxy.return_value = proxy - - mock_http = MagicMock() client = DestinationClient(mock_http, use_default_proxy=True) - - # Verify load_transparent_proxy was called during initialization mock_load_proxy.assert_called_once() assert client._transparent_proxy == proxy - def test_client_initialization_no_proxy(self): + def test_client_initialization_no_proxy(self, mock_http): """Test that DestinationClient initialization handles no proxy configuration.""" - mock_http = MagicMock() client = DestinationClient(mock_http, use_default_proxy=False) - assert client._transparent_proxy is None @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_transparent_proxy_destination_url_format(self, mock_load_proxy): + def test_transparent_proxy_destination_url_format(self, mock_load_proxy, mock_http): """Test that TransparentProxyDestination generates correct URL format.""" - proxy = TransparentProxy(proxy_name="my-proxy", namespace="my-namespace") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="my-proxy", namespace="my-namespace") client = DestinationClient(mock_http, use_default_proxy=True) - result = client.get_instance_destination("test-destination", proxy_enabled=True) - assert isinstance(result, TransparentProxyDestination) assert result.url == "http://my-proxy.my-namespace" assert result.headers == {"X-destination-name": "test-destination"} @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_transparent_proxy_destination_headers_format(self, mock_load_proxy): + def test_transparent_proxy_destination_headers_format(self, mock_load_proxy, mock_http): """Test that TransparentProxyDestination generates correct headers.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=True) - destination_name = "complex-destination-name-123" result = client.get_instance_destination(destination_name, proxy_enabled=True) - assert isinstance(result, TransparentProxyDestination) assert result.headers["X-destination-name"] == destination_name @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_instance_destination_default_proxy_disabled(self, mock_load_proxy): + def test_get_instance_destination_default_proxy_disabled(self, mock_load_proxy, mock_http): """Test that proxy_enabled defaults to client's use_default_proxy for get_instance_destination.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = {"name": "my-dest", "type": "HTTP"} - mock_http.get.return_value = resp - + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") + mock_http.request.return_value = _make_response(200, json_data={"name": "my-dest", "type": "HTTP"}) client = DestinationClient(mock_http, use_default_proxy=False) - - # Call without proxy_enabled parameter (should use client's default: False) result = client.get_instance_destination("my-dest") - assert isinstance(result, Destination) - # HTTP should be called since proxy is disabled by default - mock_http.get.assert_called_once() + mock_http.request.assert_called_once() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_subaccount_destination_default_proxy_disabled(self, mock_load_proxy): + def test_get_subaccount_destination_default_proxy_disabled(self, mock_load_proxy, mock_http): """Test that proxy_enabled defaults to client's use_default_proxy for get_subaccount_destination.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=False) - dest = Destination(name="my-dest", type="HTTP") with patch.object(client, "_get_destination", return_value=dest) as mock_get: - # Call without proxy_enabled parameter (should use client's default: False) - result = client.get_subaccount_destination( - "my-dest", - access_strategy=AccessStrategy.PROVIDER_ONLY - ) - + result = client.get_subaccount_destination("my-dest", access_strategy=AccessStrategy.PROVIDER_ONLY) assert isinstance(result, Destination) - # _get_destination should be called since proxy is disabled by default mock_get.assert_called_once() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_destination_with_proxy_enabled(self, mock_load_proxy): + def test_get_destination_with_proxy_enabled(self, mock_load_proxy, mock_http): """Test get_destination (v2 API) with proxy_enabled=True returns TransparentProxyDestination.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=True) - result = client.get_destination("my-api", proxy_enabled=True) - assert isinstance(result, TransparentProxyDestination) assert result.name == "my-api" assert result.url == "http://test-proxy.test-ns" assert result.headers == {"X-destination-name": "my-api"} - - # Verify HTTP was NOT called (bypassed by proxy) - mock_http.get.assert_not_called() + mock_http.request.assert_not_called() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_destination_with_proxy_disabled(self, mock_load_proxy): + def test_get_destination_with_proxy_disabled(self, mock_load_proxy, mock_http): """Test get_destination (v2 API) with proxy_enabled=False uses normal HTTP flow.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - + "certificates": [], + }) client = DestinationClient(mock_http, use_default_proxy=True) result = client.get_destination("my-api", proxy_enabled=False) - assert isinstance(result, Destination) assert result.name == "my-api" assert result.url == "https://api.example.com" - - # Verify HTTP was called (normal flow) - mock_http.get.assert_called_once() + mock_http.request.assert_called_once() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_destination_with_options_and_proxy_disabled(self, mock_load_proxy): + def test_get_destination_with_options_and_proxy_disabled(self, mock_load_proxy, mock_http): """Test get_destination with ConsumptionOptions and proxy disabled.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - + "certificates": [], + }) client = DestinationClient(mock_http, use_default_proxy=False) - options = ConsumptionOptions(fragment_name="prod", tenant="tenant-1") - result = client.get_destination("my-api", options=options, proxy_enabled=False) - + result = client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod", tenant="tenant-1"), proxy_enabled=False) assert isinstance(result, Destination) - - # Verify options were passed correctly - args, kwargs = mock_http.get.call_args + args, kwargs = mock_http.request.call_args assert kwargs["headers"]["X-fragment-name"] == "prod" assert kwargs["headers"]["X-tenant"] == "tenant-1" @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_destination_default_proxy_enabled(self, mock_load_proxy): + def test_get_destination_default_proxy_enabled(self, mock_load_proxy, mock_http): """Test that proxy_enabled defaults to client's use_default_proxy for get_destination.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") client = DestinationClient(mock_http, use_default_proxy=True) - - # Call without proxy_enabled parameter (should use client's default: True) result = client.get_destination("my-api") - assert isinstance(result, TransparentProxyDestination) - # HTTP should NOT be called since proxy is enabled by default - mock_http.get.assert_not_called() + mock_http.request.assert_not_called() @patch("sap_cloud_sdk.destination.client.load_transparent_proxy") - def test_get_destination_default_proxy_disabled(self, mock_load_proxy): + def test_get_destination_default_proxy_disabled(self, mock_load_proxy, mock_http): """Test that get_destination uses normal flow when proxy is disabled by default.""" - proxy = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") - mock_load_proxy.return_value = proxy - - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com" - }, + mock_load_proxy.return_value = TransparentProxy(proxy_name="test-proxy", namespace="test-ns") + mock_http.request.return_value = _make_response(200, json_data={ + "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, "authTokens": [], - "certificates": [] - } - mock_http.get.return_value = resp - + "certificates": [], + }) client = DestinationClient(mock_http, use_default_proxy=False) - - # Call without proxy_enabled parameter (should use client's default: False) result = client.get_destination("my-api") - assert isinstance(result, Destination) - # HTTP should be called since proxy is disabled by default - mock_http.get.assert_called_once() + mock_http.request.assert_called_once() - def test_get_destination_skip_token_retrieval_sends_query_param(self): + def test_get_destination_skip_token_retrieval_sends_query_param(self, mock_http, destination_client): """Test that skip_token_retrieval=True sends $skipTokenRetrieval=true query param.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { + mock_http.request.return_value = _make_response(200, json_data={ "destinationConfiguration": { - "name": "my-api", - "type": "HTTP", - "url": "https://api.example.com", - "clientId": "my-client-id", + "name": "my-api", "type": "HTTP", "url": "https://api.example.com", "clientId": "my-client-id", }, "authTokens": [], "certificates": [], - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.get_destination( - "my-api", - options=ConsumptionOptions(skip_token_retrieval=True), - ) - + }) + result = destination_client.get_destination("my-api", options=ConsumptionOptions(skip_token_retrieval=True)) assert isinstance(result, Destination) assert result.properties.get("clientId") == "my-client-id" - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs.get("params") == {"$skipTokenRetrieval": "true"} - def test_get_destination_no_skip_token_retrieval_by_default(self): + def test_get_destination_no_skip_token_retrieval_by_default(self, mock_http, destination_client): """Test that skip_token_retrieval=False (default) sends no $skipTokenRetrieval param.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = { - "destinationConfiguration": {"name": "my-api", "type": "HTTP", "url": "https://api.example.com"}, - "authTokens": [], - "certificates": [], - } - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - client.get_destination("my-api") - - _, kwargs = mock_http.get.call_args + _make_simple_resp(mock_http) + destination_client.get_destination("my-api") + _, kwargs = mock_http.request.call_args assert kwargs.get("params") is None class TestDestinationClientWriteOperations: - def test_create_destination_success(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 201 - mock_http.post.return_value = resp - - client = DestinationClient(mock_http) + def test_create_destination_success(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(201) dest = Destination(name="new-dest", type="HTTP", url="https://api.example.com") - result = client.create_destination(dest, level=Level.SUB_ACCOUNT) - + result = destination_client.create_destination(dest, level=Level.SUB_ACCOUNT) assert result is None + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinations" + assert kwargs["json"] == dest.to_dict() - args, kwargs = mock_http.post.call_args - assert args[0] == "v1/subaccountDestinations" - assert kwargs["body"] == dest.to_dict() - - def test_create_destination_with_tenant(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) + def test_create_destination_with_tenant(self, mock_http, destination_client): dest = Destination(name="new-dest", type="HTTP", url="https://api.example.com") - - client.create_destination(dest, level=Level.SUB_ACCOUNT, tenant="test-tenant") - - _, kwargs = mock_http.post.call_args + destination_client.create_destination(dest, level=Level.SUB_ACCOUNT, tenant="test-tenant") + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" - def test_create_destination_without_tenant_uses_provider_context(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - dest = Destination(name="new-dest", type="HTTP") - - client.create_destination(dest) - - _, kwargs = mock_http.post.call_args + def test_create_destination_without_tenant_uses_provider_context(self, mock_http, destination_client): + destination_client.create_destination(Destination(name="new-dest", type="HTTP")) + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None - def test_create_destination_http_error_propagates(self): - mock_http = MagicMock() - mock_http.post.side_effect = HttpError("http fail", status_code=400) - client = DestinationClient(mock_http) - + def test_create_destination_http_error_propagates(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(400) with pytest.raises(HttpError): - client.create_destination(Destination(name="d", type="HTTP")) - - def test_create_destination_unexpected_error_wrapped(self): - mock_http = MagicMock() - mock_http.post.side_effect = Exception("boom") - client = DestinationClient(mock_http) + destination_client.create_destination(Destination(name="d", type="HTTP")) + def test_create_destination_unexpected_error_wrapped(self, mock_http, destination_client): + mock_http.request.side_effect = Exception("boom") with pytest.raises(DestinationOperationError, match="failed to create destination 'x'"): - client.create_destination(Destination(name="x", type="HTTP")) - + destination_client.create_destination(Destination(name="x", type="HTTP")) - def test_update_destination_success(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - mock_http.put.return_value = resp - - client = DestinationClient(mock_http) + def test_update_destination_success(self, mock_http, destination_client): dest = Destination(name="upd-dest", type="HTTP", description="updated") - result = client.update_destination(dest, level=Level.SUB_ACCOUNT) - + result = destination_client.update_destination(dest, level=Level.SUB_ACCOUNT) assert result is None + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinations" + assert kwargs["json"] == dest.to_dict() - args, kwargs = mock_http.put.call_args - assert args[0] == "v1/subaccountDestinations" - assert kwargs["body"] == dest.to_dict() - - def test_update_destination_with_tenant(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - dest = Destination(name="upd-dest", type="HTTP") - - client.update_destination(dest, level=Level.SUB_ACCOUNT, tenant="test-tenant") - - _, kwargs = mock_http.put.call_args + def test_update_destination_with_tenant(self, mock_http, destination_client): + destination_client.update_destination(Destination(name="upd-dest", type="HTTP"), level=Level.SUB_ACCOUNT, tenant="test-tenant") + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" - def test_update_destination_without_tenant_uses_provider_context(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - dest = Destination(name="upd-dest", type="HTTP") - - client.update_destination(dest) - - _, kwargs = mock_http.put.call_args + def test_update_destination_without_tenant_uses_provider_context(self, mock_http, destination_client): + destination_client.update_destination(Destination(name="upd-dest", type="HTTP")) + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None - def test_update_destination_http_error_propagates(self): - mock_http = MagicMock() - mock_http.put.side_effect = HttpError("http fail", status_code=500) - client = DestinationClient(mock_http) - + def test_update_destination_http_error_propagates(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(500) with pytest.raises(HttpError): - client.update_destination(Destination(name="d", type="HTTP")) - - def test_update_destination_unexpected_error_wrapped(self): - mock_http = MagicMock() - mock_http.put.side_effect = Exception("boom") - client = DestinationClient(mock_http) + destination_client.update_destination(Destination(name="d", type="HTTP")) + def test_update_destination_unexpected_error_wrapped(self, mock_http, destination_client): + mock_http.request.side_effect = Exception("boom") with pytest.raises(DestinationOperationError, match="failed to update destination 'd'"): - client.update_destination(Destination(name="d", type="HTTP")) + destination_client.update_destination(Destination(name="d", type="HTTP")) - def test_delete_destination_success(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 204 - mock_http.delete.return_value = resp - - client = DestinationClient(mock_http) - client.delete_destination("to-del", level=Level.SUB_ACCOUNT) - - args, kwargs = mock_http.delete.call_args - assert args[0] == "v1/subaccountDestinations/to-del" + def test_delete_destination_success(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(204) + destination_client.delete_destination("to-del", level=Level.SUB_ACCOUNT) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinations/to-del" assert kwargs["tenant_subdomain"] is None - def test_delete_destination_with_tenant(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - - client.delete_destination("to-del", level=Level.SUB_ACCOUNT, tenant="test-tenant") - - args, kwargs = mock_http.delete.call_args - assert args[0] == "v1/subaccountDestinations/to-del" + def test_delete_destination_with_tenant(self, mock_http, destination_client): + destination_client.delete_destination("to-del", level=Level.SUB_ACCOUNT, tenant="test-tenant") + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinations/to-del" assert kwargs["tenant_subdomain"] == "test-tenant" - def test_delete_destination_without_tenant_uses_provider_context(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - - client.delete_destination("to-del") - - _, kwargs = mock_http.delete.call_args + def test_delete_destination_without_tenant_uses_provider_context(self, mock_http, destination_client): + destination_client.delete_destination("to-del") + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None - def test_delete_destination_http_error_propagates(self): - mock_http = MagicMock() - mock_http.delete.side_effect = HttpError("http fail", status_code=500) - client = DestinationClient(mock_http) - + def test_delete_destination_http_error_propagates(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(500) with pytest.raises(HttpError): - client.delete_destination("x") - - def test_delete_destination_unexpected_error_wrapped(self): - mock_http = MagicMock() - mock_http.delete.side_effect = Exception("boom") - client = DestinationClient(mock_http) + destination_client.delete_destination("x") + def test_delete_destination_unexpected_error_wrapped(self, mock_http, destination_client): + mock_http.request.side_effect = Exception("boom") with pytest.raises(DestinationOperationError, match="failed to delete destination 'x'"): - client.delete_destination("x") + destination_client.delete_destination("x") class TestDestinationClientInternalBehavior: - def test_get_destination_invalid_json_wrapped(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - # Simulate invalid JSON parsing + def test_get_destination_invalid_json_wrapped(self, mock_http, destination_client): + resp = _make_response(200) resp.json.side_effect = ValueError("bad json") - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + mock_http.request.return_value = resp with pytest.raises(DestinationOperationError, match="invalid JSON in get destination response"): - client._get_destination(name="n", tenant_subdomain=None, level=Level.SUB_ACCOUNT) + destination_client._get_destination(name="n", tenant_subdomain=None, level=Level.SUB_ACCOUNT) def test_sub_path_for_level(self): assert DestinationClient._sub_path_for_level(Level.SERVICE_INSTANCE) == "instanceDestinations" @@ -1398,220 +811,137 @@ def test_sub_path_for_level(self): class TestDestinationClientListOperations: """Test list_instance_destinations and list_subaccount_destinations methods.""" - def test_list_instance_destinations_success(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - resp.json.return_value = [ + def test_list_instance_destinations_success(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[ {"name": "dest1", "type": "HTTP"}, - {"name": "dest2", "type": "HTTP"} - ] - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.list_instance_destinations() - + {"name": "dest2", "type": "HTTP"}, + ]) + result = destination_client.list_instance_destinations() assert isinstance(result, PagedResult) assert len(result.items) == 2 assert all(isinstance(d, Destination) for d in result.items) assert result.items[0].name == "dest1" assert result.items[1].name == "dest2" - assert result.pagination is None # No pagination headers - - # Verify HTTP was called with instance path, no tenant, and no params - args, kwargs = mock_http.get.call_args - assert args[0] == "v1/instanceDestinations" + assert result.pagination is None + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/instanceDestinations" assert kwargs.get("tenant_subdomain") is None assert kwargs.get("params") == {} - def test_list_instance_destinations_empty_list(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - resp.json.return_value = [] - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.list_instance_destinations() - + def test_list_instance_destinations_empty_list(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[]) + result = destination_client.list_instance_destinations() assert isinstance(result, PagedResult) assert len(result.items) == 0 assert result.pagination is None - def test_list_instance_destinations_with_filter(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - resp.json.return_value = [{"name": "dest1", "type": "HTTP"}] - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + def test_list_instance_destinations_with_filter(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[{"name": "dest1", "type": "HTTP"}]) filter_obj = ListOptions(filter_names=["dest1", "dest2"]) - result = client.list_instance_destinations(filter=filter_obj) - + result = destination_client.list_instance_destinations(filter=filter_obj) assert isinstance(result, PagedResult) assert len(result.items) == 1 - - # Verify params were passed - args, kwargs = mock_http.get.call_args + args, kwargs = mock_http.request.call_args assert "params" in kwargs assert "$filter" in kwargs["params"] assert "Name in" in kwargs["params"]["$filter"] - def test_list_instance_destinations_http_error_wrapped(self): - mock_http = MagicMock() - mock_http.get.side_effect = HttpError("boom", status_code=500, response_text="err") - - client = DestinationClient(mock_http) + def test_list_instance_destinations_http_error_wrapped(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(500, text="err") with pytest.raises(DestinationOperationError, match="failed to list instance destinations"): - client.list_instance_destinations() - - def test_list_instance_destinations_invalid_json_wrapped(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.json.return_value = {"not": "a list"} # Should be a list - mock_http.get.return_value = resp + destination_client.list_instance_destinations() - client = DestinationClient(mock_http) + def test_list_instance_destinations_invalid_json_wrapped(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data={"not": "a list"}) with pytest.raises(DestinationOperationError, match="expected list in response"): - client.list_instance_destinations() - - def test_list_instance_destinations_with_tenant(self): - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - resp.json.return_value = [{"name": "dest1", "type": "HTTP"}] - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.list_instance_destinations(tenant="my-tenant") + destination_client.list_instance_destinations() + def test_list_instance_destinations_with_tenant(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[{"name": "dest1", "type": "HTTP"}]) + result = destination_client.list_instance_destinations(tenant="my-tenant") assert isinstance(result, PagedResult) assert len(result.items) == 1 - - args, kwargs = mock_http.get.call_args - assert args[0] == "v1/instanceDestinations" + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/instanceDestinations" assert kwargs.get("tenant_subdomain") == "my-tenant" def test_list_subaccount_destinations_requires_tenant_for_subscriber_access(self): - client = DestinationClient(MagicMock()) - + client = DestinationClient(Mock()) for strat in [AccessStrategy.SUBSCRIBER_ONLY, AccessStrategy.SUBSCRIBER_FIRST, AccessStrategy.PROVIDER_FIRST]: with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): client.list_subaccount_destinations(access_strategy=strat, tenant=None) def test_list_subaccount_destinations_provider_only_no_tenant_required(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) paged_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) - with patch.object(client, "_list_destinations", return_value=paged_result) as mock_list: result = client.list_subaccount_destinations(access_strategy=AccessStrategy.PROVIDER_ONLY, tenant=None) assert result == paged_result - # Called once with provider context (no tenant) mock_list.assert_called_once() called_kwargs = mock_list.call_args.kwargs assert called_kwargs.get("tenant_subdomain") is None assert called_kwargs.get("level") == Level.SUB_ACCOUNT def test_list_subaccount_destinations_subscriber_only_with_tenant(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) paged_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) - with patch.object(client, "_list_destinations", return_value=paged_result) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, - tenant="tenant-1" - ) + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="tenant-1") assert result == paged_result - # Called once with subscriber context mock_list.assert_called_once() called_kwargs = mock_list.call_args.kwargs assert called_kwargs.get("tenant_subdomain") == "tenant-1" def test_list_subaccount_destinations_subscriber_first_no_fallback(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) paged_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) - with patch.object(client, "_list_destinations", return_value=paged_result) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="tenant-1" - ) + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-1") assert result == paged_result - # Found in subscriber, no fallback needed mock_list.assert_called_once() def test_list_subaccount_destinations_subscriber_first_fallback_to_provider(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) empty_result = PagedResult(items=[]) provider_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) - with patch.object(client, "_list_destinations", side_effect=[empty_result, provider_result]) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="tenant-1" - ) + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-1") assert result == provider_result - # First subscriber (empty), then provider fallback assert mock_list.call_count == 2 - # First call with tenant assert mock_list.call_args_list[0].kwargs.get("tenant_subdomain") == "tenant-1" - # Second call without tenant (provider) assert mock_list.call_args_list[1].kwargs.get("tenant_subdomain") is None def test_list_subaccount_destinations_provider_first_no_fallback(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) paged_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) - with patch.object(client, "_list_destinations", return_value=paged_result) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="tenant-1" - ) + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="tenant-1") assert result == paged_result - # Found in provider, no fallback needed mock_list.assert_called_once() def test_list_subaccount_destinations_provider_first_fallback_to_subscriber(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) empty_result = PagedResult(items=[]) subscriber_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) - with patch.object(client, "_list_destinations", side_effect=[empty_result, subscriber_result]) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="tenant-1" - ) + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="tenant-1") assert result == subscriber_result - # First provider (empty), then subscriber fallback assert mock_list.call_count == 2 - # First call without tenant (provider) assert mock_list.call_args_list[0].kwargs.get("tenant_subdomain") is None - # Second call with tenant assert mock_list.call_args_list[1].kwargs.get("tenant_subdomain") == "tenant-1" def test_list_subaccount_destinations_with_filter(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) paged_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) filter_obj = ListOptions(page=1, page_size=10) - with patch.object(client, "_list_destinations", return_value=paged_result) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.PROVIDER_ONLY, - filter=filter_obj - ) + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.PROVIDER_ONLY, filter=filter_obj) assert result == paged_result - # Verify filter was passed called_kwargs = mock_list.call_args.kwargs assert called_kwargs.get("filter") == filter_obj def test_list_subaccount_destinations_http_error_wrapped(self): - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) with patch.object(client, "_list_destinations", side_effect=HttpError("bad", status_code=500)): with pytest.raises(DestinationOperationError, match="failed to list subaccount destinations"): client.list_subaccount_destinations(access_strategy=AccessStrategy.PROVIDER_ONLY) @@ -1621,118 +951,66 @@ class TestDestinationClientAccessStrategy: """Test the _apply_access_strategy helper method.""" def test_apply_access_strategy_subscriber_only(self): - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock(return_value="result") - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, - tenant="tenant-1", - fetch_func=mock_fetch - ) - + client = DestinationClient(Mock()) + mock_fetch = Mock(return_value="result") + result = client._apply_access_strategy(access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="tenant-1", fetch_func=mock_fetch) assert result == "result" mock_fetch.assert_called_once_with("tenant-1") def test_apply_access_strategy_provider_only(self): - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock(return_value="result") - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.PROVIDER_ONLY, - tenant=None, - fetch_func=mock_fetch - ) - + client = DestinationClient(Mock()) + mock_fetch = Mock(return_value="result") + result = client._apply_access_strategy(access_strategy=AccessStrategy.PROVIDER_ONLY, tenant=None, fetch_func=mock_fetch) assert result == "result" mock_fetch.assert_called_once_with(None) def test_apply_access_strategy_subscriber_first_no_fallback(self): - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock(return_value="result") - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="tenant-1", - fetch_func=mock_fetch - ) - + client = DestinationClient(Mock()) + mock_fetch = Mock(return_value="result") + result = client._apply_access_strategy(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-1", fetch_func=mock_fetch) assert result == "result" - # Called once, found result in subscriber mock_fetch.assert_called_once_with("tenant-1") def test_apply_access_strategy_subscriber_first_with_fallback(self): - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock(side_effect=[None, "provider-result"]) - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="tenant-1", - fetch_func=mock_fetch - ) - + client = DestinationClient(Mock()) + mock_fetch = Mock(side_effect=[None, "provider-result"]) + result = client._apply_access_strategy(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-1", fetch_func=mock_fetch) assert result == "provider-result" - # Called twice: subscriber returned None, then provider assert mock_fetch.call_count == 2 assert mock_fetch.call_args_list[0][0][0] == "tenant-1" assert mock_fetch.call_args_list[1][0][0] is None def test_apply_access_strategy_provider_first_no_fallback(self): - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock(return_value="result") - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="tenant-1", - fetch_func=mock_fetch - ) - + client = DestinationClient(Mock()) + mock_fetch = Mock(return_value="result") + result = client._apply_access_strategy(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="tenant-1", fetch_func=mock_fetch) assert result == "result" - # Called once, found result in provider mock_fetch.assert_called_once_with(None) def test_apply_access_strategy_provider_first_with_fallback(self): - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock(side_effect=[None, "subscriber-result"]) - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="tenant-1", - fetch_func=mock_fetch - ) - + client = DestinationClient(Mock()) + mock_fetch = Mock(side_effect=[None, "subscriber-result"]) + result = client._apply_access_strategy(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="tenant-1", fetch_func=mock_fetch) assert result == "subscriber-result" - # Called twice: provider returned None, then subscriber assert mock_fetch.call_count == 2 assert mock_fetch.call_args_list[0][0][0] is None assert mock_fetch.call_args_list[1][0][0] == "tenant-1" def test_apply_access_strategy_requires_tenant_for_subscriber_strategies(self): - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock() - + client = DestinationClient(Mock()) + mock_fetch = Mock() for strat in [AccessStrategy.SUBSCRIBER_ONLY, AccessStrategy.SUBSCRIBER_FIRST, AccessStrategy.PROVIDER_FIRST]: with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): - client._apply_access_strategy( - access_strategy=strat, - tenant=None, - fetch_func=mock_fetch - ) + client._apply_access_strategy(access_strategy=strat, tenant=None, fetch_func=mock_fetch) def test_apply_access_strategy_with_list_empty_value(self): """Test that empty PagedResult triggers fallback.""" - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) empty_result = PagedResult(items=[]) filled_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) - mock_fetch = MagicMock(side_effect=[empty_result, filled_result]) - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="tenant-1", - fetch_func=mock_fetch - ) - + mock_fetch = Mock(side_effect=[empty_result, filled_result]) + result = client._apply_access_strategy(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="tenant-1", fetch_func=mock_fetch) assert result == filled_result - # Empty PagedResult triggered fallback assert mock_fetch.call_count == 2 @@ -1741,484 +1019,293 @@ class TestDestinationClientEdgeCases: def test_get_subaccount_destination_unknown_access_strategy(self): """Test that unknown access strategy raises appropriate error.""" - from unittest.mock import Mock as MockStrategy - - client = DestinationClient(MagicMock()) - unknown_strategy = MockStrategy() + client = DestinationClient(Mock()) + unknown_strategy = Mock() unknown_strategy.value = "UNKNOWN_STRATEGY" - with pytest.raises(DestinationOperationError) as exc_info: - client.get_subaccount_destination( - "test-dest", - access_strategy=unknown_strategy, - tenant="test-tenant" - ) - + client.get_subaccount_destination("test-dest", access_strategy=unknown_strategy, tenant="test-tenant") assert "unknown access strategy" in str(exc_info.value).lower() - def test_list_destinations_non_list_response(self): + def test_list_destinations_non_list_response(self, mock_http, destination_client): """Test list destinations when response is not a list.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - resp.json.return_value = {"error": "not a list"} - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + mock_http.request.return_value = _make_response(200, json_data={"error": "not a list"}) with pytest.raises(DestinationOperationError) as exc_info: - client.list_instance_destinations() - + destination_client.list_instance_destinations() assert "expected list in response" in str(exc_info.value) def test_list_subaccount_destinations_both_empty_subscriber_first(self): """Test SUBSCRIBER_FIRST when both subscriber and provider return empty.""" - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) empty_result = PagedResult(items=[]) - with patch.object(client, "_list_destinations", return_value=empty_result) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert result == PagedResult(items=[]) assert mock_list.call_count == 2 def test_list_subaccount_destinations_both_empty_provider_first(self): """Test PROVIDER_FIRST when both provider and subscriber return empty.""" - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) empty_result = PagedResult(items=[]) - with patch.object(client, "_list_destinations", return_value=empty_result) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert result == PagedResult(items=[]) assert mock_list.call_count == 2 - def test_get_destination_malformed_destination_data(self): + def test_get_destination_malformed_destination_data(self, mock_http, destination_client): """Test get destination with malformed Destination data in response.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - # Missing required fields for Destination.from_dict - resp.json.return_value = {"name": "", "type": ""} - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + mock_http.request.return_value = _make_response(200, json_data={"name": "", "type": ""}) with pytest.raises(DestinationOperationError) as exc_info: - client.get_instance_destination("test-dest") - + destination_client.get_instance_destination("test-dest") assert "invalid JSON in get destination response" in str(exc_info.value) - def test_list_destinations_invalid_destination_in_array(self): + def test_list_destinations_invalid_destination_in_array(self, mock_http, destination_client): """Test list destinations with invalid destination object in array - invalid destinations are skipped.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - # One valid, one invalid destination - resp.json.return_value = [ + mock_http.request.return_value = _make_response(200, json_data=[ {"name": "dest1", "type": "HTTP"}, - {"name": "", "type": ""} # Invalid - will be skipped - ] - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.list_instance_destinations() - - # Should return only the valid destination, skipping the invalid one + {"name": "", "type": ""}, + ]) + result = destination_client.list_instance_destinations() assert isinstance(result, PagedResult) assert len(result.items) == 1 assert result.items[0].name == "dest1" def test_apply_access_strategy_unknown_strategy(self): """Test _apply_access_strategy with unknown strategy.""" - from unittest.mock import Mock as MockStrategy - - client = DestinationClient(MagicMock()) - unknown_strategy = MockStrategy() + client = DestinationClient(Mock()) + unknown_strategy = Mock() unknown_strategy.value = "UNKNOWN" - - mock_fetch = MagicMock(return_value="result") - with pytest.raises(DestinationOperationError) as exc_info: - client._apply_access_strategy( - access_strategy=unknown_strategy, - tenant="test-tenant", - fetch_func=mock_fetch - ) - + client._apply_access_strategy(access_strategy=unknown_strategy, tenant="test-tenant", fetch_func=Mock(return_value="result")) assert "unknown access strategy" in str(exc_info.value).lower() def test_get_subaccount_destination_provider_first_both_none(self): """Test PROVIDER_FIRST when both provider and subscriber return None.""" - client = DestinationClient(MagicMock()) - + client = DestinationClient(Mock()) with patch.object(client, "_get_destination", return_value=None) as mock_get: - destination = client.get_subaccount_destination( - "test-dest", - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - + destination = client.get_subaccount_destination("test-dest", access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert destination is None assert mock_get.call_count == 2 def test_get_subaccount_destination_subscriber_first_both_none(self): """Test SUBSCRIBER_FIRST when both subscriber and provider return None.""" - client = DestinationClient(MagicMock()) - + client = DestinationClient(Mock()) with patch.object(client, "_get_destination", return_value=None) as mock_get: - destination = client.get_subaccount_destination( - "test-dest", - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - + destination = client.get_subaccount_destination("test-dest", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert destination is None assert mock_get.call_count == 2 - def test_list_destinations_with_http_403_error(self): + def test_list_destinations_with_http_403_error(self, mock_http, destination_client): """Test list destinations with 403 Forbidden error.""" - mock_http = MagicMock() - http_error = HttpError("Forbidden", status_code=403, response_text="Forbidden") - mock_http.get.side_effect = http_error - - client = DestinationClient(mock_http) + mock_http.request.return_value = _make_response(403, text="Forbidden") with pytest.raises(DestinationOperationError) as exc_info: - client.list_instance_destinations() - + destination_client.list_instance_destinations() assert "failed to list instance destinations" in str(exc_info.value) - def test_get_destination_with_http_401_error(self): + def test_get_destination_with_http_401_error(self, mock_http, destination_client): """Test get destination with 401 Unauthorized error.""" - mock_http = MagicMock() - http_error = HttpError("Unauthorized", status_code=401, response_text="Unauthorized") - mock_http.get.side_effect = http_error - - client = DestinationClient(mock_http) + mock_http.request.return_value = _make_response(401, text="Unauthorized") with pytest.raises(DestinationOperationError) as exc_info: - client.get_instance_destination("test-dest") - + destination_client.get_instance_destination("test-dest") assert "failed to get destination 'test-dest'" in str(exc_info.value) - def test_list_destinations_json_parsing_error(self): + def test_list_destinations_json_parsing_error(self, mock_http, destination_client): """Test list destinations with JSON parsing error.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 + resp = _make_response(200) resp.headers = {} resp.json.side_effect = ValueError("Invalid JSON") - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) + mock_http.request.return_value = resp with pytest.raises(DestinationOperationError) as exc_info: - client.list_instance_destinations() - + destination_client.list_instance_destinations() assert "invalid JSON in list destinations response" in str(exc_info.value) def test_apply_access_strategy_with_paged_result_empty_value(self): """Test _apply_access_strategy properly handles empty PagedResult objects.""" - client = DestinationClient(MagicMock()) - + client = DestinationClient(Mock()) empty_paged = PagedResult(items=[]) filled_paged = PagedResult(items=[Destination(name="d1", type="HTTP")]) - - mock_fetch = MagicMock(side_effect=[empty_paged, filled_paged]) - - result = client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant", - fetch_func=mock_fetch - ) - + mock_fetch = Mock(side_effect=[empty_paged, filled_paged]) + result = client._apply_access_strategy(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant", fetch_func=mock_fetch) assert result is not None assert result == filled_paged assert len(result.items) == 1 assert mock_fetch.call_count == 2 - def test_create_destination_with_connection_error(self): + def test_create_destination_with_connection_error(self, mock_http, destination_client): """Test create destination with connection error.""" - mock_http = MagicMock() - mock_http.post.side_effect = ConnectionError("Network unreachable") - - client = DestinationClient(mock_http) + mock_http.request.side_effect = ConnectionError("Network unreachable") with pytest.raises(DestinationOperationError) as exc_info: - client.create_destination(Destination(name="test-dest", type="HTTP")) - + destination_client.create_destination(Destination(name="test-dest", type="HTTP")) assert "failed to create destination 'test-dest'" in str(exc_info.value) assert "Network unreachable" in str(exc_info.value) - def test_update_destination_with_timeout_error(self): + def test_update_destination_with_timeout_error(self, mock_http, destination_client): """Test update destination with timeout error.""" - mock_http = MagicMock() - mock_http.put.side_effect = TimeoutError("Request timeout") - - client = DestinationClient(mock_http) + mock_http.request.side_effect = TimeoutError("Request timeout") with pytest.raises(DestinationOperationError) as exc_info: - client.update_destination(Destination(name="test-dest", type="HTTP")) - + destination_client.update_destination(Destination(name="test-dest", type="HTTP")) assert "failed to update destination 'test-dest'" in str(exc_info.value) - def test_delete_destination_with_runtime_error(self): + def test_delete_destination_with_runtime_error(self, mock_http, destination_client): """Test delete destination with runtime error.""" - mock_http = MagicMock() - mock_http.delete.side_effect = RuntimeError("Unexpected runtime error") - - client = DestinationClient(mock_http) + mock_http.request.side_effect = RuntimeError("Unexpected runtime error") with pytest.raises(DestinationOperationError) as exc_info: - client.delete_destination("test-dest") - + destination_client.delete_destination("test-dest") assert "failed to delete destination 'test-dest'" in str(exc_info.value) - def test_get_destination_with_non_404_http_error_propagates(self): + def test_get_destination_with_non_404_http_error_propagates(self, mock_http, destination_client): """Test that non-404 HTTP errors are propagated correctly (not wrapped by _get_destination).""" - mock_http = MagicMock() - http_error = HttpError("Bad Gateway", status_code=502, response_text="Bad Gateway") - mock_http.get.side_effect = http_error - - client = DestinationClient(mock_http) - # _get_destination propagates non-404 HttpErrors directly + mock_http.request.return_value = _make_response(502, text="Bad Gateway") with pytest.raises(HttpError) as exc_info: - client._get_destination(name="test-dest", tenant_subdomain=None, level=Level.SUB_ACCOUNT) - - # The error should not return None (which is reserved for 404) - # It should raise HttpError directly + destination_client._get_destination(name="test-dest", tenant_subdomain=None, level=Level.SUB_ACCOUNT) assert exc_info.value.status_code == 502 - assert "Bad Gateway" in str(exc_info.value) + assert exc_info.value.response_text == "Bad Gateway" - def test_list_destinations_with_malformed_json_items(self): + def test_list_destinations_with_malformed_json_items(self, mock_http, destination_client): """Test list destinations when JSON contains items that can't be parsed into Destination objects - malformed items are skipped.""" - mock_http = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - resp.headers = {} - # Valid list structure but some items can't be converted to Destination - resp.json.return_value = [ + mock_http.request.return_value = _make_response(200, json_data=[ {"name": "dest1", "type": "HTTP"}, - {"invalid": "structure"} # Missing required 'name' and 'type' - will be skipped - ] - mock_http.get.return_value = resp - - client = DestinationClient(mock_http) - result = client.list_instance_destinations() - - # Should return only the valid destination, skipping the malformed one + {"invalid": "structure"}, + ]) + result = destination_client.list_instance_destinations() assert isinstance(result, PagedResult) assert len(result.items) == 1 assert result.items[0].name == "dest1" def test_list_subaccount_destinations_with_filter_and_fallback(self): """Test that filter is correctly passed through fallback scenarios.""" - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) empty_result = PagedResult(items=[]) filled_result = PagedResult(items=[Destination(name="d1", type="HTTP")]) filter_obj = ListOptions(filter_names=["d1"]) - with patch.object(client, "_list_destinations", side_effect=[empty_result, filled_result]) as mock_list: - result = client.list_subaccount_destinations( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant", - filter=filter_obj - ) - + result = client.list_subaccount_destinations(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant", filter=filter_obj) assert result == filled_result assert mock_list.call_count == 2 - # Verify filter was passed to both calls for call in mock_list.call_args_list: assert call.kwargs.get("filter") == filter_obj def test_apply_access_strategy_with_exception_in_fetch_func(self): """Test _apply_access_strategy when fetch_func raises an exception.""" - client = DestinationClient(MagicMock()) - mock_fetch = MagicMock(side_effect=ValueError("Fetch failed")) - + client = DestinationClient(Mock()) + mock_fetch = Mock(side_effect=ValueError("Fetch failed")) with pytest.raises(ValueError) as exc_info: - client._apply_access_strategy( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, - tenant="test-tenant", - fetch_func=mock_fetch - ) - + client._apply_access_strategy(access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="test-tenant", fetch_func=mock_fetch) assert "Fetch failed" in str(exc_info.value) class TestDestinationClientLabels: """Tests for DestinationClient label operations.""" - def test_get_destination_labels_instance(self): - mock_http = MagicMock() - mock_http.get.return_value.json.return_value = [{"key": "env", "values": ["prod"]}] - client = DestinationClient(mock_http) - - labels = client.get_destination_labels("destA", Level.SERVICE_INSTANCE) - + def test_get_destination_labels_instance(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[{"key": "env", "values": ["prod"]}]) + labels = destination_client.get_destination_labels("destA", Level.SERVICE_INSTANCE) assert len(labels) == 1 assert labels[0].key == "env" - mock_http.get.assert_called_once_with("v1/instanceDestinations/destA/labels", tenant_subdomain=None) - - def test_get_destination_labels_subaccount(self): - mock_http = MagicMock() - mock_http.get.return_value.json.return_value = [{"key": "team", "values": ["platform"]}] - client = DestinationClient(mock_http) - - labels = client.get_destination_labels("destA", Level.SUB_ACCOUNT) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/instanceDestinations/destA/labels" + assert kwargs["tenant_subdomain"] is None + def test_get_destination_labels_subaccount(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[{"key": "team", "values": ["platform"]}]) + labels = destination_client.get_destination_labels("destA", Level.SUB_ACCOUNT) assert labels[0].key == "team" - mock_http.get.assert_called_once_with("v1/subaccountDestinations/destA/labels", tenant_subdomain=None) - - def test_get_destination_labels_default_level_is_subaccount(self): - mock_http = MagicMock() - mock_http.get.return_value.json.return_value = [] - client = DestinationClient(mock_http) - - client.get_destination_labels("destA") - - mock_http.get.assert_called_once_with("v1/subaccountDestinations/destA/labels", tenant_subdomain=None) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinations/destA/labels" + assert kwargs["tenant_subdomain"] is None - def test_get_destination_labels_non_list_response_raises(self): - mock_http = MagicMock() - mock_http.get.return_value.json.return_value = {"key": "env"} - client = DestinationClient(mock_http) + def test_get_destination_labels_default_level_is_subaccount(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[]) + destination_client.get_destination_labels("destA") + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinations/destA/labels" + assert kwargs["tenant_subdomain"] is None + def test_get_destination_labels_non_list_response_raises(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data={"key": "env"}) with pytest.raises(DestinationOperationError): - client.get_destination_labels("destA") - - def test_get_destination_labels_http_error_raises_operation_error(self): - mock_http = MagicMock() - mock_http.get.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - client = DestinationClient(mock_http) + destination_client.get_destination_labels("destA") + def test_get_destination_labels_http_error_raises_operation_error(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(DestinationOperationError, match="failed to get labels for destination"): - client.get_destination_labels("destA") + destination_client.get_destination_labels("destA") - def test_update_destination_labels_instance(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) + def test_update_destination_labels_instance(self, mock_http, destination_client): labels = [Label(key="env", values=["prod"])] + destination_client.update_destination_labels("destA", labels, Level.SERVICE_INSTANCE) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PUT + assert args[1] == "/v1/instanceDestinations/destA/labels" + assert kwargs["json"] == [{"key": "env", "values": ["prod"]}] + assert kwargs["tenant_subdomain"] is None - client.update_destination_labels("destA", labels, Level.SERVICE_INSTANCE) - - mock_http.put.assert_called_once_with( - "v1/instanceDestinations/destA/labels", - body=[{"key": "env", "values": ["prod"]}], - tenant_subdomain=None, - ) - - def test_update_destination_labels_subaccount(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) + def test_update_destination_labels_subaccount(self, mock_http, destination_client): labels = [Label(key="env", values=["staging"])] + destination_client.update_destination_labels("destA", labels, Level.SUB_ACCOUNT) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PUT + assert args[1] == "/v1/subaccountDestinations/destA/labels" + assert kwargs["json"] == [{"key": "env", "values": ["staging"]}] + assert kwargs["tenant_subdomain"] is None - client.update_destination_labels("destA", labels, Level.SUB_ACCOUNT) - - mock_http.put.assert_called_once_with( - "v1/subaccountDestinations/destA/labels", - body=[{"key": "env", "values": ["staging"]}], - tenant_subdomain=None, - ) - - def test_update_destination_labels_http_error_propagates(self): - mock_http = MagicMock() - mock_http.put.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - client = DestinationClient(mock_http) - + def test_update_destination_labels_http_error_propagates(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(HttpError): - client.update_destination_labels("destA", [], Level.SUB_ACCOUNT) - - def test_patch_destination_labels_instance(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - patch = PatchLabels(action="ADD", labels=[Label(key="env", values=["prod"])]) - - client.patch_destination_labels("destA", patch, Level.SERVICE_INSTANCE) - - mock_http.patch.assert_called_once_with( - "v1/instanceDestinations/destA/labels", - body={"action": "ADD", "labels": [{"key": "env", "values": ["prod"]}]}, - tenant_subdomain=None, - ) - - def test_patch_destination_labels_subaccount(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - patch = PatchLabels(action="DELETE", labels=[Label(key="env", values=[])]) - - client.patch_destination_labels("destA", patch, Level.SUB_ACCOUNT) - - mock_http.patch.assert_called_once_with( - "v1/subaccountDestinations/destA/labels", - body={"action": "DELETE", "labels": [{"key": "env", "values": []}]}, - tenant_subdomain=None, - ) + destination_client.update_destination_labels("destA", [], Level.SUB_ACCOUNT) + + def test_patch_destination_labels_instance(self, mock_http, destination_client): + patch_obj = PatchLabels(action="ADD", labels=[Label(key="env", values=["prod"])]) + destination_client.patch_destination_labels("destA", patch_obj, Level.SERVICE_INSTANCE) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PATCH + assert args[1] == "/v1/instanceDestinations/destA/labels" + assert kwargs["json"] == {"action": "ADD", "labels": [{"key": "env", "values": ["prod"]}]} + assert kwargs["tenant_subdomain"] is None - def test_patch_destination_labels_http_error_propagates(self): - mock_http = MagicMock() - mock_http.patch.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - client = DestinationClient(mock_http) + def test_patch_destination_labels_subaccount(self, mock_http, destination_client): + patch_obj = PatchLabels(action="DELETE", labels=[Label(key="env", values=[])]) + destination_client.patch_destination_labels("destA", patch_obj, Level.SUB_ACCOUNT) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PATCH + assert args[1] == "/v1/subaccountDestinations/destA/labels" + assert kwargs["json"] == {"action": "DELETE", "labels": [{"key": "env", "values": []}]} + assert kwargs["tenant_subdomain"] is None + def test_patch_destination_labels_http_error_propagates(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(HttpError): - client.patch_destination_labels("destA", PatchLabels(action="ADD", labels=[]), Level.SUB_ACCOUNT) - - def test_get_destination_labels_with_tenant(self): - mock_http = MagicMock() - mock_http.get.return_value.json.return_value = [] - client = DestinationClient(mock_http) + destination_client.patch_destination_labels("destA", PatchLabels(action="ADD", labels=[]), Level.SUB_ACCOUNT) - client.get_destination_labels("destA", tenant="test-tenant") - - _, kwargs = mock_http.get.call_args + def test_get_destination_labels_with_tenant(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[]) + destination_client.get_destination_labels("destA", tenant="test-tenant") + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" - def test_get_destination_labels_without_tenant_uses_provider_context(self): - mock_http = MagicMock() - mock_http.get.return_value.json.return_value = [] - client = DestinationClient(mock_http) - - client.get_destination_labels("destA") - - _, kwargs = mock_http.get.call_args + def test_get_destination_labels_without_tenant_uses_provider_context(self, mock_http, destination_client): + mock_http.request.return_value = _make_response(200, json_data=[]) + destination_client.get_destination_labels("destA") + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None - def test_update_destination_labels_with_tenant(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - - client.update_destination_labels("destA", [], tenant="test-tenant") - - _, kwargs = mock_http.put.call_args + def test_update_destination_labels_with_tenant(self, mock_http, destination_client): + destination_client.update_destination_labels("destA", [], tenant="test-tenant") + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" - def test_update_destination_labels_without_tenant_uses_provider_context(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - - client.update_destination_labels("destA", []) - - _, kwargs = mock_http.put.call_args + def test_update_destination_labels_without_tenant_uses_provider_context(self, mock_http, destination_client): + destination_client.update_destination_labels("destA", []) + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None - def test_patch_destination_labels_with_tenant(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - - client.patch_destination_labels("destA", PatchLabels(action="ADD", labels=[]), tenant="test-tenant") - - _, kwargs = mock_http.patch.call_args + def test_patch_destination_labels_with_tenant(self, mock_http, destination_client): + destination_client.patch_destination_labels("destA", PatchLabels(action="ADD", labels=[]), tenant="test-tenant") + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" - def test_patch_destination_labels_without_tenant_uses_provider_context(self): - mock_http = MagicMock() - client = DestinationClient(mock_http) - - client.patch_destination_labels("destA", PatchLabels(action="ADD", labels=[])) - - _, kwargs = mock_http.patch.call_args + def test_patch_destination_labels_without_tenant_uses_provider_context(self, mock_http, destination_client): + destination_client.patch_destination_labels("destA", PatchLabels(action="ADD", labels=[])) + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None @@ -2234,7 +1321,7 @@ def fill_instanceid(*args, **kwargs): kwargs["target"].instanceid = "my-instance-id" mock_read.side_effect = fill_instanceid - client = DestinationClient(MagicMock()) + client = DestinationClient(Mock()) result = client.get_service_instance_id() @@ -2249,7 +1336,6 @@ def fill_instanceid(*args, **kwargs): @patch(_RESOLVER_PATCH, side_effect=RuntimeError("mount failed")) def test_raises_on_exception(self, _mock_read): - client = DestinationClient(MagicMock()) - + client = DestinationClient(Mock()) with pytest.raises(DestinationOperationError, match="Could not resolve destination instance ID from secrets"): client.get_service_instance_id() diff --git a/tests/destination/unit/test_fragment_client.py b/tests/destination/unit/test_fragment_client.py index f63cfd7b..a7de7269 100644 --- a/tests/destination/unit/test_fragment_client.py +++ b/tests/destination/unit/test_fragment_client.py @@ -1,10 +1,11 @@ """Unit tests for FragmentClient.""" import pytest -from unittest.mock import Mock, MagicMock +from unittest.mock import Mock from requests import Response from sap_cloud_sdk.destination.fragment_client import FragmentClient +from sap_cloud_sdk.core._http_client import HttpMethod from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Label, Level, PatchLabels from sap_cloud_sdk.destination.exceptions import ( DestinationOperationError, @@ -12,967 +13,538 @@ ) +def _make_response(status=200, json_data=None, text="", headers=None): + resp = Mock(spec=Response) + resp.status_code = status + resp.text = text + resp.headers = headers or {} + if json_data is not None: + resp.json.return_value = json_data + return resp + + @pytest.fixture def mock_http(): - """Create a mock DestinationHttp instance.""" - return Mock() + http = Mock() + http.request.return_value = _make_response(200) + return http @pytest.fixture def fragment_client(mock_http): - """Create a FragmentClient with mocked HTTP.""" return FragmentClient(http=mock_http) class TestFragmentClientInit: - """Tests for FragmentClient initialization.""" def test_init_with_http(self, mock_http): - """Test FragmentClient initialization with HTTP transport.""" client = FragmentClient(http=mock_http) assert client._http is mock_http class TestFragmentClientRead: - """Tests for FragmentClient read operations.""" def test_get_instance_fragment_success(self, fragment_client, mock_http): - """Test successful retrieval of instance fragment.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = { + mock_http.request.return_value = _make_response(200, { "FragmentName": "test-fragment", "URL": "https://api.example.com", - "Authentication": "OAuth2ClientCredentials" - } - mock_http.get.return_value = mock_response - - # Execute + "Authentication": "OAuth2ClientCredentials", + }) fragment = fragment_client.get_instance_fragment("test-fragment") - - # Verify assert fragment is not None assert fragment.name == "test-fragment" assert fragment.properties["URL"] == "https://api.example.com" - assert fragment.properties["Authentication"] == "OAuth2ClientCredentials" - mock_http.get.assert_called_once_with("v1/instanceDestinationFragments/test-fragment", tenant_subdomain=None) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.GET + assert args[1] == "/v1/instanceDestinationFragments/test-fragment" + assert kwargs["tenant_subdomain"] is None def test_get_subaccount_fragment_success(self, fragment_client, mock_http): - """Test successful retrieval of subaccount fragment.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = { + mock_http.request.return_value = _make_response(200, { "FragmentName": "test-fragment", - "ProxyType": "Internet" - } - mock_http.get.return_value = mock_response - - # Execute + "ProxyType": "Internet", + }) fragment = fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.PROVIDER_ONLY) - - # Verify assert fragment is not None - assert fragment.name == "test-fragment" assert fragment.properties["ProxyType"] == "Internet" - mock_http.get.assert_called_once_with("v1/subaccountDestinationFragments/test-fragment", tenant_subdomain=None) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinationFragments/test-fragment" + assert kwargs["tenant_subdomain"] is None def test_get_fragment_not_found(self, fragment_client, mock_http): - """Test fragment retrieval when fragment doesn't exist (404).""" - # Setup mock to raise 404 - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = http_error - - # Execute + mock_http.request.return_value = _make_response(404, text="Not Found") fragment = fragment_client.get_instance_fragment("nonexistent") - - # Verify assert fragment is None def test_get_fragment_http_error(self, fragment_client, mock_http): - """Test fragment retrieval with HTTP error (non-404).""" - # Setup mock to raise 500 - http_error = HttpError("Internal Server Error") - http_error.status_code = 500 - mock_http.get.side_effect = http_error - - # Execute & Verify + mock_http.request.return_value = _make_response(500, text="Internal Server Error") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.get_instance_fragment("test-fragment") - assert "failed to get fragment 'test-fragment'" in str(exc_info.value) def test_get_fragment_invalid_json(self, fragment_client, mock_http): - """Test fragment retrieval with invalid JSON response.""" - # Setup mock response with invalid JSON - mock_response = Mock(spec=Response) - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_http.get.return_value = mock_response - - # Execute & Verify + resp = _make_response(200) + resp.json.side_effect = ValueError("Invalid JSON") + mock_http.request.return_value = resp with pytest.raises(DestinationOperationError) as exc_info: fragment_client.get_instance_fragment("test-fragment") - assert "invalid JSON in get fragment response" in str(exc_info.value) def test_get_subaccount_fragment_access_strategies(self, fragment_client, mock_http): - """Test subaccount fragment retrieval with different access strategies.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = { - "FragmentName": "test-fragment", - "ProxyType": "Internet" - } - mock_http.get.return_value = mock_response - - # Test PROVIDER_ONLY + mock_http.request.return_value = _make_response(200, {"FragmentName": "test-fragment", "ProxyType": "Internet"}) fragment = fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.PROVIDER_ONLY) assert fragment is not None - mock_http.get.assert_called_with("v1/subaccountDestinationFragments/test-fragment", tenant_subdomain=None) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None - # Reset mock mock_http.reset_mock() - - # Test SUBSCRIBER_ONLY with tenant + mock_http.request.return_value = _make_response(200, {"FragmentName": "test-fragment", "ProxyType": "Internet"}) fragment = fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="test-tenant") assert fragment is not None - mock_http.get.assert_called_with("v1/subaccountDestinationFragments/test-fragment", tenant_subdomain="test-tenant") + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_get_subaccount_fragment_requires_tenant_for_subscriber_access(self, fragment_client, mock_http): - """Test that subscriber access strategies require tenant parameter.""" - # Test SUBSCRIBER_ONLY without tenant with pytest.raises(DestinationOperationError) as exc_info: fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.SUBSCRIBER_ONLY) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) - # Test SUBSCRIBER_FIRST without tenant with pytest.raises(DestinationOperationError) as exc_info: fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.SUBSCRIBER_FIRST) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) - # Test PROVIDER_FIRST without tenant with pytest.raises(DestinationOperationError) as exc_info: fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.PROVIDER_FIRST) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) def test_get_subaccount_fragment_fallback_strategies(self, fragment_client, mock_http): - """Test fallback behavior for SUBSCRIBER_FIRST and PROVIDER_FIRST strategies.""" - # Setup mock to return None for first call, fragment for second call - mock_response = Mock(spec=Response) - mock_response.json.return_value = { - "FragmentName": "test-fragment", - "ProxyType": "Internet" - } - - # Test SUBSCRIBER_FIRST fallback (subscriber fails, provider succeeds) - mock_http.get.side_effect = [ - HttpError("Not Found", status_code=404), # Subscriber call fails - mock_response # Provider call succeeds + mock_http.request.side_effect = [ + _make_response(404, text="Not Found"), + _make_response(200, {"FragmentName": "test-fragment", "ProxyType": "Internet"}), ] - fragment = fragment_client.get_subaccount_fragment( "test-fragment", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" + tenant="test-tenant", ) assert fragment is not None - assert mock_http.get.call_count == 2 - - # Verify calls were made in correct order - calls = mock_http.get.call_args_list - assert calls[0] == (("v1/subaccountDestinationFragments/test-fragment",), {"tenant_subdomain": "test-tenant"}) - assert calls[1] == (("v1/subaccountDestinationFragments/test-fragment",), {"tenant_subdomain": None}) + assert mock_http.request.call_count == 2 + calls = mock_http.request.call_args_list + assert calls[0][1]["tenant_subdomain"] == "test-tenant" + assert calls[1][1]["tenant_subdomain"] is None class TestFragmentClientWrite: - """Tests for FragmentClient write operations.""" def test_create_fragment_subaccount(self, fragment_client, mock_http): - """Test creating a fragment at subaccount level.""" - # Setup - fragment = Fragment( - name="new-fragment", - properties={"URL": "https://api.example.com"} - ) - - # Execute + fragment = Fragment(name="new-fragment", properties={"URL": "https://api.example.com"}) fragment_client.create_fragment(fragment, level=Level.SUB_ACCOUNT) - - # Verify - mock_http.post.assert_called_once() - call_args = mock_http.post.call_args - assert call_args[0][0] == "v1/subaccountDestinationFragments" - assert call_args[1]["body"]["FragmentName"] == "new-fragment" - assert call_args[1]["body"]["URL"] == "https://api.example.com" + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.POST + assert args[1] == "/v1/subaccountDestinationFragments" + assert kwargs["json"]["FragmentName"] == "new-fragment" + assert kwargs["json"]["URL"] == "https://api.example.com" def test_create_fragment_instance(self, fragment_client, mock_http): - """Test creating a fragment at instance level.""" - # Setup - fragment = Fragment( - name="new-fragment", - properties={"ProxyType": "Internet"} - ) - - # Execute + fragment = Fragment(name="new-fragment", properties={"ProxyType": "Internet"}) fragment_client.create_fragment(fragment, level=Level.SERVICE_INSTANCE) - - # Verify - mock_http.post.assert_called_once() - call_args = mock_http.post.call_args - assert call_args[0][0] == "v1/instanceDestinationFragments" + args, _ = mock_http.request.call_args + assert args[1] == "/v1/instanceDestinationFragments" def test_create_fragment_with_tenant(self, fragment_client, mock_http): - """Test creating a fragment with a subscriber tenant.""" fragment = Fragment(name="new-fragment", properties={"URL": "https://api.example.com"}) - fragment_client.create_fragment(fragment, level=Level.SUB_ACCOUNT, tenant="test-tenant") - - call_args = mock_http.post.call_args - assert call_args[1]["tenant_subdomain"] == "test-tenant" + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_create_fragment_without_tenant_uses_provider_context(self, fragment_client, mock_http): - """Test creating a fragment without tenant uses provider context (tenant_subdomain=None).""" fragment = Fragment(name="new-fragment", properties={}) - fragment_client.create_fragment(fragment) - - call_args = mock_http.post.call_args - assert call_args[1]["tenant_subdomain"] is None + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_create_fragment_http_error(self, fragment_client, mock_http): - """Test create fragment with HTTP error.""" - # Setup + mock_http.request.return_value = _make_response(409, text="Conflict") fragment = Fragment(name="test-fragment", properties={}) - mock_http.post.side_effect = HttpError("Conflict") - - # Execute & Verify with pytest.raises(HttpError): fragment_client.create_fragment(fragment) def test_update_fragment_success(self, fragment_client, mock_http): - """Test updating a fragment.""" - # Setup - fragment = Fragment( - name="existing-fragment", - properties={"URL": "https://updated.example.com"} - ) - - # Execute + fragment = Fragment(name="existing-fragment", properties={"URL": "https://updated.example.com"}) fragment_client.update_fragment(fragment, level=Level.SUB_ACCOUNT) - - # Verify - mock_http.put.assert_called_once() - call_args = mock_http.put.call_args - assert call_args[0][0] == "v1/subaccountDestinationFragments" - assert call_args[1]["body"]["FragmentName"] == "existing-fragment" + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PUT + assert args[1] == "/v1/subaccountDestinationFragments" + assert kwargs["json"]["FragmentName"] == "existing-fragment" def test_update_fragment_with_tenant(self, fragment_client, mock_http): - """Test updating a fragment with a subscriber tenant.""" fragment = Fragment(name="existing-fragment", properties={"URL": "https://updated.example.com"}) - fragment_client.update_fragment(fragment, level=Level.SUB_ACCOUNT, tenant="test-tenant") - - call_args = mock_http.put.call_args - assert call_args[1]["tenant_subdomain"] == "test-tenant" + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_update_fragment_without_tenant_uses_provider_context(self, fragment_client, mock_http): - """Test updating a fragment without tenant uses provider context (tenant_subdomain=None).""" fragment = Fragment(name="existing-fragment", properties={}) - fragment_client.update_fragment(fragment) - - call_args = mock_http.put.call_args - assert call_args[1]["tenant_subdomain"] is None + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_update_fragment_http_error(self, fragment_client, mock_http): - """Test update fragment with HTTP error.""" - # Setup + mock_http.request.return_value = _make_response(404, text="Not Found") fragment = Fragment(name="test-fragment", properties={}) - mock_http.put.side_effect = HttpError("Not Found") - - # Execute & Verify with pytest.raises(HttpError): fragment_client.update_fragment(fragment) def test_delete_fragment_with_tenant(self, fragment_client, mock_http): - """Test deleting a fragment with a subscriber tenant.""" fragment_client.delete_fragment("test-fragment", level=Level.SUB_ACCOUNT, tenant="test-tenant") - - mock_http.delete.assert_called_once_with( - "v1/subaccountDestinationFragments/test-fragment", tenant_subdomain="test-tenant" - ) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.DELETE + assert args[1] == "/v1/subaccountDestinationFragments/test-fragment" + assert kwargs["tenant_subdomain"] == "test-tenant" def test_delete_fragment_without_tenant_uses_provider_context(self, fragment_client, mock_http): - """Test deleting a fragment without tenant uses provider context (tenant_subdomain=None).""" fragment_client.delete_fragment("test-fragment") - - mock_http.delete.assert_called_once_with( - "v1/subaccountDestinationFragments/test-fragment", tenant_subdomain=None - ) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_delete_fragment_success(self, fragment_client, mock_http): - """Test deleting a fragment.""" - # Execute fragment_client.delete_fragment("test-fragment", level=Level.SUB_ACCOUNT) - - # Verify - mock_http.delete.assert_called_once_with( - "v1/subaccountDestinationFragments/test-fragment", tenant_subdomain=None - ) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinationFragments/test-fragment" def test_delete_fragment_instance_level(self, fragment_client, mock_http): - """Test deleting a fragment at instance level.""" - # Execute fragment_client.delete_fragment("test-fragment", level=Level.SERVICE_INSTANCE) - - # Verify - mock_http.delete.assert_called_once_with( - "v1/instanceDestinationFragments/test-fragment", tenant_subdomain=None - ) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/instanceDestinationFragments/test-fragment" def test_delete_fragment_http_error(self, fragment_client, mock_http): - """Test delete fragment with HTTP error.""" - # Setup - mock_http.delete.side_effect = HttpError("Not Found") - - # Execute & Verify + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(HttpError): fragment_client.delete_fragment("test-fragment") class TestFragmentClientHelpers: - """Tests for FragmentClient helper methods.""" def test_sub_path_for_level_instance(self): - """Test sub-path generation for instance level.""" path = FragmentClient._sub_path_for_level(Level.SERVICE_INSTANCE) assert path == "instanceDestinationFragments" def test_sub_path_for_level_subaccount(self): - """Test sub-path generation for subaccount level.""" path = FragmentClient._sub_path_for_level(Level.SUB_ACCOUNT) assert path == "subaccountDestinationFragments" class TestFragmentClientListOperations: - """Tests for FragmentClient list operations.""" def test_list_instance_fragments_success(self, fragment_client, mock_http): - """Test successful listing of instance fragments.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = [ + mock_http.request.return_value = _make_response(200, [ {"FragmentName": "frag1", "URL": "https://api1.example.com"}, - {"FragmentName": "frag2", "ProxyType": "Internet"} - ] - mock_http.get.return_value = mock_response - - # Execute + {"FragmentName": "frag2", "ProxyType": "Internet"}, + ]) fragments = fragment_client.list_instance_fragments() - - # Verify assert len(fragments) == 2 assert fragments[0].name == "frag1" - assert fragments[0].properties["URL"] == "https://api1.example.com" assert fragments[1].name == "frag2" - assert fragments[1].properties["ProxyType"] == "Internet" - mock_http.get.assert_called_once_with("v1/instanceDestinationFragments", tenant_subdomain=None, params={}) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/instanceDestinationFragments" + assert kwargs["tenant_subdomain"] is None def test_list_instance_fragments_empty(self, fragment_client, mock_http): - """Test listing instance fragments when none exist.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = [] - mock_http.get.return_value = mock_response - - # Execute + mock_http.request.return_value = _make_response(200, []) fragments = fragment_client.list_instance_fragments() - - # Verify assert fragments == [] - mock_http.get.assert_called_once() def test_list_instance_fragments_http_error(self, fragment_client, mock_http): - """Test listing instance fragments with HTTP error.""" - # Setup mock to raise error - mock_http.get.side_effect = HttpError("Internal Server Error") - - # Execute & Verify + mock_http.request.return_value = _make_response(500, text="Internal Server Error") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_instance_fragments() - assert "failed to list instance fragments" in str(exc_info.value) def test_list_instance_fragments_invalid_json(self, fragment_client, mock_http): - """Test listing instance fragments with invalid JSON response.""" - # Setup mock response with invalid format (not a list) - mock_response = Mock(spec=Response) - mock_response.json.return_value = {"error": "not a list"} - mock_http.get.return_value = mock_response - - # Execute & Verify + mock_http.request.return_value = _make_response(200, {"error": "not a list"}) with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_instance_fragments() - assert "expected list in response" in str(exc_info.value) def test_list_instance_fragments_with_tenant(self, fragment_client, mock_http): - """Test listing instance fragments with a tenant subdomain.""" - mock_response = Mock(spec=Response) - mock_response.json.return_value = [ - {"FragmentName": "frag1", "URL": "https://api1.example.com"} - ] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"FragmentName": "frag1", "URL": "https://api1.example.com"}]) fragments = fragment_client.list_instance_fragments(tenant="my-tenant") - assert len(fragments) == 1 - assert fragments[0].name == "frag1" - mock_http.get.assert_called_once_with( - "v1/instanceDestinationFragments", tenant_subdomain="my-tenant", params={} - ) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "my-tenant" def test_list_subaccount_fragments_provider_only(self, fragment_client, mock_http): - """Test listing subaccount fragments with PROVIDER_ONLY strategy.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = [ - {"FragmentName": "frag1", "URL": "https://api1.example.com"} - ] - mock_http.get.return_value = mock_response - - # Execute + mock_http.request.return_value = _make_response(200, [{"FragmentName": "frag1", "URL": "https://api1.example.com"}]) fragments = fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.PROVIDER_ONLY) - - # Verify assert len(fragments) == 1 - assert fragments[0].name == "frag1" - mock_http.get.assert_called_once_with("v1/subaccountDestinationFragments", tenant_subdomain=None, params={}) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] is None def test_list_subaccount_fragments_subscriber_only(self, fragment_client, mock_http): - """Test listing subaccount fragments with SUBSCRIBER_ONLY strategy.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = [ - {"FragmentName": "frag1", "URL": "https://api1.example.com"} - ] - mock_http.get.return_value = mock_response - - # Execute - fragments = fragment_client.list_subaccount_fragments( - access_strategy=AccessStrategy.SUBSCRIBER_ONLY, - tenant="test-tenant" - ) - - # Verify + mock_http.request.return_value = _make_response(200, [{"FragmentName": "frag1", "URL": "https://api1.example.com"}]) + fragments = fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.SUBSCRIBER_ONLY, tenant="test-tenant") assert len(fragments) == 1 - mock_http.get.assert_called_once_with("v1/subaccountDestinationFragments", tenant_subdomain="test-tenant", params={}) + _, kwargs = mock_http.request.call_args + assert kwargs["tenant_subdomain"] == "test-tenant" def test_list_subaccount_fragments_requires_tenant(self, fragment_client, mock_http): - """Test that subscriber access strategies require tenant parameter.""" - # Test SUBSCRIBER_ONLY without tenant with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.SUBSCRIBER_ONLY) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) - # Test SUBSCRIBER_FIRST without tenant with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.SUBSCRIBER_FIRST) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) - # Test PROVIDER_FIRST without tenant with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.PROVIDER_FIRST) assert "tenant subdomain must be provided for subscriber access" in str(exc_info.value) def test_list_subaccount_fragments_subscriber_first_fallback(self, fragment_client, mock_http): - """Test SUBSCRIBER_FIRST strategy with fallback to provider.""" - # Setup mock: subscriber returns empty, provider returns data - mock_response_empty = Mock(spec=Response) - mock_response_empty.json.return_value = [] - - mock_response_data = Mock(spec=Response) - mock_response_data.json.return_value = [ - {"FragmentName": "frag1", "URL": "https://api1.example.com"} + mock_http.request.side_effect = [ + _make_response(200, []), + _make_response(200, [{"FragmentName": "frag1", "URL": "https://api1.example.com"}]), ] - - mock_http.get.side_effect = [mock_response_empty, mock_response_data] - - # Execute - fragments = fragment_client.list_subaccount_fragments( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - - # Verify + fragments = fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert len(fragments) == 1 - assert fragments[0].name == "frag1" - assert mock_http.get.call_count == 2 - - # Verify calls were made in correct order - calls = mock_http.get.call_args_list - assert calls[0] == (("v1/subaccountDestinationFragments",), {"tenant_subdomain": "test-tenant", "params": {}}) - assert calls[1] == (("v1/subaccountDestinationFragments",), {"tenant_subdomain": None, "params": {}}) + assert mock_http.request.call_count == 2 + calls = mock_http.request.call_args_list + assert calls[0][1]["tenant_subdomain"] == "test-tenant" + assert calls[1][1]["tenant_subdomain"] is None def test_list_subaccount_fragments_provider_first_fallback(self, fragment_client, mock_http): - """Test PROVIDER_FIRST strategy with fallback to subscriber.""" - # Setup mock: provider returns empty, subscriber returns data - mock_response_empty = Mock(spec=Response) - mock_response_empty.json.return_value = [] - - mock_response_data = Mock(spec=Response) - mock_response_data.json.return_value = [ - {"FragmentName": "frag1", "URL": "https://api1.example.com"} + mock_http.request.side_effect = [ + _make_response(200, []), + _make_response(200, [{"FragmentName": "frag1", "URL": "https://api1.example.com"}]), ] - - mock_http.get.side_effect = [mock_response_empty, mock_response_data] - - # Execute - fragments = fragment_client.list_subaccount_fragments( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - - # Verify + fragments = fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert len(fragments) == 1 - assert mock_http.get.call_count == 2 - - # Verify calls were made in correct order - calls = mock_http.get.call_args_list - assert calls[0] == (("v1/subaccountDestinationFragments",), {"tenant_subdomain": None, "params": {}}) - assert calls[1] == (("v1/subaccountDestinationFragments",), {"tenant_subdomain": "test-tenant", "params": {}}) + assert mock_http.request.call_count == 2 + calls = mock_http.request.call_args_list + assert calls[0][1]["tenant_subdomain"] is None + assert calls[1][1]["tenant_subdomain"] == "test-tenant" def test_list_subaccount_fragments_http_error(self, fragment_client, mock_http): - """Test listing subaccount fragments with HTTP error.""" - # Setup mock to raise error - mock_http.get.side_effect = HttpError("Internal Server Error") - - # Execute & Verify + mock_http.request.return_value = _make_response(500, text="Internal Server Error") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.PROVIDER_ONLY) - assert "failed to list subaccount fragments" in str(exc_info.value) -class TestFragmentClientAccessStrategy: - """Tests for FragmentClient access strategy refactoring.""" - - def test_get_subaccount_fragment_uses_strategy_pattern(self, fragment_client, mock_http): - """Test that get_subaccount_fragment uses the new _apply_access_strategy method.""" - # Setup mock response - mock_response = Mock(spec=Response) - mock_response.json.return_value = {"FragmentName": "test-frag", "URL": "https://api.example.com"} - mock_http.get.return_value = mock_response - - # Execute - fragment = fragment_client.get_subaccount_fragment( - "test-frag", - access_strategy=AccessStrategy.PROVIDER_ONLY - ) - - # Verify - assert fragment is not None - assert fragment.name == "test-frag" - mock_http.get.assert_called_once() - - def test_get_subaccount_fragment_fallback_none_to_provider(self, fragment_client, mock_http): - """Test get with SUBSCRIBER_FIRST when subscriber returns None, fallback to provider.""" - # Setup mock: subscriber returns 404 (None), provider returns fragment - http_error = HttpError("Not Found") - http_error.status_code = 404 - - mock_response = Mock(spec=Response) - mock_response.json.return_value = {"FragmentName": "test-frag", "URL": "https://api.example.com"} - - mock_http.get.side_effect = [http_error, mock_response] - - # Execute - fragment = fragment_client.get_subaccount_fragment( - "test-frag", - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - - # Verify - assert fragment is not None - assert fragment.name == "test-frag" - assert mock_http.get.call_count == 2 - - class TestFragmentClientEdgeCases: - """Tests for edge cases and error handling.""" def test_create_fragment_unexpected_exception(self, fragment_client, mock_http): - """Test create fragment with unexpected exception (not HttpError).""" fragment = Fragment(name="test-fragment", properties={}) - mock_http.post.side_effect = RuntimeError("Unexpected error") - + mock_http.request.side_effect = RuntimeError("Unexpected error") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.create_fragment(fragment) - assert "failed to create fragment 'test-fragment'" in str(exc_info.value) assert "Unexpected error" in str(exc_info.value) def test_update_fragment_unexpected_exception(self, fragment_client, mock_http): - """Test update fragment with unexpected exception (not HttpError).""" fragment = Fragment(name="test-fragment", properties={}) - mock_http.put.side_effect = ValueError("Unexpected error") - + mock_http.request.side_effect = ValueError("Unexpected error") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.update_fragment(fragment) - assert "failed to update fragment 'test-fragment'" in str(exc_info.value) def test_delete_fragment_unexpected_exception(self, fragment_client, mock_http): - """Test delete fragment with unexpected exception (not HttpError).""" - mock_http.delete.side_effect = ConnectionError("Network error") - + mock_http.request.side_effect = ConnectionError("Network error") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.delete_fragment("test-fragment") - assert "failed to delete fragment 'test-fragment'" in str(exc_info.value) def test_apply_access_strategy_unknown_strategy(self, fragment_client, mock_http): - """Test _apply_access_strategy with unknown strategy.""" - from unittest.mock import Mock as MockStrategy - - unknown_strategy = MockStrategy() + unknown_strategy = Mock() unknown_strategy.value = "UNKNOWN" - - def fetch_func(tenant): - return None - with pytest.raises(DestinationOperationError) as exc_info: fragment_client._apply_access_strategy( access_strategy=unknown_strategy, tenant="test-tenant", - fetch_func=fetch_func, - empty_value=None + fetch_func=lambda t: None, + empty_value=None, ) - assert "unknown access strategy" in str(exc_info.value).lower() def test_list_fragments_non_list_response_raises_specific_error(self, fragment_client, mock_http): - """Test that _list_fragments raises DestinationOperationError for non-list response.""" - mock_response = Mock(spec=Response) - mock_response.json.return_value = {"error": "not a list"} - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, {"error": "not a list"}) with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_instance_fragments() - assert "expected list in response" in str(exc_info.value) def test_list_fragments_json_parsing_error(self, fragment_client, mock_http): - """Test that JSON parsing errors are wrapped properly.""" - mock_response = Mock(spec=Response) - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_http.get.return_value = mock_response - + resp = _make_response(200) + resp.json.side_effect = ValueError("Invalid JSON") + mock_http.request.return_value = resp with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_instance_fragments() - assert "invalid JSON in list fragments response" in str(exc_info.value) def test_get_fragment_malformed_fragment_data(self, fragment_client, mock_http): - """Test get fragment with malformed Fragment data in response.""" - mock_response = Mock(spec=Response) - # Missing required FragmentName field - mock_response.json.return_value = {"URL": "https://api.example.com"} - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, {"URL": "https://api.example.com"}) with pytest.raises(DestinationOperationError) as exc_info: fragment_client.get_instance_fragment("test-fragment") - assert "invalid JSON in get fragment response" in str(exc_info.value) def test_list_subaccount_fragments_both_empty_subscriber_first(self, fragment_client, mock_http): - """Test SUBSCRIBER_FIRST when both subscriber and provider return empty.""" - empty_response = Mock(spec=Response) - empty_response.json.return_value = [] - mock_http.get.return_value = empty_response - - fragments = fragment_client.list_subaccount_fragments( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(200, []) + fragments = fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert fragments == [] - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_list_subaccount_fragments_both_empty_provider_first(self, fragment_client, mock_http): - """Test PROVIDER_FIRST when both provider and subscriber return empty.""" - empty_response = Mock(spec=Response) - empty_response.json.return_value = [] - mock_http.get.return_value = empty_response - - fragments = fragment_client.list_subaccount_fragments( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(200, []) + fragments = fragment_client.list_subaccount_fragments(access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert fragments == [] - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_get_subaccount_fragment_provider_first_both_none(self, fragment_client, mock_http): - """Test PROVIDER_FIRST when both provider and subscriber return None.""" - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = http_error - - fragment = fragment_client.get_subaccount_fragment( - "test-fragment", - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(404, text="Not Found") + fragment = fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.PROVIDER_FIRST, tenant="test-tenant") assert fragment is None - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_get_subaccount_fragment_subscriber_first_both_none(self, fragment_client, mock_http): - """Test SUBSCRIBER_FIRST when both subscriber and provider return None.""" - http_error = HttpError("Not Found") - http_error.status_code = 404 - mock_http.get.side_effect = http_error - - fragment = fragment_client.get_subaccount_fragment( - "test-fragment", - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - + mock_http.request.return_value = _make_response(404, text="Not Found") + fragment = fragment_client.get_subaccount_fragment("test-fragment", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert fragment is None - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 def test_list_fragments_with_http_403_error(self, fragment_client, mock_http): - """Test list fragments with 403 Forbidden error.""" - http_error = HttpError("Forbidden") - http_error.status_code = 403 - mock_http.get.side_effect = http_error - + mock_http.request.return_value = _make_response(403, text="Forbidden") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_instance_fragments() - assert "failed to list instance fragments" in str(exc_info.value) def test_get_fragment_with_http_401_error(self, fragment_client, mock_http): - """Test get fragment with 401 Unauthorized error.""" - http_error = HttpError("Unauthorized") - http_error.status_code = 401 - mock_http.get.side_effect = http_error - + mock_http.request.return_value = _make_response(401, text="Unauthorized") with pytest.raises(DestinationOperationError) as exc_info: fragment_client.get_instance_fragment("test-fragment") - assert "failed to get fragment 'test-fragment'" in str(exc_info.value) def test_list_fragments_invalid_fragment_in_array(self, fragment_client, mock_http): - """Test list fragments with invalid fragment object in array.""" - mock_response = Mock(spec=Response) - # One valid, one invalid fragment - mock_response.json.return_value = [ + mock_http.request.return_value = _make_response(200, [ {"FragmentName": "frag1", "URL": "https://api.example.com"}, - {"URL": "https://api2.example.com"} # Invalid - missing FragmentName - ] - mock_http.get.return_value = mock_response - + {"URL": "https://api2.example.com"}, + ]) with pytest.raises(DestinationOperationError) as exc_info: fragment_client.list_instance_fragments() - - # The error bubbles up from Fragment.from_dict but gets caught and wrapped assert "fragment is missing required field" in str(exc_info.value) or "invalid JSON in list fragments response" in str(exc_info.value) - def test_apply_access_strategy_subscriber_first_no_fallback_with_data(self, fragment_client, mock_http): - """Test SUBSCRIBER_FIRST when subscriber returns data (no fallback needed).""" - mock_response = Mock(spec=Response) - mock_response.json.return_value = [{"FragmentName": "frag1", "URL": "https://api.example.com"}] - mock_http.get.return_value = mock_response - - fragments = fragment_client.list_subaccount_fragments( - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - - assert len(fragments) == 1 - assert mock_http.get.call_count == 1 # No fallback to provider - - def test_apply_access_strategy_provider_first_no_fallback_with_data(self, fragment_client, mock_http): - """Test PROVIDER_FIRST when provider returns data (no fallback needed).""" - mock_response = Mock(spec=Response) - mock_response.json.return_value = [{"FragmentName": "frag1", "URL": "https://api.example.com"}] - mock_http.get.return_value = mock_response - - fragments = fragment_client.list_subaccount_fragments( - access_strategy=AccessStrategy.PROVIDER_FIRST, - tenant="test-tenant" - ) - - assert len(fragments) == 1 - assert mock_http.get.call_count == 1 # No fallback to subscriber - - def test_get_fragment_with_none_empty_value_equality(self, fragment_client, mock_http): - """Test that None empty_value works correctly in access strategy.""" - http_error = HttpError("Not Found") - http_error.status_code = 404 - - mock_response = Mock(spec=Response) - mock_response.json.return_value = {"FragmentName": "test-frag", "URL": "https://api.example.com"} - - # First call returns None (404), second returns fragment - mock_http.get.side_effect = [http_error, mock_response] - - fragment = fragment_client.get_subaccount_fragment( - "test-frag", - access_strategy=AccessStrategy.SUBSCRIBER_FIRST, - tenant="test-tenant" - ) - + def test_get_subaccount_fragment_fallback_none_to_provider(self, fragment_client, mock_http): + mock_http.request.side_effect = [ + _make_response(404, text="Not Found"), + _make_response(200, {"FragmentName": "test-frag", "URL": "https://api.example.com"}), + ] + fragment = fragment_client.get_subaccount_fragment("test-frag", access_strategy=AccessStrategy.SUBSCRIBER_FIRST, tenant="test-tenant") assert fragment is not None assert fragment.name == "test-frag" - # Verify fallback occurred - assert mock_http.get.call_count == 2 + assert mock_http.request.call_count == 2 class TestFragmentClientLabels: - """Tests for FragmentClient label operations.""" def test_get_fragment_labels_instance(self, fragment_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = [{"key": "env", "values": ["prod"]}] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"key": "env", "values": ["prod"]}]) labels = fragment_client.get_fragment_labels("fragA", Level.SERVICE_INSTANCE) - assert len(labels) == 1 assert labels[0].key == "env" - assert labels[0].values == ["prod"] - mock_http.get.assert_called_once_with("v1/instanceDestinationFragments/fragA/labels", tenant_subdomain=None) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/instanceDestinationFragments/fragA/labels" + assert kwargs["tenant_subdomain"] is None def test_get_fragment_labels_subaccount(self, fragment_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = [{"key": "team", "values": ["platform"]}] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, [{"key": "team", "values": ["platform"]}]) labels = fragment_client.get_fragment_labels("fragA", Level.SUB_ACCOUNT) - assert labels[0].key == "team" - mock_http.get.assert_called_once_with("v1/subaccountDestinationFragments/fragA/labels", tenant_subdomain=None) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinationFragments/fragA/labels" def test_get_fragment_labels_default_level_is_subaccount(self, fragment_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = [] - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, []) fragment_client.get_fragment_labels("fragA") - - mock_http.get.assert_called_once_with("v1/subaccountDestinationFragments/fragA/labels", tenant_subdomain=None) + args, _ = mock_http.request.call_args + assert "subaccountDestinationFragments" in args[1] def test_get_fragment_labels_non_list_response_raises(self, fragment_client, mock_http): - mock_response = Mock(spec=Response) - mock_response.json.return_value = {"key": "env", "values": ["prod"]} - mock_http.get.return_value = mock_response - + mock_http.request.return_value = _make_response(200, {"key": "env", "values": ["prod"]}) with pytest.raises(DestinationOperationError): fragment_client.get_fragment_labels("fragA") def test_get_fragment_labels_http_error_raises_operation_error(self, fragment_client, mock_http): - mock_http.get.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(DestinationOperationError, match="failed to get labels for fragment"): fragment_client.get_fragment_labels("fragA") def test_update_fragment_labels_instance(self, fragment_client, mock_http): labels = [Label(key="env", values=["prod"])] - fragment_client.update_fragment_labels("fragA", labels, Level.SERVICE_INSTANCE) - - mock_http.put.assert_called_once_with( - "v1/instanceDestinationFragments/fragA/labels", - body=[{"key": "env", "values": ["prod"]}], - tenant_subdomain=None, - ) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PUT + assert args[1] == "/v1/instanceDestinationFragments/fragA/labels" + assert kwargs["json"] == [{"key": "env", "values": ["prod"]}] + assert kwargs["tenant_subdomain"] is None def test_update_fragment_labels_subaccount(self, fragment_client, mock_http): labels = [Label(key="env", values=["staging"])] - fragment_client.update_fragment_labels("fragA", labels, Level.SUB_ACCOUNT) - - mock_http.put.assert_called_once_with( - "v1/subaccountDestinationFragments/fragA/labels", - body=[{"key": "env", "values": ["staging"]}], - tenant_subdomain=None, - ) + args, kwargs = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinationFragments/fragA/labels" + assert kwargs["json"] == [{"key": "env", "values": ["staging"]}] def test_update_fragment_labels_http_error_propagates(self, fragment_client, mock_http): - mock_http.put.side_effect = HttpError("Conflict", status_code=409, response_text="Conflict") - + mock_http.request.return_value = _make_response(409, text="Conflict") with pytest.raises(HttpError): fragment_client.update_fragment_labels("fragA", [], Level.SUB_ACCOUNT) def test_patch_fragment_labels_instance(self, fragment_client, mock_http): patch = PatchLabels(action="ADD", labels=[Label(key="env", values=["prod"])]) - fragment_client.patch_fragment_labels("fragA", patch, Level.SERVICE_INSTANCE) - - mock_http.patch.assert_called_once_with( - "v1/instanceDestinationFragments/fragA/labels", - body={"action": "ADD", "labels": [{"key": "env", "values": ["prod"]}]}, - tenant_subdomain=None, - ) + args, kwargs = mock_http.request.call_args + assert args[0] == HttpMethod.PATCH + assert args[1] == "/v1/instanceDestinationFragments/fragA/labels" + assert kwargs["json"]["action"] == "ADD" + assert kwargs["tenant_subdomain"] is None def test_patch_fragment_labels_subaccount(self, fragment_client, mock_http): patch = PatchLabels(action="DELETE", labels=[Label(key="env", values=[])]) - fragment_client.patch_fragment_labels("fragA", patch, Level.SUB_ACCOUNT) - - mock_http.patch.assert_called_once_with( - "v1/subaccountDestinationFragments/fragA/labels", - body={"action": "DELETE", "labels": [{"key": "env", "values": []}]}, - tenant_subdomain=None, - ) + args, _ = mock_http.request.call_args + assert args[1] == "/v1/subaccountDestinationFragments/fragA/labels" def test_patch_fragment_labels_http_error_propagates(self, fragment_client, mock_http): - mock_http.patch.side_effect = HttpError("Not Found", status_code=404, response_text="Not Found") - + mock_http.request.return_value = _make_response(404, text="Not Found") with pytest.raises(HttpError): fragment_client.patch_fragment_labels("fragA", PatchLabels(action="ADD", labels=[]), Level.SUB_ACCOUNT) def test_get_fragment_labels_with_tenant(self, fragment_client, mock_http): - mock_http.get.return_value.json.return_value = [] - + mock_http.request.return_value = _make_response(200, []) fragment_client.get_fragment_labels("fragA", tenant="test-tenant") - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" def test_get_fragment_labels_without_tenant_uses_provider_context(self, fragment_client, mock_http): - mock_http.get.return_value.json.return_value = [] - + mock_http.request.return_value = _make_response(200, []) fragment_client.get_fragment_labels("fragA") - - _, kwargs = mock_http.get.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None def test_update_fragment_labels_with_tenant(self, fragment_client, mock_http): fragment_client.update_fragment_labels("fragA", [], tenant="test-tenant") - - _, kwargs = mock_http.put.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" def test_update_fragment_labels_without_tenant_uses_provider_context(self, fragment_client, mock_http): fragment_client.update_fragment_labels("fragA", []) - - _, kwargs = mock_http.put.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None def test_patch_fragment_labels_with_tenant(self, fragment_client, mock_http): fragment_client.patch_fragment_labels("fragA", PatchLabels(action="ADD", labels=[]), tenant="test-tenant") - - _, kwargs = mock_http.patch.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] == "test-tenant" def test_patch_fragment_labels_without_tenant_uses_provider_context(self, fragment_client, mock_http): fragment_client.patch_fragment_labels("fragA", PatchLabels(action="ADD", labels=[])) - - _, kwargs = mock_http.patch.call_args + _, kwargs = mock_http.request.call_args assert kwargs["tenant_subdomain"] is None diff --git a/tests/destination/unit/test_http.py b/tests/destination/unit/test_http.py index d78a92b9..7d06c671 100644 --- a/tests/destination/unit/test_http.py +++ b/tests/destination/unit/test_http.py @@ -1,217 +1,105 @@ -"""Unit tests for Destination HTTP utilities (TokenProvider, DestinationHttp).""" +"""Unit tests for _dest_request helper.""" import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from requests import Response from requests.exceptions import RequestException -from sap_cloud_sdk.destination._http import TokenProvider, DestinationHttp -from sap_cloud_sdk.destination.config import DestinationConfig +from sap_cloud_sdk.core._http_client import HttpClient, HttpMethod +from sap_cloud_sdk.destination._http import _request from sap_cloud_sdk.destination.exceptions import HttpError -class TestTokenProvider: +def _mock_http(status: int = 200, text: str = "") -> tuple[MagicMock, MagicMock]: + http = MagicMock(spec=HttpClient) + resp = MagicMock(spec=Response) + resp.status_code = status + resp.text = text + http.request.return_value = resp + return http, resp - @patch("sap_cloud_sdk.destination._http.OAuth2Session") - def test_fetch_token(self, mock_oauth): - mock_session = MagicMock() - mock_oauth.return_value = mock_session - mock_session.fetch_token.return_value = { - "access_token": "tok-1", - } - binding = DestinationConfig( - url="https://destination.example.com", - token_url="https://auth.example.com/oauth/token", - client_id="cid", - client_secret="csecret", - identityzone="provider-zone", - ) - provider = TokenProvider(binding) - - # First call should fetch - token1 = provider.get_token() - assert token1 == "tok-1" - mock_session.fetch_token.assert_called_once_with( - token_url="https://auth.example.com/oauth/token", - client_id="cid", - client_secret="csecret", - include_client_id=True, - ) - - # Second call should also fetch (no caching in simple implementation) - mock_session.fetch_token.reset_mock() - mock_session.fetch_token.return_value = {"access_token": "tok-1"} - token2 = provider.get_token() - assert token2 == "tok-1" - mock_session.fetch_token.assert_called_once_with( - token_url="https://auth.example.com/oauth/token", - client_id="cid", - client_secret="csecret", - include_client_id=True, - ) - - @patch("sap_cloud_sdk.destination._http.OAuth2Session") - def test_missing_access_token_raises(self, mock_oauth): - mock_session = MagicMock() - mock_oauth.return_value = mock_session - mock_session.fetch_token.return_value = { - # missing access_token - "expires_in": 3600, - } - - binding = DestinationConfig( - url="https://destination.example.com", - token_url="https://auth.example.com/oauth/token", - client_id="cid", - client_secret="csecret", - identityzone="provider-zone", - ) - provider = TokenProvider(binding) - - with pytest.raises(HttpError, match="missing access_token"): - provider.get_token() - - @patch("sap_cloud_sdk.destination._http.OAuth2Session") - def test_invalid_tenant_subdomain_raises_value_error(self, mock_oauth): - mock_session = MagicMock() - mock_oauth.return_value = mock_session - - binding = DestinationConfig( - url="https://destination.example.com", - token_url="https://provider-zone.authentication.region/oauth/token", - client_id="cid", - client_secret="csecret", - identityzone="provider-zone", - ) - provider = TokenProvider(binding) +class TestRequest: - with pytest.raises(ValueError, match="Invalid tenant_subdomain"): - provider.get_token(tenant_subdomain="-invalid") + def test_get_injects_accept_header_and_normalizes_path(self): + http, resp = _mock_http(200) - with pytest.raises(ValueError, match="Invalid tenant_subdomain"): - provider.get_token(tenant_subdomain="has.dot") + result = _request(http, HttpMethod.GET, "v1/instanceDestinations/my-dest", tenant_subdomain="tenant-1") - with pytest.raises(ValueError, match="Invalid tenant_subdomain"): - provider.get_token(tenant_subdomain="trailing-hyphen-") - - mock_session.fetch_token.assert_not_called() + assert result is resp + http.request.assert_called_once_with( + HttpMethod.GET, + "/v1/instanceDestinations/my-dest", + tenant_subdomain="tenant-1", + params=None, + json=None, + headers={"Accept": "application/json"}, + ) - @patch("sap_cloud_sdk.destination._http.OAuth2Session") - def test_tenant_subdomain_replaces_identityzone(self, mock_oauth): - mock_session = MagicMock() - mock_oauth.return_value = mock_session + def test_path_with_leading_slash_not_doubled(self): + http, _ = _mock_http(200) - # Return different tokens per call to verify per-URL caching - mock_session.fetch_token.side_effect = [ - {"access_token": "sub-token", "expires_in": 3600}, - {"access_token": "prov-token", "expires_in": 3600}, - ] + _request(http, HttpMethod.GET, "/v1/subaccountDestinations") - binding = DestinationConfig( - url="https://destination.example.com", - # Include identityzone in token_url for replacement - token_url="https://provider-zone.authentication.region/oauth/token", - client_id="cid", - client_secret="csecret", - identityzone="provider-zone", - ) - provider = TokenProvider(binding) - - # Subscriber context: replace 'provider-zone' with tenant subdomain - sub_token = provider.get_token(tenant_subdomain="tenant-123") - assert sub_token == "sub-token" - mock_session.fetch_token.assert_called_with( - token_url="https://tenant-123.authentication.region/oauth/token", - client_id="cid", - client_secret="csecret", - include_client_id=True, - ) + call_args = http.request.call_args + assert call_args[0][1] == "/v1/subaccountDestinations" - # Provider context: base token URL unchanged - prov_token = provider.get_token(tenant_subdomain=None) - assert prov_token == "prov-token" - mock_session.fetch_token.assert_called_with( - token_url="https://provider-zone.authentication.region/oauth/token", - client_id="cid", - client_secret="csecret", - include_client_id=True, - ) + def test_post_passes_json_body(self): + http, _ = _mock_http(201) + body = {"Name": "my-dest", "URL": "https://api.example.com"} + _request(http, HttpMethod.POST, "v1/subaccountDestinations", json=body) -class TestDestinationHttp: + call_args = http.request.call_args + assert call_args[1]["json"] == body - def _make_binding(self, base_url: str = "https://destination.example.com/") -> DestinationConfig: - return DestinationConfig( - url=base_url, - token_url="https://auth.example.com/oauth/token", - client_id="cid", - client_secret="csecret", - identityzone="provider-zone", - ) + def test_extra_headers_merged_with_accept(self): + http, _ = _mock_http(200) - def test_base_url_construction_trailing_slash_removed(self): - binding = self._make_binding("https://destination.example.com/") - tp = MagicMock() - http = DestinationHttp(config=binding, token_provider=tp, session=MagicMock()) - assert http.base_url == "https://destination.example.com/destination-configuration" + _request(http, HttpMethod.GET, "v1/foo", headers={"X-Custom": "val"}) - binding2 = self._make_binding("https://destination.example.com") - http2 = DestinationHttp(config=binding2, token_provider=tp, session=MagicMock()) - assert http2.base_url == "https://destination.example.com/destination-configuration" + call_args = http.request.call_args + assert call_args[1]["headers"] == {"Accept": "application/json", "X-Custom": "val"} - def test_get_success_and_auth_header(self): - binding = self._make_binding() - token_provider = MagicMock() - token_provider.get_token.return_value = "abc123" + def test_non_2xx_raises_http_error_with_status_and_text(self): + http, _ = _mock_http(404, "Not Found") - session = MagicMock() - resp = MagicMock(spec=Response) - resp.status_code = 200 - session.request.return_value = resp + with pytest.raises(HttpError) as exc: + _request(http, HttpMethod.GET, "instanceDestinations/unknown") - http = DestinationHttp(config=binding, token_provider=token_provider, session=session) - result = http.get("v1/instanceDestinations/my-dest", tenant_subdomain="tenant-1") + err = exc.value + assert err.status_code == 404 + assert "Not Found" in err.response_text # ty: ignore[unsupported-operator] + assert "HTTP 404 for GET" in str(err) - assert result is resp - # Verify request call and Authorization header passed - args, kwargs = session.request.call_args - assert kwargs["method"] == "GET" - assert "https://destination.example.com/destination-configuration/v1/instanceDestinations/my-dest" in kwargs["url"] - assert kwargs["headers"]["Authorization"] == "Bearer abc123" + def test_500_error_raises_http_error(self): + http, _ = _mock_http(500, "Internal Server Error") - token_provider.get_token.assert_called_once_with("tenant-1") + with pytest.raises(HttpError) as exc: + _request(http, HttpMethod.POST, "v1/subaccountDestinations", json={}) - def test_http_error_includes_status_and_text(self): - binding = self._make_binding() - token_provider = MagicMock() - token_provider.get_token.return_value = "abc123" + assert exc.value.status_code == 500 - session = MagicMock() - bad_resp = MagicMock(spec=Response) - bad_resp.status_code = 404 - bad_resp.text = "Not Found" - session.request.return_value = bad_resp + def test_request_exception_wrapped_in_http_error(self): + http = MagicMock(spec=HttpClient) + http.request.side_effect = RequestException("connection refused") - http = DestinationHttp(config=binding, token_provider=token_provider, session=session) + with pytest.raises(HttpError, match="request failed: connection refused"): + _request(http, HttpMethod.GET, "any/path") - with pytest.raises(HttpError) as exc: - http.get("instanceDestinations/unknown") + def test_tenant_subdomain_forwarded(self): + http, _ = _mock_http(200) - err = exc.value - assert err.status_code == 404 - assert "Not Found" in err.response_text # ty: ignore[unsupported-operator] - assert "HTTP 404 for GET" in str(err) + _request(http, HttpMethod.GET, "v1/foo", tenant_subdomain="subscriber-abc") - def test_request_network_error_wrapped(self): - binding = self._make_binding() - token_provider = MagicMock() - token_provider.get_token.return_value = "abc123" + call_args = http.request.call_args + assert call_args[1]["tenant_subdomain"] == "subscriber-abc" - session = MagicMock() - session.request.side_effect = RequestException("boom") + def test_params_forwarded(self): + http, _ = _mock_http(200) + params = {"$filter": "Name eq 'dest'"} - http = DestinationHttp(config=binding, token_provider=token_provider, session=session) + _request(http, HttpMethod.GET, "v1/subaccountDestinations", params=params) - with pytest.raises(HttpError, match="request failed: boom"): - http.get("any/path") + call_args = http.request.call_args + assert call_args[1]["params"] == params diff --git a/tests/destination/unit/test_init.py b/tests/destination/unit/test_init.py index 901d39de..1b5ba4d2 100644 --- a/tests/destination/unit/test_init.py +++ b/tests/destination/unit/test_init.py @@ -20,15 +20,15 @@ from sap_cloud_sdk.core.telemetry import Module _NO_MOCK_FILE = patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) +_BUILD_HTTP = "sap_cloud_sdk.destination._build_destination_http" class TestCreateClient: """Tests for create_client cloud mode.""" @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_client_with_explicit_config(self, mock_http, mock_token_provider): + @patch(_BUILD_HTTP) + def test_create_client_with_explicit_config(self, mock_build_http): config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -36,69 +36,49 @@ def test_create_client_with_explicit_config(self, mock_http, mock_token_provider client_secret="test-secret", identityzone="provider-zone" ) - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + mock_build_http.return_value = Mock() client = create_client(config=config) assert isinstance(client, DestinationClient) - mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) + mock_build_http.assert_called_once_with(None, config) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_client_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + @patch(_BUILD_HTTP) + def test_create_client_cloud_mode_default(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_client() assert isinstance(client, DestinationClient) - mock_load_config.assert_called_once_with(None) - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) + mock_build_http.assert_called_once_with(None, None) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_client_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + @patch(_BUILD_HTTP) + def test_create_client_cloud_mode_with_instance_name(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_client(instance="custom-instance") assert isinstance(client, DestinationClient) - mock_load_config.assert_called_once_with("custom-instance") + mock_build_http.assert_called_once_with("custom-instance", None) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - def test_create_client_config_error(self, mock_load_config): - mock_load_config.side_effect = Exception("Config loading failed") + @patch(_BUILD_HTTP) + def test_create_client_config_error(self, mock_build_http): + mock_build_http.side_effect = Exception("Config loading failed") with pytest.raises(ClientCreationError) as exc_info: create_client() assert "failed to create destination client" in str(exc_info.value) assert "Config loading failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - def test_create_client_token_provider_error(self, mock_token_provider, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_token_provider.side_effect = Exception("Token provider failed") + @patch(_BUILD_HTTP) + def test_create_client_token_provider_error(self, mock_build_http): + mock_build_http.side_effect = Exception("Token provider failed") with pytest.raises(ClientCreationError) as exc_info: create_client() assert "failed to create destination client" in str(exc_info.value) assert "Token provider failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_client_http_error(self, mock_http, mock_token_provider, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_token_provider.return_value = Mock() - mock_http.side_effect = Exception("HTTP client failed") + @patch(_BUILD_HTTP) + def test_create_client_http_error(self, mock_build_http): + mock_build_http.side_effect = Exception("HTTP client failed") with pytest.raises(ClientCreationError) as exc_info: create_client() assert "failed to create destination client" in str(exc_info.value) @@ -125,14 +105,10 @@ def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): assert "local" in mock_logger.warning.call_args[0][0].lower() assert "production" in mock_logger.warning.call_args[0][0].lower() - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch(_BUILD_HTTP) @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) - def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_tp.return_value = Mock() - mock_http.return_value = Mock() + def test_falls_through_to_cloud_when_no_mock_file(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_client() assert isinstance(client, DestinationClient) @@ -141,24 +117,16 @@ class TestCreateFragmentClient: """Tests for create_fragment_client cloud mode.""" @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_fragment_client_default(self, mock_http, mock_token_provider, mock_load_config): - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + @patch(_BUILD_HTTP) + def test_create_fragment_client_default(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_fragment_client() assert isinstance(client, FragmentClient) - mock_load_config.assert_called_once_with(None) - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) + mock_build_http.assert_called_once_with(None, None) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_fragment_client_with_explicit_config(self, mock_http, mock_token_provider): + @patch(_BUILD_HTTP) + def test_create_fragment_client_with_explicit_config(self, mock_build_http): config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -166,54 +134,41 @@ def test_create_fragment_client_with_explicit_config(self, mock_http, mock_token client_secret="test-secret", identityzone="provider-zone" ) - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + mock_build_http.return_value = Mock() client = create_fragment_client(config=config) assert isinstance(client, FragmentClient) - mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) + mock_build_http.assert_called_once_with(None, config) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_fragment_client_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + @patch(_BUILD_HTTP) + def test_create_fragment_client_with_instance_name(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_fragment_client(instance="custom-instance") assert isinstance(client, FragmentClient) - mock_load_config.assert_called_once_with("custom-instance") + mock_build_http.assert_called_once_with("custom-instance", None) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - def test_create_fragment_client_config_error(self, mock_load_config): - mock_load_config.side_effect = Exception("Config loading failed") + @patch(_BUILD_HTTP) + def test_create_fragment_client_config_error(self, mock_build_http): + mock_build_http.side_effect = Exception("Config loading failed") with pytest.raises(ClientCreationError) as exc_info: create_fragment_client() assert "failed to create fragment client" in str(exc_info.value) assert "Config loading failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - def test_create_fragment_client_token_provider_error(self, mock_token_provider, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_token_provider.side_effect = Exception("Token provider failed") + @patch(_BUILD_HTTP) + def test_create_fragment_client_token_provider_error(self, mock_build_http): + mock_build_http.side_effect = Exception("Token provider failed") with pytest.raises(ClientCreationError) as exc_info: create_fragment_client() assert "failed to create fragment client" in str(exc_info.value) assert "Token provider failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_fragment_client_http_error(self, mock_http, mock_token_provider, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_token_provider.return_value = Mock() - mock_http.side_effect = Exception("HTTP client failed") + @patch(_BUILD_HTTP) + def test_create_fragment_client_http_error(self, mock_build_http): + mock_build_http.side_effect = Exception("HTTP client failed") with pytest.raises(ClientCreationError) as exc_info: create_fragment_client() assert "failed to create fragment client" in str(exc_info.value) @@ -240,14 +195,10 @@ def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): assert "local" in mock_logger.warning.call_args[0][0].lower() assert "production" in mock_logger.warning.call_args[0][0].lower() - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch(_BUILD_HTTP) @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) - def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_tp.return_value = Mock() - mock_http.return_value = Mock() + def test_falls_through_to_cloud_when_no_mock_file(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_fragment_client() assert isinstance(client, FragmentClient) @@ -256,9 +207,8 @@ class TestCreateCertificateClient: """Tests for create_certificate_client cloud mode.""" @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_certificate_client_with_explicit_config(self, mock_http, mock_token_provider): + @patch(_BUILD_HTTP) + def test_create_certificate_client_with_explicit_config(self, mock_build_http): config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -266,69 +216,49 @@ def test_create_certificate_client_with_explicit_config(self, mock_http, mock_to client_secret="test-secret", identityzone="provider-zone" ) - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + mock_build_http.return_value = Mock() client = create_certificate_client(config=config) assert isinstance(client, CertificateClient) - mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) + mock_build_http.assert_called_once_with(None, config) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_certificate_client_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + @patch(_BUILD_HTTP) + def test_create_certificate_client_cloud_mode_default(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_certificate_client() assert isinstance(client, CertificateClient) - mock_load_config.assert_called_once_with(None) - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) + mock_build_http.assert_called_once_with(None, None) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_certificate_client_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_token_provider.return_value = Mock() - mock_http.return_value = Mock() + @patch(_BUILD_HTTP) + def test_create_certificate_client_cloud_mode_with_instance_name(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_certificate_client(instance="custom-instance") assert isinstance(client, CertificateClient) - mock_load_config.assert_called_once_with("custom-instance") + mock_build_http.assert_called_once_with("custom-instance", None) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - def test_create_certificate_client_config_error(self, mock_load_config): - mock_load_config.side_effect = Exception("Config loading failed") + @patch(_BUILD_HTTP) + def test_create_certificate_client_config_error(self, mock_build_http): + mock_build_http.side_effect = Exception("Config loading failed") with pytest.raises(ClientCreationError) as exc_info: create_certificate_client() assert "failed to create certificate client" in str(exc_info.value) assert "Config loading failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - def test_create_certificate_client_token_provider_error(self, mock_token_provider, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_token_provider.side_effect = Exception("Token provider failed") + @patch(_BUILD_HTTP) + def test_create_certificate_client_token_provider_error(self, mock_build_http): + mock_build_http.side_effect = Exception("Token provider failed") with pytest.raises(ClientCreationError) as exc_info: create_certificate_client() assert "failed to create certificate client" in str(exc_info.value) assert "Token provider failed" in str(exc_info.value) @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_create_certificate_client_http_error(self, mock_http, mock_token_provider, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_token_provider.return_value = Mock() - mock_http.side_effect = Exception("HTTP client failed") + @patch(_BUILD_HTTP) + def test_create_certificate_client_http_error(self, mock_build_http): + mock_build_http.side_effect = Exception("HTTP client failed") with pytest.raises(ClientCreationError) as exc_info: create_certificate_client() assert "failed to create certificate client" in str(exc_info.value) @@ -355,14 +285,10 @@ def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): assert "local" in mock_logger.warning.call_args[0][0].lower() assert "production" in mock_logger.warning.call_args[0][0].lower() - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch(_BUILD_HTTP) @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) - def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): - mock_load_config.return_value = Mock(spec=DestinationConfig) - mock_tp.return_value = Mock() - mock_http.return_value = Mock() + def test_falls_through_to_cloud_when_no_mock_file(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_certificate_client() assert isinstance(client, CertificateClient) @@ -371,19 +297,15 @@ class TestCreateClientTelemetrySource: """Verify _telemetry_source kwarg is stored on the client.""" @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_default_source_is_none(self, mock_http, mock_tp, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) + @patch(_BUILD_HTTP) + def test_default_source_is_none(self, mock_build_http): + mock_build_http.return_value = Mock() assert create_client()._telemetry_source is None @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_explicit_source_is_stored(self, mock_http, mock_tp, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) + @patch(_BUILD_HTTP) + def test_explicit_source_is_stored(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_client(_telemetry_source=Module.AGENTGATEWAY) assert client._telemetry_source is Module.AGENTGATEWAY @@ -392,19 +314,15 @@ class TestCreateFragmentClientTelemetrySource: """Verify _telemetry_source kwarg is stored on the fragment client.""" @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_default_source_is_none(self, mock_http, mock_tp, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) + @patch(_BUILD_HTTP) + def test_default_source_is_none(self, mock_build_http): + mock_build_http.return_value = Mock() assert create_fragment_client()._telemetry_source is None @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_explicit_source_is_stored(self, mock_http, mock_tp, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) + @patch(_BUILD_HTTP) + def test_explicit_source_is_stored(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_fragment_client(_telemetry_source=Module.AGENTGATEWAY) assert client._telemetry_source is Module.AGENTGATEWAY @@ -413,18 +331,14 @@ class TestCreateCertificateClientTelemetrySource: """Verify _telemetry_source kwarg is stored on the certificate client.""" @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_default_source_is_none(self, mock_http, mock_tp, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) + @patch(_BUILD_HTTP) + def test_default_source_is_none(self, mock_build_http): + mock_build_http.return_value = Mock() assert create_certificate_client()._telemetry_source is None @_NO_MOCK_FILE - @patch("sap_cloud_sdk.destination.load_from_env_or_mount") - @patch("sap_cloud_sdk.destination.TokenProvider") - @patch("sap_cloud_sdk.destination.DestinationHttp") - def test_explicit_source_is_stored(self, mock_http, mock_tp, mock_load_config): - mock_load_config.return_value = Mock(spec=DestinationConfig) + @patch(_BUILD_HTTP) + def test_explicit_source_is_stored(self, mock_build_http): + mock_build_http.return_value = Mock() client = create_certificate_client(_telemetry_source=Module.DATA_ANONYMIZATION) assert client._telemetry_source is Module.DATA_ANONYMIZATION