From 7e14b8ee16b9d82484145bc13d203d5c85564e72 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Tue, 31 Mar 2026 13:30:59 -0300 Subject: [PATCH 1/7] Add local clients for destination module --- .../destination/_local_client_base.py | 306 +++++++++++++++ .../destination/local_certificate_client.py | 233 ++++++++++++ src/sap_cloud_sdk/destination/local_client.py | 276 ++++++++++++++ .../destination/local_fragment_client.py | 223 +++++++++++ .../unit/test_local_certificate_client.py | 281 ++++++++++++++ .../unit/test_local_destination_client.py | 349 ++++++++++++++++++ .../unit/test_local_fragment_client.py | 279 ++++++++++++++ 7 files changed, 1947 insertions(+) create mode 100644 src/sap_cloud_sdk/destination/_local_client_base.py create mode 100644 src/sap_cloud_sdk/destination/local_certificate_client.py create mode 100644 src/sap_cloud_sdk/destination/local_client.py create mode 100644 src/sap_cloud_sdk/destination/local_fragment_client.py create mode 100644 tests/destination/unit/test_local_certificate_client.py create mode 100644 tests/destination/unit/test_local_destination_client.py create mode 100644 tests/destination/unit/test_local_fragment_client.py diff --git a/src/sap_cloud_sdk/destination/_local_client_base.py b/src/sap_cloud_sdk/destination/_local_client_base.py new file mode 100644 index 0000000..b090e8e --- /dev/null +++ b/src/sap_cloud_sdk/destination/_local_client_base.py @@ -0,0 +1,306 @@ +"""Base class for local development clients with common JSON file operations.""" + +from __future__ import annotations + +import json +import os +import threading +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, List, Optional, TypeVar, Generic + +from sap_cloud_sdk.destination._models import AccessStrategy +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + +T = TypeVar('T') + +_SUBSCRIBER_ACCESS_STRATEGIES = { + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, +} + +class LocalDevClientBase(ABC, Generic[T]): + """ + Base class for local development clients that manipulate JSON files. + + This class provides common functionality for: + - Thread-safe JSON file operations + - File initialization and management + - Common search and indexing operations + - Access-strategy resolution for subaccount-scoped reads + + Subclasses must implement: + - file_name: Return the JSON file name (e.g., "destinations.json") + - name_field: Return the field name used for entity names + - alt_name_field: Return the alternative name field (or None) + - from_dict(data): Convert dict to entity object + - to_dict(entity): Convert entity object to dict + """ + + def __init__(self) -> None: + # Resolve to repo root and mocks path + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + self._file_path = os.path.join(repo_root, "mocks", self.file_name) + self._lock = threading.Lock() + self._ensure_file() + + @property + @abstractmethod + def file_name(self) -> str: + """Return the JSON file name (e.g., 'destinations.json').""" + pass + + @property + @abstractmethod + def name_field(self) -> str: + """Return the primary name field for entities (e.g., 'name', 'FragmentName').""" + pass + + @property + @abstractmethod + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for entities (e.g., 'Name', 'fragmentName').""" + pass + + @abstractmethod + def from_dict(self, data: Dict[str, Any]) -> T: + """Convert dictionary to entity object.""" + pass + + @abstractmethod + def to_dict(self, entity: T) -> Dict[str, Any]: + """Convert entity object to dictionary.""" + pass + + def get_initial_data(self) -> Dict[str, Any]: + """Return initial data structure for empty files.""" + return {"subaccount": [], "instance": []} + + # ---------- File operations ---------- + + def _ensure_file(self) -> None: + """Ensure the JSON file exists with initial structure.""" + if not os.path.exists(self._file_path): + with self._lock: + if not os.path.exists(self._file_path): + self._safe_write(self.get_initial_data()) + + def _read(self) -> Dict[str, Any]: + """Read and parse the JSON file.""" + try: + with open(self._file_path, "r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + # Initialize if deleted between calls + self._ensure_file() + return self.get_initial_data() + except Exception as e: + raise DestinationOperationError(f"failed to read local store: {e}") + + def _write(self, data: Dict[str, Any]) -> None: + """Write data to the JSON file.""" + try: + self._safe_write(data) + except Exception as e: + raise DestinationOperationError(f"failed to write local store: {e}") + + def _safe_write(self, data: Dict[str, Any]) -> None: + """Atomically write data to the JSON file.""" + # Ensure directory exists + os.makedirs(os.path.dirname(self._file_path), exist_ok=True) + tmp_path = f"{self._file_path}.tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + os.replace(tmp_path, self._file_path) + + # ---------- Search operations ---------- + + def _resolve_name(self, item: Dict[str, Any]) -> Optional[str]: + """Resolve entity name from primary or alternative field.""" + name = item.get(self.name_field) + if not name and self.alt_name_field: + name = item.get(self.alt_name_field) + return name + + def _find_by_name(self, lst: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]: + """Find an item by name in a list.""" + for item in lst: + if self._resolve_name(item) == name: + return item + return None + + def _index_by_name(self, lst: List[Dict[str, Any]], name: str) -> int: + """Find the index of an item by name in a list.""" + for i, item in enumerate(lst): + if self._resolve_name(item) == name: + return i + return -1 + + def _find_by_name_and_no_tenant(self, lst: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]: + """Find an item by name that has no tenant field (provider context).""" + for item in lst: + if self._resolve_name(item) == name and not item.get("tenant"): + return item + return None + + def _find_by_name_and_tenant(self, lst: List[Dict[str, Any]], name: str, tenant: str) -> Optional[Dict[str, Any]]: + """Find an item by name and tenant (subscriber context).""" + for item in lst: + if self._resolve_name(item) == name and item.get("tenant") == tenant: + return item + return None + + def _index_by_name_and_no_tenant(self, lst: List[Dict[str, Any]], name: str) -> int: + """Find the index of an item by name that has no tenant field (provider context).""" + for i, item in enumerate(lst): + if self._resolve_name(item) == name and not item.get("tenant"): + return i + return -1 + + # ---------- Access-strategy resolution ---------- + + def _validate_subscriber_access(self, access_strategy: AccessStrategy, tenant: Optional[str], entity_kind: str) -> None: + """Raise DestinationOperationError when tenant is required but missing.""" + if access_strategy in _SUBSCRIBER_ACCESS_STRATEGIES and tenant is None: + raise DestinationOperationError( + f"tenant subdomain must be provided for subscriber access. " + f"If you want to access provider {entity_kind} only, use AccessStrategy.PROVIDER_ONLY." + ) + + def _resolve_subaccount_entity( + self, + name: str, + access_strategy: AccessStrategy, + tenant: Optional[str], + sub_list: List[Dict[str, Any]], + ) -> Optional[T]: + """Resolve a single entity from the subaccount list using the given access strategy.""" + def find_subscriber() -> Optional[T]: + if tenant is None: + return None + entry = self._find_by_name_and_tenant(sub_list, name, tenant) + return self.from_dict(entry) if entry else None + + def find_provider() -> Optional[T]: + entry = self._find_by_name_and_no_tenant(sub_list, name) + return self.from_dict(entry) if entry else None + + order_map: Dict[AccessStrategy, tuple[Callable[[], Optional[T]], ...]] = { + AccessStrategy.SUBSCRIBER_ONLY: (find_subscriber,), + AccessStrategy.PROVIDER_ONLY: (find_provider,), + AccessStrategy.SUBSCRIBER_FIRST: (find_subscriber, find_provider), + AccessStrategy.PROVIDER_FIRST: (find_provider, find_subscriber), + } + + funcs = order_map.get(access_strategy) + if not funcs: + raise DestinationOperationError(f"unknown access strategy: {access_strategy}") + + for fn in funcs: + result = fn() + if result is not None: + return result + return None + + def _resolve_subaccount_list( + self, + access_strategy: AccessStrategy, + tenant: Optional[str], + sub_list: List[Dict[str, Any]], + ) -> List[T]: + """Resolve a list of entities from the subaccount list using the given access strategy.""" + def list_subscriber() -> List[T]: + if tenant is None: + return [] + return [self.from_dict(entry) for entry in sub_list if entry.get("tenant") == tenant] + + def list_provider() -> List[T]: + return [self.from_dict(entry) for entry in sub_list if not entry.get("tenant")] + + order_map: Dict[AccessStrategy, tuple[Callable[[], List[T]], ...]] = { + AccessStrategy.SUBSCRIBER_ONLY: (list_subscriber,), + AccessStrategy.PROVIDER_ONLY: (list_provider,), + AccessStrategy.SUBSCRIBER_FIRST: (list_subscriber, list_provider), + AccessStrategy.PROVIDER_FIRST: (list_provider, list_subscriber), + } + + funcs = order_map.get(access_strategy) + if not funcs: + raise DestinationOperationError(f"unknown access strategy: {access_strategy}") + + results = funcs[0]() + if not results and len(funcs) > 1: + results = funcs[1]() + return results + + # ---------- Common CRUD operations ---------- + + def _get_entity(self, collection: str, name: str) -> Optional[T]: + """Get an entity from a collection by name.""" + try: + data = self._read() + entry = self._find_by_name(data.get(collection, []), name) + return self.from_dict(entry) if entry else None + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get entity '{name}': {e}") + + def _create_entity(self, collection: str, entity: T, entity_name: str) -> None: + """Create an entity in a collection.""" + entry = self.to_dict(entity) + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + if self._find_by_name(lst, entity_name) is not None: + raise HttpError(f"entity '{entity_name}' already exists", status_code=409, response_text="Conflict") + + lst.append(entry) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to create entity '{entity_name}': {e}") + + def _update_entity(self, collection: str, entity: T, entity_name: str, preserve_fields: Optional[List[str]] = None) -> None: + """Update an entity in a collection.""" + updated = self.to_dict(entity) + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + idx = self._index_by_name(lst, entity_name) + if idx < 0: + raise HttpError(f"entity '{entity_name}' not found", status_code=404, response_text="Not Found") + + if preserve_fields: + existing = lst[idx] + for field in preserve_fields: + if field in existing and existing[field]: + updated[field] = existing[field] + + lst[idx] = updated + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to update entity '{entity_name}': {e}") + + def _delete_entity(self, collection: str, entity_name: str) -> None: + """Delete an entity from a collection.""" + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + idx = self._index_by_name(lst, entity_name) + if idx < 0: + raise HttpError(f"entity '{entity_name}' not found", status_code=404, response_text="Not Found") + + lst.pop(idx) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to delete entity '{entity_name}': {e}") diff --git a/src/sap_cloud_sdk/destination/local_certificate_client.py b/src/sap_cloud_sdk/destination/local_certificate_client.py new file mode 100644 index 0000000..a58838e --- /dev/null +++ b/src/sap_cloud_sdk/destination/local_certificate_client.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase +from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError + + +class LocalDevCertificateClient(LocalDevClientBase[Certificate]): + """ + Local development client that mocks CertificateClient by manipulating a JSON file. + + Backing store: + - Fixed JSON file at '/mocks/certificates.json'. + - Overrides via environment variables are not supported. + + JSON schema example (lower-cased keys): + { + "subaccount": [ + { + "Name": "cert1.pem", + "Content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...", + "Type": "PEM" + ...additional string properties... + } + ], + "instance": [ + { + "Name": "keystore.jks", + "Content": "base64-encoded-content", + "Type": "JKS" + ...additional string properties... + } + ] + } + + Semantics: + - get_instance_certificate(name) -> Optional[Certificate] + - get_subaccount_certificate(name) -> Optional[Certificate] + - create_certificate(certificate, level) -> None + Creates in 'instance' or 'subaccount'. Duplicate names raise HttpError(409). + - update_certificate(certificate, level) -> None + Updates by name in the selected collection. Missing raises HttpError(404). + - delete_certificate(name, level) -> None + Deletes by name in the selected collection. Missing raises HttpError(404). + """ + + # ---------- Base class implementation ---------- + + @property + def file_name(self) -> str: + """Return the JSON file name.""" + return "certificates.json" + + @property + def name_field(self) -> str: + """Return the primary name field for certificates.""" + return "Name" + + @property + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for certificates.""" + return "name" + + def from_dict(self, data: Dict[str, Any]) -> Certificate: + """Convert dictionary to Certificate object.""" + return Certificate.from_dict(data) + + def to_dict(self, entity: Certificate) -> Dict[str, Any]: + """Convert Certificate object to dictionary.""" + return entity.to_dict() + + # ---------- Public API ---------- + + def get_instance_certificate(self, name: str) -> Optional[Certificate]: + """Get a certificate from the service instance scope. + + Args: + name: Certificate name. + + Returns: + Certificate if found, otherwise None. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + return self._get_entity("instance", name) + + def get_subaccount_certificate( + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> Optional[Certificate]: + """Get a certificate from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: Fetch only from subscriber context (tenant required) + - PROVIDER_ONLY: Fetch only from provider context (no tenant required) + - SUBSCRIBER_FIRST: Try subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: Try provider first, fallback to subscriber (tenant required) + + Args: + name: Certificate name. + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + Certificate if found, otherwise None (after trying configured precedence). + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + on HTTP errors, or response parsing failures. + """ + self._validate_subscriber_access(access_strategy, tenant, "certificates") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_entity(name, access_strategy, tenant, sub_list) + except HttpError: + raise + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get certificate '{name}': {e}") + + def create_certificate(self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Create a certificate. + + Args: + certificate: Certificate entity to create. + level: Scope where the certificate should be created (subaccount by default). + + Raises: + HttpError: If a certificate with the same name already exists (409). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._create_entity(collection, certificate, certificate.name) + + def update_certificate(self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Update a certificate. + + Args: + certificate: Certificate entity with updated fields. + level: Scope where the certificate exists (subaccount by default). + + Raises: + HttpError: If the certificate is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._update_entity(collection, certificate, certificate.name) + + def delete_certificate(self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Delete a certificate. + + Args: + name: Certificate name. + level: Scope where the certificate exists (subaccount by default). + + Raises: + HttpError: If the certificate is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._delete_entity(collection, name) + + def list_instance_certificates( + self, + _filter: Optional[Any] = None + ) -> PagedResult[Certificate]: + """List all certificates from the service instance scope. + + Args: + filter: Optional ListCertificatesFilter (ignored in local dev mode). + + Returns: + PagedResult[Certificate] containing certificates and pagination info. + Pagination info is always None in local dev mode. + Returns empty items list if none found. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + try: + data = self._read() + items = [Certificate.from_dict(entry) for entry in data.get("instance", [])] + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list instance certificates: {e}") + + def list_subaccount_certificates( + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + _filter: Optional[Any] = None + ) -> PagedResult[Certificate]: + """List certificates from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: List only from subscriber context (tenant required) + - PROVIDER_ONLY: List only from provider context (no tenant required) + - SUBSCRIBER_FIRST: List from subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: List from provider first, fallback to subscriber (tenant required) + + Args: + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + filter: Optional ListCertificatesFilter (ignored in local dev mode). + + Returns: + PagedResult[Certificate] containing certificates and pagination info. + Pagination info is always None in local dev mode. + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "certificates") + try: + data = self._read() + sub_list = data.get("subaccount", []) + items = self._resolve_subaccount_list(access_strategy, tenant, sub_list) + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list subaccount certificates: {e}") diff --git a/src/sap_cloud_sdk/destination/local_client.py b/src/sap_cloud_sdk/destination/local_client.py new file mode 100644 index 0000000..9d9c3a9 --- /dev/null +++ b/src/sap_cloud_sdk/destination/local_client.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase +from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + +class LocalDevDestinationClient(LocalDevClientBase[Destination]): + """ + Local development client that mocks DestinationClient by manipulating a JSON file. + + Backing store: + - Fixed JSON file at '/mocks/destination.json'. + - Overrides via environment variables are not supported. + + JSON schema example (lower-cased keys, plus optional 'tenant' for subscriber entries): + { + "subaccount": [ + { + "tenant": "t1", # optional: subscriber-specific entry + "name": "destA", + "type": "HTTP", + "url": "https://example.com", + "proxyType": "Internet", + "authentication": "NoAuthentication", + "description": "Sample" + ...additional string properties... + } + ], + "instance": [ + { + "name": "destC", + "type": "HTTP", + "url": "https://provider.example.com" + ...additional string properties... + } + ] + } + + Semantics: + - get_instance_destination(name) -> Optional[Destination] + - get_subaccount_destination(name, access_strategy, tenant) -> Optional[Destination] + Access strategies match DestinationClient: + * SUBSCRIBER_ONLY: search subaccount entries with matching tenant + * PROVIDER_ONLY: search subaccount entries without 'tenant' + * SUBSCRIBER_FIRST: try subscriber (tenant required), then provider + * PROVIDER_FIRST: try provider, then subscriber (tenant required) + - create_destination(dest, level) -> None + Creates in 'instance' or provider 'subaccount' (no tenant). Duplicate names raise HttpError(409). + - update_destination(dest, level) -> None + Updates by name in the selected collection. Missing raises HttpError(404). + - delete_destination(name, level) -> None + Deletes by name in the selected collection. Missing raises HttpError(404). + """ + + # ---------- Base class implementation ---------- + + @property + def file_name(self) -> str: + """Return the JSON file name.""" + return "destination.json" + + @property + def name_field(self) -> str: + """Return the primary name field for destinations.""" + return "name" + + @property + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for destinations.""" + return "Name" + + def from_dict(self, data: Dict[str, Any]) -> Destination: + """Convert dictionary to Destination object.""" + return Destination.from_dict(data) + + def to_dict(self, entity: Destination) -> Dict[str, Any]: + """Convert Destination object to dictionary.""" + return entity.to_dict() + + # ---------- Read operations ---------- + + def get_instance_destination(self, name: str) -> Optional[Destination]: + """Get a destination from the service instance scope. + + Args: + name: Destination name. + + Returns: + Destination if found, otherwise None. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + return self._get_entity("instance", name) + + def get_subaccount_destination( + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> Optional[Destination]: + """Get a destination from the subaccount scope with an access strategy. + + Args: + name: Destination name. + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + Destination if found, otherwise None (after trying configured precedence). + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "destinations") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_entity(name, access_strategy, tenant, sub_list) + except HttpError: + raise + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get destination '{name}': {e}") + + # ---------- Write operations ---------- + + def create_destination(self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Create a destination. + + Args: + dest: Destination entity to create. + level: Scope where the destination should be created (subaccount by default). + + Raises: + HttpError: If a destination with the same name already exists (409). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + + if collection == "instance": + self._create_entity(collection, dest, dest.name) + else: + # Provider context only — no tenant field + entry = dest.to_dict() + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + if self._find_by_name_and_no_tenant(lst, dest.name) is not None: + raise HttpError(f"destination '{dest.name}' already exists", status_code=409, + response_text="Conflict") + lst.append(entry) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to create destination '{dest.name}': {e}") + + def update_destination(self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Update a destination. + + Args: + dest: Destination entity with updated fields. + level: Scope where the destination exists (subaccount by default). + + Raises: + HttpError: If the destination is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + if collection == "instance": + self._update_entity(collection, dest, dest.name) + else: + # Preserve tenant field for subaccount-level entries + self._update_entity(collection, dest, dest.name, preserve_fields=["tenant"]) + + def delete_destination(self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Delete a destination. + + Args: + name: Destination name. + level: Scope where the destination exists (subaccount by default). + + Raises: + HttpError: If the destination is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + + if collection == "instance": + self._delete_entity(collection, name) + else: + # Provider context only — no tenant field + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + idx = self._index_by_name_and_no_tenant(lst, name) + if idx < 0: + raise HttpError(f"destination '{name}' not found", status_code=404, response_text="Not Found") + lst.pop(idx) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to delete destination '{name}': {e}") + + def list_instance_destinations( + self, + _filter: Optional[Any] = None + ) -> PagedResult[Destination]: + """List all destinations from the service instance scope. + + Args: + filter: Optional ListDestinationsFilter (ignored in local dev mode). + + Returns: + PagedResult[Destination] containing destinations and pagination info. + Pagination info is always None in local dev mode. + Returns empty items list if no destinations are found. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + try: + data = self._read() + items = [Destination.from_dict(entry) for entry in data.get("instance", [])] + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list instance destinations: {e}") + + def list_subaccount_destinations( + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + _filter: Optional[Any] = None + ) -> PagedResult[Destination]: + """List destinations from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: List only from subscriber context (tenant required) + - PROVIDER_ONLY: List only from provider context (no tenant required) + - SUBSCRIBER_FIRST: List from subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: List from provider first, fallback to subscriber (tenant required) + + Args: + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + filter: Optional ListDestinationsFilter (ignored in local dev mode). + + Returns: + PagedResult[Destination] containing destinations and pagination info. + Pagination info is always None in local dev mode. + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "destinations") + try: + data = self._read() + sub_list = data.get("subaccount", []) + items = self._resolve_subaccount_list(access_strategy, tenant, sub_list) + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list subaccount destinations: {e}") diff --git a/src/sap_cloud_sdk/destination/local_fragment_client.py b/src/sap_cloud_sdk/destination/local_fragment_client.py new file mode 100644 index 0000000..0a61b31 --- /dev/null +++ b/src/sap_cloud_sdk/destination/local_fragment_client.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase +from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Level +from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError + + +class LocalDevFragmentClient(LocalDevClientBase[Fragment]): + """ + Local development client that mocks FragmentClient by manipulating a JSON file. + + Backing store: + - Fixed JSON file at '/mocks/fragments.json'. + - Overrides via environment variables are not supported. + + JSON schema example (lower-cased keys): + { + "subaccount": [ + { + "fragmentName": "fragmentA", + "URL": "https://example.com", + "ProxyType": "Internet", + "Authentication": "NoAuthentication", + "Description": "Sample fragment" + ...additional string properties... + } + ], + "instance": [ + { + "fragmentName": "fragmentC", + "URL": "https://provider.example.com", + "ProxyType": "Internet" + ...additional string properties... + } + ] + } + + Semantics: + - get_instance_fragment(name) -> Optional[Fragment] + - get_subaccount_fragment(name) -> Optional[Fragment] + - create_fragment(fragment, level) -> None + Creates in 'instance' or 'subaccount'. Duplicate names raise HttpError(409). + - update_fragment(fragment, level) -> None + Updates by name in the selected collection. Missing raises HttpError(404). + - delete_fragment(name, level) -> None + Deletes by name in the selected collection. Missing raises HttpError(404). + """ + + # ---------- Base class implementation ---------- + + @property + def file_name(self) -> str: + """Return the JSON file name.""" + return "fragments.json" + + @property + def name_field(self) -> str: + """Return the primary name field for fragments.""" + return "FragmentName" + + @property + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for fragments.""" + return "fragmentName" + + def from_dict(self, data: Dict[str, Any]) -> Fragment: + """Convert dictionary to Fragment object.""" + return Fragment.from_dict(data) + + def to_dict(self, entity: Fragment) -> Dict[str, Any]: + """Convert Fragment object to dictionary.""" + return entity.to_dict() + + # ---------- Read operations ---------- + + def get_instance_fragment(self, name: str) -> Optional[Fragment]: + """Get a fragment from the service instance scope. + + Args: + name: Fragment name. + + Returns: + Fragment if found, otherwise None. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + return self._get_entity("instance", name) + + def get_subaccount_fragment( + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> Optional[Fragment]: + """Get a fragment from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: Fetch only from subscriber context (tenant required) + - PROVIDER_ONLY: Fetch only from provider context (no tenant required) + - SUBSCRIBER_FIRST: Try subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: Try provider first, fallback to subscriber (tenant required) + + Args: + name: Fragment name. + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + Fragment if found, otherwise None (after trying configured precedence). + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + on HTTP errors, or response parsing failures. + """ + self._validate_subscriber_access(access_strategy, tenant, "fragments") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_entity(name, access_strategy, tenant, sub_list) + except HttpError: + raise + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get fragment '{name}': {e}") + + def list_instance_fragments(self) -> List[Fragment]: + """List all fragments from the service instance scope. + + Returns: + List of fragments. Returns empty list if no fragments exist. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + try: + data = self._read() + return [Fragment.from_dict(entry) for entry in data.get("instance", [])] + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list instance fragments: {e}") + + def list_subaccount_fragments( + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> List[Fragment]: + """List fragments from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: List only from subscriber context (tenant required) + - PROVIDER_ONLY: List only from provider context (no tenant required) + - SUBSCRIBER_FIRST: List from subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: List from provider first, fallback to subscriber (tenant required) + + Args: + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + List of fragments (after trying configured precedence). Returns empty list if no fragments exist. + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "fragments") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_list(access_strategy, tenant, sub_list) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list subaccount fragments: {e}") + + # ---------- Write operations ---------- + + def create_fragment(self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Create a fragment. + + Args: + fragment: Fragment entity to create. + level: Scope where the fragment should be created (subaccount by default). + + Raises: + HttpError: If a fragment with the same name already exists (409). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._create_entity(collection, fragment, fragment.name) + + def update_fragment(self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Update a fragment. + + Args: + fragment: Fragment entity with updated fields. + level: Scope where the fragment exists (subaccount by default). + + Raises: + HttpError: If the fragment is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._update_entity(collection, fragment, fragment.name) + + def delete_fragment(self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + """Delete a fragment. + + Args: + name: Fragment name. + level: Scope where the fragment exists (subaccount by default). + + Raises: + HttpError: If the fragment is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._delete_entity(collection, name) diff --git a/tests/destination/unit/test_local_certificate_client.py b/tests/destination/unit/test_local_certificate_client.py new file mode 100644 index 0000000..409d6d6 --- /dev/null +++ b/tests/destination/unit/test_local_certificate_client.py @@ -0,0 +1,281 @@ +"""Unit tests for LocalDevCertificateClient.""" + +import json + +import pytest + +from sap_cloud_sdk.destination.local_certificate_client import LocalDevCertificateClient +from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Create a LocalDevCertificateClient backed by a temp directory.""" + monkeypatch.setattr( + "sap_cloud_sdk.destination._local_client_base.os.path.abspath", + lambda _: str(tmp_path), + ) + return LocalDevCertificateClient() + + +def _store_path(tmp_path): + return tmp_path / "mocks" / "certificates.json" + + +def _write_store(client, data): + client._write(data) + + +class TestInit: + def test_creates_backing_file_on_init(self, client, tmp_path): + assert _store_path(tmp_path).exists() + + def test_initial_store_has_empty_collections(self, client, tmp_path): + data = json.loads(_store_path(tmp_path).read_text()) + assert data == {"subaccount": [], "instance": []} + + +class TestGetInstanceCertificate: + def test_returns_certificate_when_found(self, client): + _write_store(client, {"instance": [{"Name": "cert.pem", "Content": "abc", "Type": "PEM"}], "subaccount": []}) + result = client.get_instance_certificate("cert.pem") + assert result is not None + assert result.name == "cert.pem" + + def test_returns_none_when_not_found(self, client): + assert client.get_instance_certificate("nonexistent") is None + + def test_finds_by_alt_name_field(self, client): + # Certificates stored with lowercase "name" key (alt_name_field) + _write_store(client, {"instance": [{"name": "cert.pem", "Content": "abc"}], "subaccount": []}) + result = client.get_instance_certificate("cert.pem") + assert result is not None + assert result.name == "cert.pem" + + def test_ignores_subaccount_collection(self, client): + _write_store(client, { + "instance": [], + "subaccount": [{"Name": "sub-cert.pem", "Content": "abc"}], + }) + assert client.get_instance_certificate("sub-cert.pem") is None + + +class TestGetSubaccountCertificate: + def test_provider_only_returns_provider_certificate(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) + assert result is not None + assert result.name == "cert.pem" + + def test_provider_only_skips_subscriber_entries(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) is None + + def test_subscriber_only_returns_matching_tenant(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + + def test_subscriber_only_returns_none_for_wrong_tenant(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_certificate("cert.pem", AccessStrategy.SUBSCRIBER_ONLY, tenant="t2") is None + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result is not None + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + + def test_returns_none_when_not_found(self, client): + assert client.get_subaccount_certificate("ghost", AccessStrategy.PROVIDER_ONLY) is None + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.get_subaccount_certificate("cert.pem", strategy, tenant=None) + + def test_provider_only_does_not_require_tenant(self, client): + result = client.get_subaccount_certificate("nonexistent", AccessStrategy.PROVIDER_ONLY) + assert result is None + + +class TestListInstanceCertificates: + def test_returns_paged_result(self, client): + _write_store(client, {"instance": [ + {"Name": "cert1.pem", "Content": "c1"}, + {"Name": "cert2.jks", "Content": "c2"}, + ], "subaccount": []}) + result = client.list_instance_certificates() + assert isinstance(result, PagedResult) + assert len(result.items) == 2 + assert all(isinstance(c, Certificate) for c in result.items) + + def test_pagination_is_always_none(self, client): + assert client.list_instance_certificates().pagination is None + + def test_returns_empty_for_empty_store(self, client): + result = client.list_instance_certificates() + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + def test_filter_param_is_accepted_and_ignored(self, client): + _write_store(client, {"instance": [{"Name": "cert.pem", "Content": "c1"}], "subaccount": []}) + result = client.list_instance_certificates(_filter=object()) + assert len(result.items) == 1 + + def test_does_not_include_subaccount_entries(self, client): + _write_store(client, { + "instance": [{"Name": "inst.pem", "Content": "c1"}], + "subaccount": [{"Name": "sub.pem", "Content": "c2"}], + }) + result = client.list_instance_certificates() + assert len(result.items) == 1 + assert result.items[0].name == "inst.pem" + + +class TestListSubaccountCertificates: + def test_returns_paged_result(self, client): + result = client.list_subaccount_certificates(AccessStrategy.PROVIDER_ONLY) + assert isinstance(result, PagedResult) + assert result.pagination is None + + def test_provider_only_returns_only_provider_entries(self, client): + _write_store(client, {"subaccount": [ + {"Name": "prov.pem", "Content": "c1"}, + {"Name": "sub.pem", "Content": "c2", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.PROVIDER_ONLY) + assert len(result.items) == 1 + assert result.items[0].name == "prov.pem" + + def test_subscriber_only_returns_only_matching_tenant(self, client): + _write_store(client, {"subaccount": [ + {"Name": "prov.pem", "Content": "c1"}, + {"Name": "sub-t1.pem", "Content": "c2", "tenant": "t1"}, + {"Name": "sub-t2.pem", "Content": "c3", "tenant": "t2"}, + ], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub-t1.pem" + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"Name": "prov.pem", "Content": "c1"}], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result.items) == 1 + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"Name": "sub.pem", "Content": "c1", "tenant": "t1"}], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result.items) == 1 + + def test_both_empty_returns_empty_paged_result(self, client): + result = client.list_subaccount_certificates(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.list_subaccount_certificates(strategy, tenant=None) + + +class TestCreateCertificate: + def test_create_instance_certificate(self, client): + cert = Certificate(name="new.pem", content="c1", type="PEM") + client.create_certificate(cert, Level.SERVICE_INSTANCE) + assert client.get_instance_certificate("new.pem") is not None + + def test_create_subaccount_certificate(self, client): + cert = Certificate(name="new.pem", content="c1", type="PEM") + client.create_certificate(cert, Level.SUB_ACCOUNT) + result = client.get_subaccount_certificate("new.pem", AccessStrategy.PROVIDER_ONLY) + assert result is not None + + def test_default_level_is_subaccount(self, client): + client.create_certificate(Certificate(name="default.pem", content="c1")) + assert client.get_subaccount_certificate("default.pem", AccessStrategy.PROVIDER_ONLY) is not None + + def test_create_duplicate_instance_raises_409(self, client): + cert = Certificate(name="dup.pem", content="c1") + client.create_certificate(cert, Level.SERVICE_INSTANCE) + with pytest.raises(HttpError) as exc_info: + client.create_certificate(cert, Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 409 + + def test_create_duplicate_subaccount_raises_409(self, client): + cert = Certificate(name="dup.pem", content="c1") + client.create_certificate(cert, Level.SUB_ACCOUNT) + with pytest.raises(HttpError) as exc_info: + client.create_certificate(cert, Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 409 + + +class TestUpdateCertificate: + def test_update_instance_certificate(self, client): + client.create_certificate(Certificate(name="cert.pem", content="v1"), Level.SERVICE_INSTANCE) + client.update_certificate(Certificate(name="cert.pem", content="v2"), Level.SERVICE_INSTANCE) + result = client.get_instance_certificate("cert.pem") + assert result.content == "v2" + + def test_update_subaccount_certificate(self, client): + client.create_certificate(Certificate(name="cert.pem", content="v1"), Level.SUB_ACCOUNT) + client.update_certificate(Certificate(name="cert.pem", content="v2"), Level.SUB_ACCOUNT) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) + assert result.content == "v2" + + def test_default_level_is_subaccount(self, client): + client.create_certificate(Certificate(name="cert.pem", content="v1")) + client.update_certificate(Certificate(name="cert.pem", content="v2")) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) + assert result.content == "v2" + + def test_update_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_certificate(Certificate(name="ghost.pem", content="c"), Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_update_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_certificate(Certificate(name="ghost.pem", content="c"), Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + +class TestDeleteCertificate: + def test_delete_instance_certificate(self, client): + client.create_certificate(Certificate(name="del.pem", content="c"), Level.SERVICE_INSTANCE) + client.delete_certificate("del.pem", Level.SERVICE_INSTANCE) + assert client.get_instance_certificate("del.pem") is None + + def test_delete_subaccount_certificate(self, client): + client.create_certificate(Certificate(name="del.pem", content="c"), Level.SUB_ACCOUNT) + client.delete_certificate("del.pem", Level.SUB_ACCOUNT) + assert client.get_subaccount_certificate("del.pem", AccessStrategy.PROVIDER_ONLY) is None + + def test_default_level_is_subaccount(self, client): + client.create_certificate(Certificate(name="del.pem", content="c")) + client.delete_certificate("del.pem") + assert client.get_subaccount_certificate("del.pem", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_certificate("ghost.pem", Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_delete_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_certificate("ghost.pem", Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 diff --git a/tests/destination/unit/test_local_destination_client.py b/tests/destination/unit/test_local_destination_client.py new file mode 100644 index 0000000..298e24a --- /dev/null +++ b/tests/destination/unit/test_local_destination_client.py @@ -0,0 +1,349 @@ +"""Unit tests for LocalDevDestinationClient.""" + +import json + +import pytest + +from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient +from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Create a LocalDevDestinationClient backed by a temp directory.""" + monkeypatch.setattr( + "sap_cloud_sdk.destination._local_client_base.os.path.abspath", + lambda _: str(tmp_path), + ) + return LocalDevDestinationClient() + + +def _store_path(tmp_path): + return tmp_path / "mocks" / "destination.json" + + +def _write_store(client, data): + client._write(data) + + +class TestInit: + def test_creates_backing_file_on_init(self, client, tmp_path): + assert _store_path(tmp_path).exists() + + def test_initial_store_has_empty_collections(self, client, tmp_path): + data = json.loads(_store_path(tmp_path).read_text()) + assert data == {"subaccount": [], "instance": []} + + +class TestGetInstanceDestination: + def test_returns_destination_when_found(self, client): + _write_store(client, {"instance": [{"name": "destA", "type": "HTTP"}], "subaccount": []}) + result = client.get_instance_destination("destA") + assert result is not None + assert result.name == "destA" + + def test_returns_none_when_not_found(self, client): + assert client.get_instance_destination("nonexistent") is None + + def test_finds_by_alt_name_field(self, client): + _write_store(client, {"instance": [{"Name": "destB", "type": "HTTP"}], "subaccount": []}) + result = client.get_instance_destination("destB") + assert result is not None + assert result.name == "destB" + + def test_ignores_subaccount_collection(self, client): + _write_store(client, { + "instance": [], + "subaccount": [{"name": "sub-dest", "type": "HTTP"}], + }) + assert client.get_instance_destination("sub-dest") is None + + +class TestGetSubaccountDestination: + def test_provider_only_returns_provider_destination(self, client): + _write_store(client, {"subaccount": [{"name": "prov", "type": "HTTP"}], "instance": []}) + result = client.get_subaccount_destination("prov", AccessStrategy.PROVIDER_ONLY) + assert result is not None + assert result.name == "prov" + + def test_provider_only_skips_subscriber_entries(self, client): + _write_store(client, {"subaccount": [{"name": "prov", "type": "HTTP", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_destination("prov", AccessStrategy.PROVIDER_ONLY) is None + + def test_subscriber_only_returns_matching_tenant(self, client): + _write_store(client, {"subaccount": [{"name": "dest", "type": "HTTP", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + assert result.name == "dest" + + def test_subscriber_only_returns_none_for_wrong_tenant(self, client): + _write_store(client, {"subaccount": [{"name": "dest", "type": "HTTP", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t2") is None + + def test_subscriber_first_returns_subscriber_when_present(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP", "tenant": "t1"}, + {"name": "dest", "type": "HTTP"}, + ], "instance": []}) + result = client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + # Should prefer subscriber entry (has tenant) + assert result is not None + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"name": "prov", "type": "HTTP"}], "instance": []}) + result = client.get_subaccount_destination("prov", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result is not None + assert result.name == "prov" + + def test_provider_first_returns_provider_when_present(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP"}, + {"name": "dest", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"name": "sub", "type": "HTTP", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_destination("sub", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + assert result.name == "sub" + + def test_returns_none_when_not_found(self, client): + assert client.get_subaccount_destination("ghost", AccessStrategy.PROVIDER_ONLY) is None + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.get_subaccount_destination("d", strategy, tenant=None) + + def test_provider_only_does_not_require_tenant(self, client): + # Should not raise even without tenant + result = client.get_subaccount_destination("nonexistent", AccessStrategy.PROVIDER_ONLY) + assert result is None + + +class TestListInstanceDestinations: + def test_returns_paged_result(self, client): + _write_store(client, {"instance": [ + {"name": "d1", "type": "HTTP"}, + {"name": "d2", "type": "HTTP"}, + ], "subaccount": []}) + result = client.list_instance_destinations() + assert isinstance(result, PagedResult) + assert len(result.items) == 2 + assert all(isinstance(d, Destination) for d in result.items) + + def test_pagination_is_always_none(self, client): + result = client.list_instance_destinations() + assert result.pagination is None + + def test_returns_empty_for_empty_store(self, client): + result = client.list_instance_destinations() + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + def test_filter_param_is_accepted_and_ignored(self, client): + _write_store(client, {"instance": [{"name": "d1", "type": "HTTP"}], "subaccount": []}) + result = client.list_instance_destinations(_filter=object()) + assert len(result.items) == 1 + + def test_does_not_include_subaccount_entries(self, client): + _write_store(client, { + "instance": [{"name": "inst", "type": "HTTP"}], + "subaccount": [{"name": "sub", "type": "HTTP"}], + }) + result = client.list_instance_destinations() + assert len(result.items) == 1 + assert result.items[0].name == "inst" + + +class TestListSubaccountDestinations: + def test_returns_paged_result(self, client): + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_ONLY) + assert isinstance(result, PagedResult) + assert result.pagination is None + + def test_provider_only_returns_only_provider_entries(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + {"name": "sub", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_ONLY) + assert len(result.items) == 1 + assert result.items[0].name == "prov" + + def test_subscriber_only_returns_only_matching_tenant(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + {"name": "sub-t1", "type": "HTTP", "tenant": "t1"}, + {"name": "sub-t2", "type": "HTTP", "tenant": "t2"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub-t1" + + def test_subscriber_first_no_fallback_needed(self, client): + _write_store(client, {"subaccount": [ + {"name": "sub", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub" + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "prov" + + def test_provider_first_no_fallback_needed(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result.items) == 1 + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [ + {"name": "sub", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub" + + def test_both_empty_returns_empty_paged_result(self, client): + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.list_subaccount_destinations(strategy, tenant=None) + + +class TestCreateDestination: + def test_create_instance_destination(self, client): + client.create_destination(Destination(name="new", type="HTTP"), Level.SERVICE_INSTANCE) + assert client.get_instance_destination("new") is not None + + def test_create_subaccount_destination(self, client): + client.create_destination(Destination(name="new", type="HTTP"), Level.SUB_ACCOUNT) + result = client.get_subaccount_destination("new", AccessStrategy.PROVIDER_ONLY) + assert result is not None + + def test_default_level_is_subaccount(self, client): + client.create_destination(Destination(name="default", type="HTTP")) + assert client.get_subaccount_destination("default", AccessStrategy.PROVIDER_ONLY) is not None + + def test_create_duplicate_instance_raises_409(self, client): + dest = Destination(name="dup", type="HTTP") + client.create_destination(dest, Level.SERVICE_INSTANCE) + with pytest.raises(HttpError) as exc_info: + client.create_destination(dest, Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 409 + + def test_create_duplicate_subaccount_raises_409(self, client): + dest = Destination(name="dup", type="HTTP") + client.create_destination(dest, Level.SUB_ACCOUNT) + with pytest.raises(HttpError) as exc_info: + client.create_destination(dest, Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 409 + + def test_subaccount_entry_has_no_tenant_field(self, client): + client.create_destination(Destination(name="prov", type="HTTP"), Level.SUB_ACCOUNT) + # A subscriber-access read should not find it (no tenant attached) + assert client.get_subaccount_destination("prov", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") is None + # But provider-access should find it + assert client.get_subaccount_destination("prov", AccessStrategy.PROVIDER_ONLY) is not None + + +class TestUpdateDestination: + def test_update_instance_destination(self, client): + client.create_destination(Destination(name="dest", type="HTTP", description="v1"), Level.SERVICE_INSTANCE) + client.update_destination(Destination(name="dest", type="HTTP", description="v2"), Level.SERVICE_INSTANCE) + result = client.get_instance_destination("dest") + assert result.description == "v2" + + def test_update_subaccount_destination(self, client): + client.create_destination(Destination(name="dest", type="HTTP", description="v1"), Level.SUB_ACCOUNT) + client.update_destination(Destination(name="dest", type="HTTP", description="v2"), Level.SUB_ACCOUNT) + result = client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_ONLY) + assert result.description == "v2" + + def test_update_subaccount_preserves_tenant_field(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP", "tenant": "t1", "description": "old"}, + ], "instance": []}) + client.update_destination(Destination(name="dest", type="HTTP", description="new"), Level.SUB_ACCOUNT) + # Tenant was preserved — entry is still accessible as a subscriber destination + result = client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + assert result.description == "new" + + def test_default_level_is_subaccount(self, client): + client.create_destination(Destination(name="dest", type="HTTP", description="v1")) + client.update_destination(Destination(name="dest", type="HTTP", description="v2")) + result = client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_ONLY) + assert result.description == "v2" + + def test_update_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_destination(Destination(name="ghost", type="HTTP"), Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_update_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_destination(Destination(name="ghost", type="HTTP"), Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + +class TestDeleteDestination: + def test_delete_instance_destination(self, client): + client.create_destination(Destination(name="del", type="HTTP"), Level.SERVICE_INSTANCE) + client.delete_destination("del", Level.SERVICE_INSTANCE) + assert client.get_instance_destination("del") is None + + def test_delete_subaccount_provider_destination(self, client): + client.create_destination(Destination(name="del", type="HTTP"), Level.SUB_ACCOUNT) + client.delete_destination("del", Level.SUB_ACCOUNT) + assert client.get_subaccount_destination("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_default_level_is_subaccount(self, client): + client.create_destination(Destination(name="del", type="HTTP")) + client.delete_destination("del") + assert client.get_subaccount_destination("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_destination("ghost", Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_delete_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_destination("ghost", Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + def test_delete_subaccount_only_removes_provider_entry(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP", "tenant": "t1"}, # subscriber + {"name": "dest", "type": "HTTP"}, # provider + ], "instance": []}) + client.delete_destination("dest", Level.SUB_ACCOUNT) + # Subscriber entry must remain + assert client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") is not None + # Provider entry is gone + assert client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_ONLY) is None diff --git a/tests/destination/unit/test_local_fragment_client.py b/tests/destination/unit/test_local_fragment_client.py new file mode 100644 index 0000000..4724e09 --- /dev/null +++ b/tests/destination/unit/test_local_fragment_client.py @@ -0,0 +1,279 @@ +"""Unit tests for LocalDevFragmentClient.""" + +import json + +import pytest + +from sap_cloud_sdk.destination.local_fragment_client import LocalDevFragmentClient +from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Level +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Create a LocalDevFragmentClient backed by a temp directory.""" + monkeypatch.setattr( + "sap_cloud_sdk.destination._local_client_base.os.path.abspath", + lambda _: str(tmp_path), + ) + return LocalDevFragmentClient() + + +def _store_path(tmp_path): + return tmp_path / "mocks" / "fragments.json" + + +def _write_store(client, data): + client._write(data) + + +class TestInit: + def test_creates_backing_file_on_init(self, client, tmp_path): + assert _store_path(tmp_path).exists() + + def test_initial_store_has_empty_collections(self, client, tmp_path): + data = json.loads(_store_path(tmp_path).read_text()) + assert data == {"subaccount": [], "instance": []} + + +class TestGetInstanceFragment: + def test_returns_fragment_when_found(self, client): + _write_store(client, {"instance": [{"FragmentName": "fragA", "URL": "https://example.com"}], "subaccount": []}) + result = client.get_instance_fragment("fragA") + assert result is not None + assert result.name == "fragA" + + def test_returns_none_when_not_found(self, client): + assert client.get_instance_fragment("nonexistent") is None + + def test_finds_by_alt_name_field(self, client): + # Fragments can be stored with lowercase "fragmentName" (alt_name_field) + _write_store(client, {"instance": [{"fragmentName": "fragB", "URL": "https://example.com"}], "subaccount": []}) + result = client.get_instance_fragment("fragB") + assert result is not None + assert result.name == "fragB" + + def test_ignores_subaccount_collection(self, client): + _write_store(client, { + "instance": [], + "subaccount": [{"FragmentName": "sub-frag", "URL": "https://example.com"}], + }) + assert client.get_instance_fragment("sub-frag") is None + + def test_returns_fragment_properties(self, client): + _write_store(client, {"instance": [ + {"FragmentName": "fragA", "URL": "https://example.com", "Authentication": "NoAuthentication"}, + ], "subaccount": []}) + result = client.get_instance_fragment("fragA") + assert result.properties["URL"] == "https://example.com" + assert result.properties["Authentication"] == "NoAuthentication" + + +class TestGetSubaccountFragment: + def test_provider_only_returns_provider_fragment(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "prov", "URL": "https://example.com"}], "instance": []}) + result = client.get_subaccount_fragment("prov", AccessStrategy.PROVIDER_ONLY) + assert result is not None + assert result.name == "prov" + + def test_provider_only_skips_subscriber_entries(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "frag", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_fragment("frag", AccessStrategy.PROVIDER_ONLY) is None + + def test_subscriber_only_returns_matching_tenant(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "frag", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_fragment("frag", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + + def test_subscriber_only_returns_none_for_wrong_tenant(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "frag", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_fragment("frag", AccessStrategy.SUBSCRIBER_ONLY, tenant="t2") is None + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "prov", "URL": "https://x.com"}], "instance": []}) + result = client.get_subaccount_fragment("prov", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result is not None + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "sub", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_fragment("sub", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + + def test_returns_none_when_not_found(self, client): + assert client.get_subaccount_fragment("ghost", AccessStrategy.PROVIDER_ONLY) is None + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.get_subaccount_fragment("frag", strategy, tenant=None) + + def test_provider_only_does_not_require_tenant(self, client): + result = client.get_subaccount_fragment("nonexistent", AccessStrategy.PROVIDER_ONLY) + assert result is None + + +class TestListInstanceFragments: + def test_returns_list_of_fragments(self, client): + _write_store(client, {"instance": [ + {"FragmentName": "f1", "URL": "https://a.com"}, + {"FragmentName": "f2", "URL": "https://b.com"}, + ], "subaccount": []}) + result = client.list_instance_fragments() + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(f, Fragment) for f in result) + + def test_returns_empty_list_for_empty_store(self, client): + result = client.list_instance_fragments() + assert result == [] + + def test_does_not_include_subaccount_entries(self, client): + _write_store(client, { + "instance": [{"FragmentName": "inst", "URL": "https://a.com"}], + "subaccount": [{"FragmentName": "sub", "URL": "https://b.com"}], + }) + result = client.list_instance_fragments() + assert len(result) == 1 + assert result[0].name == "inst" + + +class TestListSubaccountFragments: + def test_returns_list_of_fragments(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "f1", "URL": "https://a.com"}], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.PROVIDER_ONLY) + assert isinstance(result, list) + assert len(result) == 1 + + def test_provider_only_returns_only_provider_entries(self, client): + _write_store(client, {"subaccount": [ + {"FragmentName": "prov", "URL": "https://a.com"}, + {"FragmentName": "sub", "URL": "https://b.com", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.PROVIDER_ONLY) + assert len(result) == 1 + assert result[0].name == "prov" + + def test_subscriber_only_returns_only_matching_tenant(self, client): + _write_store(client, {"subaccount": [ + {"FragmentName": "prov", "URL": "https://a.com"}, + {"FragmentName": "sub-t1", "URL": "https://b.com", "tenant": "t1"}, + {"FragmentName": "sub-t2", "URL": "https://c.com", "tenant": "t2"}, + ], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert len(result) == 1 + assert result[0].name == "sub-t1" + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "prov", "URL": "https://a.com"}], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result) == 1 + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "sub", "URL": "https://a.com", "tenant": "t1"}], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result) == 1 + + def test_both_empty_returns_empty_list(self, client): + result = client.list_subaccount_fragments(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result == [] + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.list_subaccount_fragments(strategy, tenant=None) + + +class TestCreateFragment: + def test_create_instance_fragment(self, client): + frag = Fragment(name="new", properties={"URL": "https://example.com"}) + client.create_fragment(frag, Level.SERVICE_INSTANCE) + assert client.get_instance_fragment("new") is not None + + def test_create_subaccount_fragment(self, client): + frag = Fragment(name="new", properties={"URL": "https://example.com"}) + client.create_fragment(frag, Level.SUB_ACCOUNT) + result = client.get_subaccount_fragment("new", AccessStrategy.PROVIDER_ONLY) + assert result is not None + + def test_default_level_is_subaccount(self, client): + client.create_fragment(Fragment(name="default", properties={})) + assert client.get_subaccount_fragment("default", AccessStrategy.PROVIDER_ONLY) is not None + + def test_create_duplicate_instance_raises_409(self, client): + frag = Fragment(name="dup", properties={}) + client.create_fragment(frag, Level.SERVICE_INSTANCE) + with pytest.raises(HttpError) as exc_info: + client.create_fragment(frag, Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 409 + + def test_create_duplicate_subaccount_raises_409(self, client): + frag = Fragment(name="dup", properties={}) + client.create_fragment(frag, Level.SUB_ACCOUNT) + with pytest.raises(HttpError) as exc_info: + client.create_fragment(frag, Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 409 + + +class TestUpdateFragment: + def test_update_instance_fragment(self, client): + client.create_fragment(Fragment(name="frag", properties={"URL": "https://old.com"}), Level.SERVICE_INSTANCE) + client.update_fragment(Fragment(name="frag", properties={"URL": "https://new.com"}), Level.SERVICE_INSTANCE) + result = client.get_instance_fragment("frag") + assert result.properties["URL"] == "https://new.com" + + def test_update_subaccount_fragment(self, client): + client.create_fragment(Fragment(name="frag", properties={"URL": "https://old.com"}), Level.SUB_ACCOUNT) + client.update_fragment(Fragment(name="frag", properties={"URL": "https://new.com"}), Level.SUB_ACCOUNT) + result = client.get_subaccount_fragment("frag", AccessStrategy.PROVIDER_ONLY) + assert result.properties["URL"] == "https://new.com" + + def test_default_level_is_subaccount(self, client): + client.create_fragment(Fragment(name="frag", properties={"URL": "https://old.com"})) + client.update_fragment(Fragment(name="frag", properties={"URL": "https://new.com"})) + result = client.get_subaccount_fragment("frag", AccessStrategy.PROVIDER_ONLY) + assert result.properties["URL"] == "https://new.com" + + def test_update_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_fragment(Fragment(name="ghost", properties={}), Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_update_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_fragment(Fragment(name="ghost", properties={}), Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + +class TestDeleteFragment: + def test_delete_instance_fragment(self, client): + client.create_fragment(Fragment(name="del", properties={}), Level.SERVICE_INSTANCE) + client.delete_fragment("del", Level.SERVICE_INSTANCE) + assert client.get_instance_fragment("del") is None + + def test_delete_subaccount_fragment(self, client): + client.create_fragment(Fragment(name="del", properties={}), Level.SUB_ACCOUNT) + client.delete_fragment("del", Level.SUB_ACCOUNT) + assert client.get_subaccount_fragment("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_default_level_is_subaccount(self, client): + client.create_fragment(Fragment(name="del", properties={})) + client.delete_fragment("del") + assert client.get_subaccount_fragment("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_fragment("ghost", Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_delete_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_fragment("ghost", Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 From 0808e87232e8d00e3a2fba8e136c49f2a697629b Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Tue, 31 Mar 2026 14:49:46 -0300 Subject: [PATCH 2/7] use local clients and update user guide --- src/sap_cloud_sdk/destination/__init__.py | 42 ++- .../destination/_local_client_base.py | 4 + .../destination/local_certificate_client.py | 4 +- src/sap_cloud_sdk/destination/local_client.py | 4 +- .../destination/local_fragment_client.py | 4 +- src/sap_cloud_sdk/destination/user-guide.md | 86 +++++ tests/destination/unit/test_init.py | 322 +++++++++--------- .../unit/test_local_certificate_client.py | 3 +- .../unit/test_local_destination_client.py | 3 +- .../unit/test_local_fragment_client.py | 3 +- 10 files changed, 303 insertions(+), 172 deletions(-) diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index 5b10c99..ade4aa1 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -22,6 +22,8 @@ from __future__ import annotations +import logging +import os from typing import Optional from sap_cloud_sdk.destination._models import ( @@ -46,6 +48,14 @@ from sap_cloud_sdk.destination.client import DestinationClient from sap_cloud_sdk.destination.fragment_client import FragmentClient from sap_cloud_sdk.destination.certificate_client import CertificateClient +from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient +from sap_cloud_sdk.destination.local_fragment_client import LocalDevFragmentClient +from sap_cloud_sdk.destination.local_certificate_client import LocalDevCertificateClient +from sap_cloud_sdk.destination._local_client_base import ( + DESTINATION_MOCK_FILE, + FRAGMENT_MOCK_FILE, + CERTIFICATE_MOCK_FILE, +) from sap_cloud_sdk.destination.exceptions import ( DestinationError, ClientCreationError, @@ -56,6 +66,15 @@ ) +logger = logging.getLogger(__name__) + + +def _mock_file(name: str) -> str: + """Return the absolute path to a mocks/ file relative to the repo root.""" + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + return os.path.join(repo_root, "mocks", name) + + def create_client( *, instance: Optional[str] = None, @@ -78,12 +97,19 @@ def create_client( Defaults to False. Returns: - DestinationClient or LocalDevDestinationProvider: Client implementing the Destination interface. + DestinationClient or LocalDevDestinationClient: Client implementing the Destination interface. Raises: ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: + if os.path.isfile(_mock_file(DESTINATION_MOCK_FILE)): + logger.warning( + "Local mock mode active: using LocalDevDestinationClient backed by mocks/destination.json. " + "This is intended for local development only and must not be used in production." + ) + return LocalDevDestinationClient() + # Cloud mode via secret resolver or explicit config binding = config or load_from_env_or_mount(instance) tp = TokenProvider(binding) @@ -118,6 +144,13 @@ def create_fragment_client( ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: + if os.path.isfile(_mock_file(FRAGMENT_MOCK_FILE)): + logger.warning( + "Local mock mode active: using LocalDevFragmentClient backed by mocks/fragments.json. " + "This is intended for local development only and must not be used in production." + ) + return LocalDevFragmentClient() + # Use provided config or load from environment/mount (cloud mode) binding = config or load_from_env_or_mount(instance) tp = TokenProvider(binding) @@ -152,6 +185,13 @@ def create_certificate_client( ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: + if os.path.isfile(_mock_file(CERTIFICATE_MOCK_FILE)): + logger.warning( + "Local mock mode active: using LocalDevCertificateClient backed by mocks/certificates.json. " + "This is intended for local development only and must not be used in production." + ) + return LocalDevCertificateClient() + # Use provided config or load from environment/mount (cloud mode) binding = config or load_from_env_or_mount(instance) tp = TokenProvider(binding) diff --git a/src/sap_cloud_sdk/destination/_local_client_base.py b/src/sap_cloud_sdk/destination/_local_client_base.py index b090e8e..963fba8 100644 --- a/src/sap_cloud_sdk/destination/_local_client_base.py +++ b/src/sap_cloud_sdk/destination/_local_client_base.py @@ -13,6 +13,10 @@ T = TypeVar('T') +DESTINATION_MOCK_FILE = "destination.json" +FRAGMENT_MOCK_FILE = "fragments.json" +CERTIFICATE_MOCK_FILE = "certificates.json" + _SUBSCRIBER_ACCESS_STRATEGIES = { AccessStrategy.SUBSCRIBER_ONLY, AccessStrategy.SUBSCRIBER_FIRST, diff --git a/src/sap_cloud_sdk/destination/local_certificate_client.py b/src/sap_cloud_sdk/destination/local_certificate_client.py index a58838e..e303bf5 100644 --- a/src/sap_cloud_sdk/destination/local_certificate_client.py +++ b/src/sap_cloud_sdk/destination/local_certificate_client.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional -from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase +from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase, CERTIFICATE_MOCK_FILE from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Level from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError @@ -52,7 +52,7 @@ class LocalDevCertificateClient(LocalDevClientBase[Certificate]): @property def file_name(self) -> str: """Return the JSON file name.""" - return "certificates.json" + return CERTIFICATE_MOCK_FILE @property def name_field(self) -> str: diff --git a/src/sap_cloud_sdk/destination/local_client.py b/src/sap_cloud_sdk/destination/local_client.py index 9d9c3a9..229544e 100644 --- a/src/sap_cloud_sdk/destination/local_client.py +++ b/src/sap_cloud_sdk/destination/local_client.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional -from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase +from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase, DESTINATION_MOCK_FILE from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Level from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError @@ -60,7 +60,7 @@ class LocalDevDestinationClient(LocalDevClientBase[Destination]): @property def file_name(self) -> str: """Return the JSON file name.""" - return "destination.json" + return DESTINATION_MOCK_FILE @property def name_field(self) -> str: diff --git a/src/sap_cloud_sdk/destination/local_fragment_client.py b/src/sap_cloud_sdk/destination/local_fragment_client.py index 0a61b31..d6f975b 100644 --- a/src/sap_cloud_sdk/destination/local_fragment_client.py +++ b/src/sap_cloud_sdk/destination/local_fragment_client.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional -from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase +from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase, FRAGMENT_MOCK_FILE from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Level from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError @@ -53,7 +53,7 @@ class LocalDevFragmentClient(LocalDevClientBase[Fragment]): @property def file_name(self) -> str: """Return the JSON file name.""" - return "fragments.json" + return FRAGMENT_MOCK_FILE @property def name_field(self) -> str: diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index 086a0fb..6900e17 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -333,6 +333,92 @@ This ensures only valid headers are used with transparent proxy destinations. - `get_subaccount_destination()` - V1 API for subaccount-level destinations with access strategies - `get_destination()` - V2 API for runtime consumption with automatic token retrieval +## Local Development Mode + +When a `mocks/.json` file is present at the repository root, the factory functions automatically return a local in-memory client backed by that file instead of connecting to the SAP BTP Destination Service. No credentials or network access are required. + +| Factory | Mock file | +|---|---| +| `create_client()` | `mocks/destination.json` | +| `create_fragment_client()` | `mocks/fragments.json` | +| `create_certificate_client()` | `mocks/certificates.json` | + +> **WARNING: Local mode is for local development only.** +> Local clients perform no authentication and store data in plain JSON files on disk. Never use local mode in a deployed or production environment. A warning is logged at `WARNING` level every time a local client is returned by a factory. + +### Recommended: add mock files to `.gitignore` + +Mock files may contain sensitive data (URLs, credentials, certificates). Add them to `.gitignore` to prevent accidental commits: + +``` +mocks/destination.json +mocks/fragments.json +mocks/certificates.json +``` + +### Mock file format + +**`mocks/destination.json`** + +```json +{ + "subaccount": [ + { + "name": "my-destination", + "type": "HTTP", + "url": "https://example.com", + "authentication": "NoAuthentication" + }, + { + "tenant": "my-tenant", + "name": "subscriber-destination", + "type": "HTTP", + "url": "https://subscriber.example.com", + "authentication": "NoAuthentication" + } + ], + "instance": [ + { + "name": "instance-destination", + "type": "HTTP", + "url": "https://instance.example.com" + } + ] +} +``` + +**`mocks/fragments.json`** + +```json +{ + "subaccount": [ + { + "FragmentName": "my-fragment", + "URL": "https://example.com", + "Authentication": "NoAuthentication" + } + ], + "instance": [] +} +``` + +**`mocks/certificates.json`** + +```json +{ + "subaccount": [ + { + "Name": "my-cert.pem", + "Content": "LS0tLS1CRUdJTi...", + "Type": "PEM" + } + ], + "instance": [] +} +``` + +Entries with a `"tenant"` field are treated as subscriber-specific. Entries without `"tenant"` are provider entries. + ## Secret Resolution ### Service Binding diff --git a/tests/destination/unit/test_init.py b/tests/destination/unit/test_init.py index 75b3941..6958d5f 100644 --- a/tests/destination/unit/test_init.py +++ b/tests/destination/unit/test_init.py @@ -3,21 +3,31 @@ import pytest from unittest.mock import Mock, patch +from sap_cloud_sdk.destination._local_client_base import ( + DESTINATION_MOCK_FILE, + FRAGMENT_MOCK_FILE, + CERTIFICATE_MOCK_FILE, +) from sap_cloud_sdk.destination import create_client, create_fragment_client, create_certificate_client from sap_cloud_sdk.destination.client import DestinationClient from sap_cloud_sdk.destination.fragment_client import FragmentClient from sap_cloud_sdk.destination.certificate_client import CertificateClient +from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient +from sap_cloud_sdk.destination.local_fragment_client import LocalDevFragmentClient +from sap_cloud_sdk.destination.local_certificate_client import LocalDevCertificateClient from sap_cloud_sdk.destination.config import DestinationConfig from sap_cloud_sdk.destination.exceptions import ClientCreationError +_NO_MOCK_FILE = patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) + class TestCreateClient: - """Tests for create_client factory function.""" + """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): - """Test creating client with explicit configuration.""" config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -25,138 +35,129 @@ def test_create_client_with_explicit_config(self, mock_http, mock_token_provider client_secret="test-secret", identityzone="provider-zone" ) - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_client(config=config) - - # Verify assert isinstance(client, DestinationClient) mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_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_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): - """Test creating client in cloud mode with default configuration.""" - mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_client() - - # Verify 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_tp) + mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_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_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - """Test creating client in cloud mode with custom instance name.""" mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_client(instance="custom-instance") - - # Verify assert isinstance(client, DestinationClient) mock_load_config.assert_called_once_with("custom-instance") - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") def test_create_client_config_error(self, mock_load_config): - """Test that configuration errors are wrapped in ClientCreationError.""" mock_load_config.side_effect = Exception("Config loading failed") - - # Call and verify 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): - """Test that token provider errors are wrapped in ClientCreationError.""" - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config + mock_load_config.return_value = Mock(spec=DestinationConfig) mock_token_provider.side_effect = Exception("Token provider failed") - - # Call and verify 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): - """Test that HTTP client errors are wrapped in ClientCreationError.""" - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_token_provider.return_value = Mock() mock_http.side_effect = Exception("HTTP client failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_client() - assert "failed to create destination client" in str(exc_info.value) assert "HTTP client failed" in str(exc_info.value) +class TestCreateClientLocalMode: + """Tests for create_client local mock mode detection.""" + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_returns_local_client_when_mock_file_exists(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + client = create_client() + assert isinstance(client, LocalDevDestinationClient) + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + with patch("sap_cloud_sdk.destination.logger") as mock_logger: + create_client() + mock_logger.warning.assert_called_once() + 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("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() + client = create_client() + assert isinstance(client, DestinationClient) + + class TestCreateFragmentClient: - """Tests for create_fragment_client factory function.""" + """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): - """Test creating fragment client with default configuration.""" - # Setup mocks mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_fragment_client() - - # Verify 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_tp) + mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) + @_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): - """Test creating fragment client with explicit configuration.""" - # Setup config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -164,98 +165,99 @@ def test_create_fragment_client_with_explicit_config(self, mock_http, mock_token client_secret="test-secret", identityzone="provider-zone" ) - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_fragment_client(config=config) - - # Verify assert isinstance(client, FragmentClient) mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_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_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - """Test creating fragment client with custom instance name.""" - # Setup mocks mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_fragment_client(instance="custom-instance") - - # Verify assert isinstance(client, FragmentClient) mock_load_config.assert_called_once_with("custom-instance") - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") def test_create_fragment_client_config_error(self, mock_load_config): - """Test that configuration errors are wrapped in ClientCreationError.""" - # Setup mock_load_config.side_effect = Exception("Config loading failed") - - # Call and verify 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): - """Test that token provider errors are wrapped in ClientCreationError.""" - # Setup - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config + mock_load_config.return_value = Mock(spec=DestinationConfig) mock_token_provider.side_effect = Exception("Token provider failed") - - # Call and verify 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): - """Test that HTTP client errors are wrapped in ClientCreationError.""" - # Setup - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_token_provider.return_value = Mock() mock_http.side_effect = Exception("HTTP client failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_fragment_client() - assert "failed to create fragment client" in str(exc_info.value) assert "HTTP client failed" in str(exc_info.value) +class TestCreateFragmentClientLocalMode: + """Tests for create_fragment_client local mock mode detection.""" + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_returns_local_client_when_mock_file_exists(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + client = create_fragment_client() + assert isinstance(client, LocalDevFragmentClient) + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + with patch("sap_cloud_sdk.destination.logger") as mock_logger: + create_fragment_client() + mock_logger.warning.assert_called_once() + 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("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() + client = create_fragment_client() + assert isinstance(client, FragmentClient) + + class TestCreateCertificateClient: - """Tests for create_certificate_client factory function.""" + """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): - """Test creating certificate client with explicit configuration.""" - config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -263,106 +265,102 @@ def test_create_certificate_client_with_explicit_config(self, mock_http, mock_to client_secret="test-secret", identityzone="provider-zone" ) - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_certificate_client(config=config) - - # Verify assert isinstance(client, CertificateClient) mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_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_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): - """Test creating certificate client in cloud mode with default configuration.""" mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_certificate_client() - - # Verify 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_tp) + mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_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_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - """Test creating certificate client in cloud mode with custom instance name.""" - mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_certificate_client(instance="custom-instance") - - # Verify assert isinstance(client, CertificateClient) mock_load_config.assert_called_once_with("custom-instance") - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") def test_create_certificate_client_config_error(self, mock_load_config): - """Test that configuration errors are wrapped in ClientCreationError.""" - mock_load_config.side_effect = Exception("Config loading failed") - - # Call and verify 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): - """Test that token provider errors are wrapped in ClientCreationError.""" - - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config + mock_load_config.return_value = Mock(spec=DestinationConfig) mock_token_provider.side_effect = Exception("Token provider failed") - - # Call and verify 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): - """Test that HTTP client errors are wrapped in ClientCreationError.""" - - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_token_provider.return_value = Mock() mock_http.side_effect = Exception("HTTP client failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_certificate_client() - assert "failed to create certificate client" in str(exc_info.value) assert "HTTP client failed" in str(exc_info.value) + + +class TestCreateCertificateClientLocalMode: + """Tests for create_certificate_client local mock mode detection.""" + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_returns_local_client_when_mock_file_exists(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + client = create_certificate_client() + assert isinstance(client, LocalDevCertificateClient) + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + with patch("sap_cloud_sdk.destination.logger") as mock_logger: + create_certificate_client() + mock_logger.warning.assert_called_once() + 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("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() + client = create_certificate_client() + assert isinstance(client, CertificateClient) diff --git a/tests/destination/unit/test_local_certificate_client.py b/tests/destination/unit/test_local_certificate_client.py index 409d6d6..f8859c8 100644 --- a/tests/destination/unit/test_local_certificate_client.py +++ b/tests/destination/unit/test_local_certificate_client.py @@ -5,6 +5,7 @@ import pytest from sap_cloud_sdk.destination.local_certificate_client import LocalDevCertificateClient +from sap_cloud_sdk.destination._local_client_base import CERTIFICATE_MOCK_FILE from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Level from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError @@ -21,7 +22,7 @@ def client(tmp_path, monkeypatch): def _store_path(tmp_path): - return tmp_path / "mocks" / "certificates.json" + return tmp_path / "mocks" / CERTIFICATE_MOCK_FILE def _write_store(client, data): diff --git a/tests/destination/unit/test_local_destination_client.py b/tests/destination/unit/test_local_destination_client.py index 298e24a..268a561 100644 --- a/tests/destination/unit/test_local_destination_client.py +++ b/tests/destination/unit/test_local_destination_client.py @@ -5,6 +5,7 @@ import pytest from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient +from sap_cloud_sdk.destination._local_client_base import DESTINATION_MOCK_FILE from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Level from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError @@ -21,7 +22,7 @@ def client(tmp_path, monkeypatch): def _store_path(tmp_path): - return tmp_path / "mocks" / "destination.json" + return tmp_path / "mocks" / DESTINATION_MOCK_FILE def _write_store(client, data): diff --git a/tests/destination/unit/test_local_fragment_client.py b/tests/destination/unit/test_local_fragment_client.py index 4724e09..f017a84 100644 --- a/tests/destination/unit/test_local_fragment_client.py +++ b/tests/destination/unit/test_local_fragment_client.py @@ -5,6 +5,7 @@ import pytest from sap_cloud_sdk.destination.local_fragment_client import LocalDevFragmentClient +from sap_cloud_sdk.destination._local_client_base import FRAGMENT_MOCK_FILE from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Level from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError @@ -20,7 +21,7 @@ def client(tmp_path, monkeypatch): def _store_path(tmp_path): - return tmp_path / "mocks" / "fragments.json" + return tmp_path / "mocks" / FRAGMENT_MOCK_FILE def _write_store(client, data): From eeb6ce516c86a95f97b628f4710af7cfc98e04de Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Tue, 31 Mar 2026 14:51:44 -0300 Subject: [PATCH 3/7] fix format issues --- src/sap_cloud_sdk/destination/__init__.py | 4 +- .../destination/_local_client_base.py | 99 ++++++++++++++----- .../destination/local_certificate_client.py | 48 +++++---- src/sap_cloud_sdk/destination/local_client.py | 70 ++++++++----- .../destination/local_fragment_client.py | 21 +++- 5 files changed, 171 insertions(+), 71 deletions(-) diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index ade4aa1..81ce282 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -71,7 +71,9 @@ def _mock_file(name: str) -> str: """Return the absolute path to a mocks/ file relative to the repo root.""" - repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + repo_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") + ) return os.path.join(repo_root, "mocks", name) diff --git a/src/sap_cloud_sdk/destination/_local_client_base.py b/src/sap_cloud_sdk/destination/_local_client_base.py index 963fba8..a4c44fc 100644 --- a/src/sap_cloud_sdk/destination/_local_client_base.py +++ b/src/sap_cloud_sdk/destination/_local_client_base.py @@ -11,7 +11,7 @@ from sap_cloud_sdk.destination._models import AccessStrategy from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError -T = TypeVar('T') +T = TypeVar("T") DESTINATION_MOCK_FILE = "destination.json" FRAGMENT_MOCK_FILE = "fragments.json" @@ -23,6 +23,7 @@ AccessStrategy.PROVIDER_FIRST, } + class LocalDevClientBase(ABC, Generic[T]): """ Base class for local development clients that manipulate JSON files. @@ -43,7 +44,9 @@ class LocalDevClientBase(ABC, Generic[T]): def __init__(self) -> None: # Resolve to repo root and mocks path - repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + repo_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") + ) self._file_path = os.path.join(repo_root, "mocks", self.file_name) self._lock = threading.Lock() self._ensure_file() @@ -127,7 +130,9 @@ def _resolve_name(self, item: Dict[str, Any]) -> Optional[str]: name = item.get(self.alt_name_field) return name - def _find_by_name(self, lst: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]: + def _find_by_name( + self, lst: List[Dict[str, Any]], name: str + ) -> Optional[Dict[str, Any]]: """Find an item by name in a list.""" for item in lst: if self._resolve_name(item) == name: @@ -141,14 +146,18 @@ def _index_by_name(self, lst: List[Dict[str, Any]], name: str) -> int: return i return -1 - def _find_by_name_and_no_tenant(self, lst: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]: + def _find_by_name_and_no_tenant( + self, lst: List[Dict[str, Any]], name: str + ) -> Optional[Dict[str, Any]]: """Find an item by name that has no tenant field (provider context).""" for item in lst: if self._resolve_name(item) == name and not item.get("tenant"): return item return None - def _find_by_name_and_tenant(self, lst: List[Dict[str, Any]], name: str, tenant: str) -> Optional[Dict[str, Any]]: + def _find_by_name_and_tenant( + self, lst: List[Dict[str, Any]], name: str, tenant: str + ) -> Optional[Dict[str, Any]]: """Find an item by name and tenant (subscriber context).""" for item in lst: if self._resolve_name(item) == name and item.get("tenant") == tenant: @@ -164,7 +173,9 @@ def _index_by_name_and_no_tenant(self, lst: List[Dict[str, Any]], name: str) -> # ---------- Access-strategy resolution ---------- - def _validate_subscriber_access(self, access_strategy: AccessStrategy, tenant: Optional[str], entity_kind: str) -> None: + def _validate_subscriber_access( + self, access_strategy: AccessStrategy, tenant: Optional[str], entity_kind: str + ) -> None: """Raise DestinationOperationError when tenant is required but missing.""" if access_strategy in _SUBSCRIBER_ACCESS_STRATEGIES and tenant is None: raise DestinationOperationError( @@ -173,13 +184,14 @@ def _validate_subscriber_access(self, access_strategy: AccessStrategy, tenant: O ) def _resolve_subaccount_entity( - self, - name: str, - access_strategy: AccessStrategy, - tenant: Optional[str], - sub_list: List[Dict[str, Any]], + self, + name: str, + access_strategy: AccessStrategy, + tenant: Optional[str], + sub_list: List[Dict[str, Any]], ) -> Optional[T]: """Resolve a single entity from the subaccount list using the given access strategy.""" + def find_subscriber() -> Optional[T]: if tenant is None: return None @@ -199,7 +211,9 @@ def find_provider() -> Optional[T]: funcs = order_map.get(access_strategy) if not funcs: - raise DestinationOperationError(f"unknown access strategy: {access_strategy}") + raise DestinationOperationError( + f"unknown access strategy: {access_strategy}" + ) for fn in funcs: result = fn() @@ -208,19 +222,26 @@ def find_provider() -> Optional[T]: return None def _resolve_subaccount_list( - self, - access_strategy: AccessStrategy, - tenant: Optional[str], - sub_list: List[Dict[str, Any]], + self, + access_strategy: AccessStrategy, + tenant: Optional[str], + sub_list: List[Dict[str, Any]], ) -> List[T]: """Resolve a list of entities from the subaccount list using the given access strategy.""" + def list_subscriber() -> List[T]: if tenant is None: return [] - return [self.from_dict(entry) for entry in sub_list if entry.get("tenant") == tenant] + return [ + self.from_dict(entry) + for entry in sub_list + if entry.get("tenant") == tenant + ] def list_provider() -> List[T]: - return [self.from_dict(entry) for entry in sub_list if not entry.get("tenant")] + return [ + self.from_dict(entry) for entry in sub_list if not entry.get("tenant") + ] order_map: Dict[AccessStrategy, tuple[Callable[[], List[T]], ...]] = { AccessStrategy.SUBSCRIBER_ONLY: (list_subscriber,), @@ -231,7 +252,9 @@ def list_provider() -> List[T]: funcs = order_map.get(access_strategy) if not funcs: - raise DestinationOperationError(f"unknown access strategy: {access_strategy}") + raise DestinationOperationError( + f"unknown access strategy: {access_strategy}" + ) results = funcs[0]() if not results and len(funcs) > 1: @@ -259,16 +282,28 @@ def _create_entity(self, collection: str, entity: T, entity_name: str) -> None: data = self._read() lst = data.setdefault(collection, []) if self._find_by_name(lst, entity_name) is not None: - raise HttpError(f"entity '{entity_name}' already exists", status_code=409, response_text="Conflict") + raise HttpError( + f"entity '{entity_name}' already exists", + status_code=409, + response_text="Conflict", + ) lst.append(entry) self._write(data) except HttpError: raise except Exception as e: - raise DestinationOperationError(f"failed to create entity '{entity_name}': {e}") + raise DestinationOperationError( + f"failed to create entity '{entity_name}': {e}" + ) - def _update_entity(self, collection: str, entity: T, entity_name: str, preserve_fields: Optional[List[str]] = None) -> None: + def _update_entity( + self, + collection: str, + entity: T, + entity_name: str, + preserve_fields: Optional[List[str]] = None, + ) -> None: """Update an entity in a collection.""" updated = self.to_dict(entity) try: @@ -277,7 +312,11 @@ def _update_entity(self, collection: str, entity: T, entity_name: str, preserve_ lst = data.setdefault(collection, []) idx = self._index_by_name(lst, entity_name) if idx < 0: - raise HttpError(f"entity '{entity_name}' not found", status_code=404, response_text="Not Found") + raise HttpError( + f"entity '{entity_name}' not found", + status_code=404, + response_text="Not Found", + ) if preserve_fields: existing = lst[idx] @@ -290,7 +329,9 @@ def _update_entity(self, collection: str, entity: T, entity_name: str, preserve_ except HttpError: raise except Exception as e: - raise DestinationOperationError(f"failed to update entity '{entity_name}': {e}") + raise DestinationOperationError( + f"failed to update entity '{entity_name}': {e}" + ) def _delete_entity(self, collection: str, entity_name: str) -> None: """Delete an entity from a collection.""" @@ -300,11 +341,17 @@ def _delete_entity(self, collection: str, entity_name: str) -> None: lst = data.setdefault(collection, []) idx = self._index_by_name(lst, entity_name) if idx < 0: - raise HttpError(f"entity '{entity_name}' not found", status_code=404, response_text="Not Found") + raise HttpError( + f"entity '{entity_name}' not found", + status_code=404, + response_text="Not Found", + ) lst.pop(idx) self._write(data) except HttpError: raise except Exception as e: - raise DestinationOperationError(f"failed to delete entity '{entity_name}': {e}") + raise DestinationOperationError( + f"failed to delete entity '{entity_name}': {e}" + ) diff --git a/src/sap_cloud_sdk/destination/local_certificate_client.py b/src/sap_cloud_sdk/destination/local_certificate_client.py index e303bf5..d87b396 100644 --- a/src/sap_cloud_sdk/destination/local_certificate_client.py +++ b/src/sap_cloud_sdk/destination/local_certificate_client.py @@ -2,7 +2,10 @@ from typing import Any, Dict, Optional -from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase, CERTIFICATE_MOCK_FILE +from sap_cloud_sdk.destination._local_client_base import ( + LocalDevClientBase, + CERTIFICATE_MOCK_FILE, +) from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Level from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError @@ -89,10 +92,10 @@ def get_instance_certificate(self, name: str) -> Optional[Certificate]: return self._get_entity("instance", name) def get_subaccount_certificate( - self, - name: str, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, - tenant: Optional[str] = None, + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, ) -> Optional[Certificate]: """Get a certificate from the subaccount scope with an access strategy. @@ -118,7 +121,9 @@ def get_subaccount_certificate( try: data = self._read() sub_list = data.get("subaccount", []) - return self._resolve_subaccount_entity(name, access_strategy, tenant, sub_list) + return self._resolve_subaccount_entity( + name, access_strategy, tenant, sub_list + ) except HttpError: raise except DestinationOperationError: @@ -126,7 +131,9 @@ def get_subaccount_certificate( except Exception as e: raise DestinationOperationError(f"failed to get certificate '{name}': {e}") - def create_certificate(self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def create_certificate( + self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Create a certificate. Args: @@ -140,7 +147,9 @@ def create_certificate(self, certificate: Certificate, level: Optional[Level] = collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" self._create_entity(collection, certificate, certificate.name) - def update_certificate(self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def update_certificate( + self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Update a certificate. Args: @@ -154,7 +163,9 @@ def update_certificate(self, certificate: Certificate, level: Optional[Level] = collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" self._update_entity(collection, certificate, certificate.name) - def delete_certificate(self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def delete_certificate( + self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Delete a certificate. Args: @@ -169,8 +180,7 @@ def delete_certificate(self, name: str, level: Optional[Level] = Level.SUB_ACCOU self._delete_entity(collection, name) def list_instance_certificates( - self, - _filter: Optional[Any] = None + self, _filter: Optional[Any] = None ) -> PagedResult[Certificate]: """List all certificates from the service instance scope. @@ -192,13 +202,15 @@ def list_instance_certificates( except DestinationOperationError: raise except Exception as e: - raise DestinationOperationError(f"failed to list instance certificates: {e}") + raise DestinationOperationError( + f"failed to list instance certificates: {e}" + ) def list_subaccount_certificates( - self, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, - tenant: Optional[str] = None, - _filter: Optional[Any] = None + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + _filter: Optional[Any] = None, ) -> PagedResult[Certificate]: """List certificates from the subaccount scope with an access strategy. @@ -230,4 +242,6 @@ def list_subaccount_certificates( except DestinationOperationError: raise except Exception as e: - raise DestinationOperationError(f"failed to list subaccount certificates: {e}") + raise DestinationOperationError( + f"failed to list subaccount certificates: {e}" + ) diff --git a/src/sap_cloud_sdk/destination/local_client.py b/src/sap_cloud_sdk/destination/local_client.py index 229544e..df0a857 100644 --- a/src/sap_cloud_sdk/destination/local_client.py +++ b/src/sap_cloud_sdk/destination/local_client.py @@ -2,11 +2,15 @@ from typing import Any, Dict, Optional -from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase, DESTINATION_MOCK_FILE +from sap_cloud_sdk.destination._local_client_base import ( + LocalDevClientBase, + DESTINATION_MOCK_FILE, +) from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Level from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + class LocalDevDestinationClient(LocalDevClientBase[Destination]): """ Local development client that mocks DestinationClient by manipulating a JSON file. @@ -97,10 +101,10 @@ def get_instance_destination(self, name: str) -> Optional[Destination]: return self._get_entity("instance", name) def get_subaccount_destination( - self, - name: str, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, - tenant: Optional[str] = None, + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, ) -> Optional[Destination]: """Get a destination from the subaccount scope with an access strategy. @@ -120,7 +124,9 @@ def get_subaccount_destination( try: data = self._read() sub_list = data.get("subaccount", []) - return self._resolve_subaccount_entity(name, access_strategy, tenant, sub_list) + return self._resolve_subaccount_entity( + name, access_strategy, tenant, sub_list + ) except HttpError: raise except DestinationOperationError: @@ -130,7 +136,9 @@ def get_subaccount_destination( # ---------- Write operations ---------- - def create_destination(self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def create_destination( + self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Create a destination. Args: @@ -153,16 +161,23 @@ def create_destination(self, dest: Destination, level: Optional[Level] = Level.S data = self._read() lst = data.setdefault(collection, []) if self._find_by_name_and_no_tenant(lst, dest.name) is not None: - raise HttpError(f"destination '{dest.name}' already exists", status_code=409, - response_text="Conflict") + raise HttpError( + f"destination '{dest.name}' already exists", + status_code=409, + response_text="Conflict", + ) lst.append(entry) self._write(data) except HttpError: raise except Exception as e: - raise DestinationOperationError(f"failed to create destination '{dest.name}': {e}") + raise DestinationOperationError( + f"failed to create destination '{dest.name}': {e}" + ) - def update_destination(self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def update_destination( + self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Update a destination. Args: @@ -180,7 +195,9 @@ def update_destination(self, dest: Destination, level: Optional[Level] = Level.S # Preserve tenant field for subaccount-level entries self._update_entity(collection, dest, dest.name, preserve_fields=["tenant"]) - def delete_destination(self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def delete_destination( + self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Delete a destination. Args: @@ -203,17 +220,22 @@ def delete_destination(self, name: str, level: Optional[Level] = Level.SUB_ACCOU lst = data.setdefault(collection, []) idx = self._index_by_name_and_no_tenant(lst, name) if idx < 0: - raise HttpError(f"destination '{name}' not found", status_code=404, response_text="Not Found") + raise HttpError( + f"destination '{name}' not found", + status_code=404, + response_text="Not Found", + ) lst.pop(idx) self._write(data) except HttpError: raise except Exception as e: - raise DestinationOperationError(f"failed to delete destination '{name}': {e}") + raise DestinationOperationError( + f"failed to delete destination '{name}': {e}" + ) def list_instance_destinations( - self, - _filter: Optional[Any] = None + self, _filter: Optional[Any] = None ) -> PagedResult[Destination]: """List all destinations from the service instance scope. @@ -235,13 +257,15 @@ def list_instance_destinations( except DestinationOperationError: raise except Exception as e: - raise DestinationOperationError(f"failed to list instance destinations: {e}") + raise DestinationOperationError( + f"failed to list instance destinations: {e}" + ) def list_subaccount_destinations( - self, - access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, - tenant: Optional[str] = None, - _filter: Optional[Any] = None + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + _filter: Optional[Any] = None, ) -> PagedResult[Destination]: """List destinations from the subaccount scope with an access strategy. @@ -273,4 +297,6 @@ def list_subaccount_destinations( except DestinationOperationError: raise except Exception as e: - raise DestinationOperationError(f"failed to list subaccount destinations: {e}") + raise DestinationOperationError( + f"failed to list subaccount destinations: {e}" + ) diff --git a/src/sap_cloud_sdk/destination/local_fragment_client.py b/src/sap_cloud_sdk/destination/local_fragment_client.py index d6f975b..963bf36 100644 --- a/src/sap_cloud_sdk/destination/local_fragment_client.py +++ b/src/sap_cloud_sdk/destination/local_fragment_client.py @@ -2,7 +2,10 @@ from typing import Any, Dict, List, Optional -from sap_cloud_sdk.destination._local_client_base import LocalDevClientBase, FRAGMENT_MOCK_FILE +from sap_cloud_sdk.destination._local_client_base import ( + LocalDevClientBase, + FRAGMENT_MOCK_FILE, +) from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Level from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError @@ -119,7 +122,9 @@ def get_subaccount_fragment( try: data = self._read() sub_list = data.get("subaccount", []) - return self._resolve_subaccount_entity(name, access_strategy, tenant, sub_list) + return self._resolve_subaccount_entity( + name, access_strategy, tenant, sub_list + ) except HttpError: raise except DestinationOperationError: @@ -180,7 +185,9 @@ def list_subaccount_fragments( # ---------- Write operations ---------- - def create_fragment(self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def create_fragment( + self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Create a fragment. Args: @@ -194,7 +201,9 @@ def create_fragment(self, fragment: Fragment, level: Optional[Level] = Level.SUB collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" self._create_entity(collection, fragment, fragment.name) - def update_fragment(self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def update_fragment( + self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Update a fragment. Args: @@ -208,7 +217,9 @@ def update_fragment(self, fragment: Fragment, level: Optional[Level] = Level.SUB collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" self._update_entity(collection, fragment, fragment.name) - def delete_fragment(self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT) -> None: + def delete_fragment( + self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: """Delete a fragment. Args: From 7dd2d42c12dc7a171bc005ceb9b0bce669810ff4 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Tue, 31 Mar 2026 17:00:29 -0300 Subject: [PATCH 4/7] delete mocks --- mocks/certificates.json | 4 ---- mocks/destination.json | 1 - 2 files changed, 5 deletions(-) delete mode 100644 mocks/certificates.json delete mode 100644 mocks/destination.json diff --git a/mocks/certificates.json b/mocks/certificates.json deleted file mode 100644 index ea1cc33..0000000 --- a/mocks/certificates.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "subaccount": [], - "instance": [] -} diff --git a/mocks/destination.json b/mocks/destination.json deleted file mode 100644 index bae2af7..0000000 --- a/mocks/destination.json +++ /dev/null @@ -1 +0,0 @@ -{"subaccount": [], "instance": [{"name": "inst1", "type": "HTTP", "url": "https://inst1.example.com"}]} From bffd29e10a3547009392a1fc3beb1adefc9876e0 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Wed, 1 Apr 2026 08:59:37 -0300 Subject: [PATCH 5/7] add mocks to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0e91c7f..6c07499 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ test-results.* # SonarQube Reports .sonar* + +# Local Mode +mocks/ From fd9e8a59a9204e562c5f67fdae27e65c8cc8e7c1 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Wed, 8 Apr 2026 14:46:35 -0300 Subject: [PATCH 6/7] remove scenarios for destination mt --- .../integration/destination.feature | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/destination/integration/destination.feature b/tests/destination/integration/destination.feature index ced6505..f059127 100644 --- a/tests/destination/integration/destination.feature +++ b/tests/destination/integration/destination.feature @@ -72,20 +72,20 @@ Feature: Destination Service Integration When I get subaccount destination "non-existent-destination" with "PROVIDER_ONLY" access strategy Then the destination should not be found - Scenario: Get destination using subscriber first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_FIRST" access strategy - Then the destination should be retrieved successfully + # Scenario: Get destination using subscriber first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_FIRST" access strategy + # Then the destination should be retrieved successfully - Scenario: Get destination using subscriber only strategy - Given I use tenant "app-foundation-dev-subscriber" - When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_ONLY" access strategy - Then the destination should be retrieved successfully + # Scenario: Get destination using subscriber only strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_ONLY" access strategy + # Then the destination should be retrieved successfully - Scenario: Get destination using provider first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I get subaccount destination "subscriber-dest-test" with "PROVIDER_FIRST" access strategy - Then the destination should be retrieved successfully + # Scenario: Get destination using provider first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I get subaccount destination "subscriber-dest-test" with "PROVIDER_FIRST" access strategy + # Then the destination should be retrieved successfully Scenario: Get destination using provider only strategy Given I use tenant "app-foundation-dev-subscriber" @@ -140,20 +140,20 @@ Feature: Destination Service Integration And the destination "other-destination" should not be in the list And I clean up all instance destinations - Scenario: List destinations using subscriber first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I list subaccount destinations with "SUBSCRIBER_FIRST" access strategy - Then the destination "subscriber-dest-test" should be in the list + # Scenario: List destinations using subscriber first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I list subaccount destinations with "SUBSCRIBER_FIRST" access strategy + # Then the destination "subscriber-dest-test" should be in the list - Scenario: List destinations using subscriber only strategy - Given I use tenant "app-foundation-dev-subscriber" - When I list subaccount destinations with "SUBSCRIBER_ONLY" access strategy - Then the destination "subscriber-dest-test" should be in the list + # Scenario: List destinations using subscriber only strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I list subaccount destinations with "SUBSCRIBER_ONLY" access strategy + # Then the destination "subscriber-dest-test" should be in the list - Scenario: List destinations using provider first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I list subaccount destinations with "PROVIDER_FIRST" access strategy - Then the destination list should be retrieved successfully + # Scenario: List destinations using provider first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I list subaccount destinations with "PROVIDER_FIRST" access strategy + # Then the destination list should be retrieved successfully Scenario: List destinations using provider only strategy Given I use tenant "app-foundation-dev-subscriber" From 108a8fc87734b8f8eb06bf4af9bca275c32a7ec9 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Wed, 8 Apr 2026 15:24:02 -0300 Subject: [PATCH 7/7] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 808bbe7..4888fd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.5.0" +version = "0.6.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0"