diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 21896676f..368b9fa1e 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -48,6 +48,7 @@ sdk.agenthub # AgentHubService sdk.mcp # McpService sdk.resource_catalog # ResourceCatalogService sdk.automation_tracker # AutomationTrackerService +sdk.business_rules # BusinessRulesService ``` ### Authentication @@ -95,6 +96,7 @@ Services provide both sync and async variants (e.g., `.invoke()` and `.invoke_as | `action_center/` | Task management for human-in-the-loop workflows | | `agenthub/` | System agents and LLM model discovery | | `automation_tracker/` | Business Transaction Service (BTS) for Process Mining | +| `business_rules/` | Business rule runs for rules deployed to Orchestrator, behind one `run()` | | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1b57a68a4..3df1c4def 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 98af7b8b6..52f392e66 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -9,6 +9,7 @@ from .agenthub._agenthub_service import AgentHubService from .agenthub._remote_a2a_service import RemoteA2aService from .automation_ops import AutomationOpsService +from .business_rules import BusinessRulesService from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService from .common import ( ApiClient, @@ -190,6 +191,10 @@ def orchestrator_setup(self) -> OrchestratorSetupService: def automation_ops(self) -> AutomationOpsService: return AutomationOpsService(self._config, self._execution_context) + @property + def business_rules(self) -> BusinessRulesService: + return BusinessRulesService(self._config, self._execution_context, self.folders) + @property def pii_detection(self) -> PiiDetectionService: return PiiDetectionService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py new file mode 100644 index 000000000..889f86cb3 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -0,0 +1,26 @@ +"""Business Rules service package. + +Provides the ``BusinessRulesService`` client for running UiPath Business Rules +deployed to Orchestrator, and the Pydantic models for its results and trace +context. +""" + +from ._business_rules_service import BusinessRulesService +from .business_rules import ( + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleRunResult, + BusinessRuleStatus, + RunMode, + TraceContext, +) + +__all__ = [ + "BusinessRuleDecision", + "BusinessRuleError", + "BusinessRuleRunResult", + "BusinessRuleStatus", + "BusinessRulesService", + "RunMode", + "TraceContext", +] diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py new file mode 100644 index 000000000..808dd257d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -0,0 +1,419 @@ +"""Business Rules service for UiPath Platform. + +Runs business rules deployed to Orchestrator. +""" + +from collections.abc import Mapping +from typing import Any, Dict, List, Optional, Tuple + +from uipath.core.tracing import traced + +from ..common._base_service import _TRACE_PARENT_HEADER, BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext +from ..common._models import Endpoint, RequestSpec +from ..constants import HEADER_FOLDER_KEY +from ..orchestrator._folder_service import FolderService +from .business_rules import ( + BusinessRuleDecision, + BusinessRuleError, + BusinessRuleRunResult, + BusinessRuleStatus, + RunMode, + TraceContext, + _WireResponse, + _WireResult, +) + +_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") + +_HEADER_ORGANIZATION_UNIT_ID = "x-uipath-organizationunitid" + +# The service's contract is a batch; this SDK submits exactly one input under this id. +_SINGLE_INPUT_ID = "input-1" +_MAX_INPUT_KEYS = 256 +_MAX_RULE_NAME_LENGTH = 256 + + +class BusinessRulesService(FolderContext, BaseService): + """Service for running UiPath Business Rules. + + Each call runs one input against a business rule deployed to Orchestrator, + named like any other resource, and returns the decisions it produced. The + caller never picks a service endpoint. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + @traced(name="business_rules_run", run_type="uipath") + def run( + self, + name: str, + input: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + explain: bool = False, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + organization_unit_id: Optional[int] = None, + trace_context: Optional[TraceContext] = None, + ) -> BusinessRuleRunResult: + """Run a business rule against one input. + + Args: + name: The name of the business rule deployed to Orchestrator. + input: The input to run, keyed by the rule's input names. Declared + inputs absent from it bind to null. + version: The rule version to run; defaults to the active version. + decision_names: The decisions to evaluate; defaults to the whole model. + explain: Whether to record condition-level explanations in the trace. + folder_key: The key of the folder to run in. + folder_path: The path of the folder to run in. Looked up and sent as + its key. + organization_unit_id: The numeric id of the folder to run in, sent as + given. The service prefers the folder key when both are sent. + trace_context: The trace to file the run's spans under. Defaults to + the ambient trace: ``UIPATH_TRACE_ID`` and the current span. + + A folder is required: ``folder_key``, ``folder_path`` or + ``organization_unit_id``, with ``explain=True`` needing a folder key (from + ``folder_key`` or ``folder_path``). ``folder_key`` and ``folder_path`` are + exclusive. When the caller gives no folder at all, it falls back to + ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``; an explicit folder is + never replaced by the environment's. A ``businessRule`` binding can remap + ``name`` and the folder per environment; its folder then replaces + ``folder_key`` or ``folder_path``. + + Returns: + BusinessRuleRunResult: The decisions produced for the input, and the + mode that ran. + + Raises: + ValueError: If the request is invalid or a required folder is missing. + EnrichedException: If the service rejects the request. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + result = client.business_rules.run( + "Loan Pricing", + {"creditScore": 740, "age": 34}, + folder_path="Finance", + ) + for decision in result.decisions: + print(decision.decision_name, decision.outputs) + ``` + """ + name, folder_key, folder_path = self._apply_binding( + name, folder_key, folder_path + ) + _validate_run(name, input) + key, path = self._folder_source( + folder_key, folder_path, use_env=organization_unit_id is None or explain + ) + if path: + key = self._folders_service.retrieve_folder_key(path) + mode, spec = self._run_spec( + name, input, version, decision_names, explain, key, organization_unit_id + ) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=_with_trace(spec.headers, trace_context), + scoped="tenant", + ) + return _to_run_result(mode, _WireResponse.model_validate(response.json())) + + @traced(name="business_rules_run", run_type="uipath") + async def run_async( + self, + name: str, + input: Dict[str, Any], + *, + version: Optional[str] = None, + decision_names: Optional[List[str]] = None, + explain: bool = False, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + organization_unit_id: Optional[int] = None, + trace_context: Optional[TraceContext] = None, + ) -> BusinessRuleRunResult: + """Asynchronously run a business rule against one input. + + Args: + name: The name of the business rule deployed to Orchestrator. + input: The input to run, keyed by the rule's input names. Declared + inputs absent from it bind to null. + version: The rule version to run; defaults to the active version. + decision_names: The decisions to evaluate; defaults to the whole model. + explain: Whether to record condition-level explanations in the trace. + folder_key: The key of the folder to run in. + folder_path: The path of the folder to run in. Looked up and sent as + its key. + organization_unit_id: The numeric id of the folder to run in, sent as + given. The service prefers the folder key when both are sent. + trace_context: The trace to file the run's spans under. Defaults to + the ambient trace: ``UIPATH_TRACE_ID`` and the current span. + + Returns: + BusinessRuleRunResult: The decisions produced for the input, and the + mode that ran. + + Raises: + ValueError: If the request is invalid or a required folder is missing. + EnrichedException: If the service rejects the request. + """ + name, folder_key, folder_path = self._apply_binding( + name, folder_key, folder_path + ) + _validate_run(name, input) + key, path = self._folder_source( + folder_key, folder_path, use_env=organization_unit_id is None or explain + ) + if path: + key = await self._folders_service.retrieve_folder_key_async(path) + mode, spec = self._run_spec( + name, input, version, decision_names, explain, key, organization_unit_id + ) + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=_with_trace(spec.headers, trace_context), + scoped="tenant", + ) + return _to_run_result(mode, _WireResponse.model_validate(response.json())) + + @resource_override(resource_type="businessRule") + def _binding( + self, name: str, folder_path: Optional[str] = None + ) -> Tuple[str, Optional[str]]: + # resource_override swaps these two arguments when the solution's + # bindings remap this rule; the method just returns what it was given. + return name, folder_path + + def _apply_binding( + self, name: str, folder_key: Optional[str], folder_path: Optional[str] + ) -> Tuple[str, Optional[str], Optional[str]]: + """Apply a businessRule binding, if one remaps this rule. + + The binding names a folder by path. When it applies, that folder + replaces whichever folder the caller gave, including a folder_key, which + the override decorator alone would leave in place next to the new path. + """ + bound_name, bound_path = self._binding(name, folder_path=folder_path) + if (bound_name, bound_path) != (name, folder_path) and bound_path: + folder_key = None + return bound_name, folder_key, bound_path + + def _folder_source( + self, folder_key: Optional[str], folder_path: Optional[str], use_env: bool + ) -> Tuple[Optional[str], Optional[str]]: + """Pick the folder key to send, as a (key, path-still-to-resolve) pair. + + ``use_env`` is false when the caller named the folder another way (by its + numeric id), so the environment's folder can't silently replace it. + """ + if folder_key and folder_path: + raise ValueError("Only one of folder_key or folder_path can be provided") + if folder_key: + return folder_key, None + if folder_path: + return None, folder_path + if not use_env: + return None, None + if self._folder_key: + return self._folder_key, None + return None, self._folder_path or None + + def _run_spec( + self, + name: str, + input: Dict[str, Any], + version: Optional[str], + decision_names: Optional[List[str]], + explain: bool, + folder_key: Optional[str], + organization_unit_id: Optional[int], + ) -> Tuple[RunMode, RequestSpec]: + if explain and not folder_key: + raise _missing_folder_key() + if not folder_key and organization_unit_id is None: + raise _missing_folder("a deployed business rule") + return RunMode.DEPLOYED, self._evaluate_spec( + name, + input, + version, + decision_names, + explain, + folder_key, + organization_unit_id, + ) + + def _evaluate_spec( + self, + name: str, + input: Dict[str, Any], + version: Optional[str], + decision_names: Optional[List[str]], + explain: bool, + folder_key: Optional[str], + organization_unit_id: Optional[int], + ) -> RequestSpec: + body: Dict[str, Any] = { + "businessRuleName": name, + "explain": explain, + "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], + } + if version: + body["version"] = version + if decision_names: + body["decisionNames"] = decision_names + return RequestSpec( + method="POST", + endpoint=_EVALUATE_ENDPOINT, + json=body, + headers=_folder_headers(folder_key, organization_unit_id), + ) + + +class _TraceHeaders(Dict[str, str]): + """Request headers that keep the caller's explicit trace header. + + BaseService writes the ambient trace header into the headers it is given just + before sending; this dict ignores that write when an explicit one is set. + """ + + def __setitem__(self, key: str, value: str) -> None: + if key == _TRACE_PARENT_HEADER and key in self: + return + super().__setitem__(key, value) + + +def _with_trace( + headers: Dict[str, str], trace_context: Optional[TraceContext] +) -> Dict[str, str]: + if trace_context is None: + return headers + pinned = _TraceHeaders(headers) + dict.__setitem__(pinned, _TRACE_PARENT_HEADER, trace_context.to_traceparent()) + return pinned + + +def _present(value: Optional[str]) -> bool: + # Blank counts as absent, matching how the service reads these fields. + return bool(value and value.strip()) + + +def _folder_headers( + folder_key: Optional[str], organization_unit_id: Optional[int] +) -> Dict[str, str]: + headers: Dict[str, str] = {} + if folder_key: + headers[HEADER_FOLDER_KEY] = folder_key + if organization_unit_id is not None: + headers[_HEADER_ORGANIZATION_UNIT_ID] = str(organization_unit_id) + return headers + + +def _missing_folder(needed_for: str) -> ValueError: + return ValueError( + f"A folder is required for {needed_for}: pass folder_key, folder_path or " + "organization_unit_id, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + ) + + +def _missing_folder_key() -> ValueError: + return ValueError( + "A folder key is required for explain=True: pass folder_key or " + "folder_path, or set UIPATH_FOLDER_KEY or UIPATH_FOLDER_PATH" + ) + + +def _validate_run(name: str, input: Dict[str, Any]) -> None: + _validate_rule_name(name, "name") + _validate_input(input) + + +def _validate_rule_name(rule_name: str, field: str) -> None: + if not _present(rule_name): + raise ValueError(f"{field} must be specified") + if len(rule_name) > _MAX_RULE_NAME_LENGTH: + raise ValueError(f"{field} must not exceed {_MAX_RULE_NAME_LENGTH} characters") + for forbidden in ("/", "\\", "..", "%"): + if forbidden in rule_name: + raise ValueError(f"{field} must not contain '{forbidden}'") + if any(not ch.isprintable() for ch in rule_name): + raise ValueError(f"{field} must not contain control characters") + + +def _validate_input(input: Dict[str, Any]) -> None: + if input is None: + raise ValueError("input must not be None") + if not isinstance(input, Mapping): + raise ValueError( + "input must be a mapping of the rule's input names to values, " + f"not {type(input).__name__}" + ) + if len(input) > _MAX_INPUT_KEYS: + raise ValueError(f"input must not exceed {_MAX_INPUT_KEYS} keys") + + +def _single_result( + response: _WireResponse, +) -> Tuple[List[BusinessRuleDecision], List[BusinessRuleError], BusinessRuleStatus]: + results = response.results or [] + ours: Optional[_WireResult] = next( + (r for r in results if r.id == _SINGLE_INPUT_ID), None + ) + if ours is None and len(results) == 1: + ours = results[0] + elif ours is None and results: + raise ValueError( + f"The service returned {len(results)} results and none carried the id " + f"'{_SINGLE_INPUT_ID}' this request was submitted under" + ) + if ours is None: + # No result for our input at all: it was never evaluated. + return [], [], BusinessRuleStatus.ALL_FAILED + + decisions = ours.decisions or [] + errors = ours.errors or [] + if errors: + status = BusinessRuleStatus.ALL_FAILED + else: + failed = sum(1 for d in decisions if d.error is not None) + if failed == 0: + status = BusinessRuleStatus.SUCCESS + elif failed == len(decisions): + status = BusinessRuleStatus.ALL_FAILED + else: + status = BusinessRuleStatus.PARTIAL_SUCCESS + return decisions, errors, status + + +def _to_run_result(mode: RunMode, response: _WireResponse) -> BusinessRuleRunResult: + decisions, errors, status = _single_result(response) + return BusinessRuleRunResult( + mode=mode, + status=status, + decisions=decisions, + errors=errors, + top_level_error=response.error.code if response.error else None, + business_rule_name=response.business_rule_name, + version=response.version, + ) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py new file mode 100644 index 000000000..f63fb97f8 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -0,0 +1,136 @@ +"""Models for the UiPath Business Rules service.""" + +import re +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_HEX = re.compile(r"^[0-9a-f]+$") + + +class BusinessRuleStatus(str, Enum): + """The overall outcome of running a business rule against one input.""" + + SUCCESS = "Success" + PARTIAL_SUCCESS = "PartialSuccess" + ALL_FAILED = "AllFailed" + + +class RunMode(str, Enum): + """Which kind of model ran, and so which service endpoint served the run.""" + + DEPLOYED = "Deployed" + + +class TraceContext(BaseModel): + """An existing trace to file the run's spans under. + + Optional on ``run()``: when omitted, the SDK takes the trace from + ``UIPATH_TRACE_ID`` and the current span, as it does for every service. + """ + + trace_id: str = Field( + description="The trace id: 32 hex characters, or a UUID with dashes." + ) + parent_span_id: str = Field( + description="The span the run's spans nest under: 16 hex characters." + ) + + @field_validator("trace_id") + @classmethod + def _normalize_trace_id(cls, value: str) -> str: + normalized = value.replace("-", "").strip().lower() + if len(normalized) != 32 or not _HEX.match(normalized): + raise ValueError("trace_id must be 32 hex characters or a UUID") + if normalized == "0" * 32: + raise ValueError("trace_id must not be all zeros") + return normalized + + @field_validator("parent_span_id") + @classmethod + def _normalize_parent_span_id(cls, value: str) -> str: + normalized = value.strip().lower() + if len(normalized) != 16 or not _HEX.match(normalized): + raise ValueError("parent_span_id must be 16 hex characters") + if normalized == "0" * 16: + raise ValueError("parent_span_id must not be all zeros") + return normalized + + def to_traceparent(self) -> str: + """Return the W3C traceparent value for this context.""" + return f"00-{self.trace_id}-{self.parent_span_id}-01" + + +class BusinessRuleError(BaseModel): + """A code/message pair describing an input-level or decision-level error.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + code: str = Field(default="", alias="code") + message: str = Field(default="", alias="message") + + +class BusinessRuleDecision(BaseModel): + """The result of evaluating a single decision.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + decision_name: str = Field(default="", alias="decisionName") + outputs: Optional[Dict[str, Any]] = Field(default=None, alias="outputs") + error: Optional[BusinessRuleError] = Field(default=None, alias="error") + + +class BusinessRuleRunResult(BaseModel): + """The outcome of running a business rule against one input.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + ) + + mode: RunMode = Field(description="Which kind of model ran.") + status: BusinessRuleStatus + decisions: List[BusinessRuleDecision] = Field(default_factory=list) + errors: List[BusinessRuleError] = Field( + default_factory=list, + description="Errors that failed the input as a whole; non-empty means nothing was evaluated.", + ) + top_level_error: Optional[str] = Field( + default=None, + description="The request-level error code (e.g. BATCH_TIMEOUT), if one was reported.", + ) + business_rule_name: Optional[str] = Field( + default=None, description="The deployed rule that ran." + ) + version: Optional[str] = Field( + default=None, description="The rule version that ran." + ) + + +class _WireResult(BaseModel): + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + id: Optional[str] = None + decisions: Optional[List[BusinessRuleDecision]] = None + errors: Optional[List[BusinessRuleError]] = None + + +class _WireResponse(BaseModel): + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="ignore" + ) + + business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") + version: Optional[str] = None + error: Optional[BusinessRuleError] = None + results: Optional[List[_WireResult]] = None diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index a93880896..e764ee587 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -61,6 +61,7 @@ class GenericResourceOverwrite(ResourceOverwrite): "queue", "remoteA2aAgent", "memorySpace", + "businessRule", ] name: str = Field(alias="name") folder_path: str = Field(alias="folderPath") diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py new file mode 100644 index 000000000..abeb04026 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -0,0 +1,840 @@ +import json +from typing import Any, Iterator +from unittest.mock import AsyncMock, Mock + +import pytest +from opentelemetry import trace +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags +from pydantic import ValidationError +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.business_rules import ( + BusinessRulesService, + BusinessRuleStatus, + RunMode, + TraceContext, +) +from uipath.platform.common._bindings import ( + GenericResourceOverwrite, + ResourceOverwriteParser, + _resource_overwrites, +) +from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT +from uipath.platform.errors import EnrichedException + +FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" +TRACEPARENT = "x-uipath-traceparent-id" +RULE = "Loan Pricing" + + +@pytest.fixture +def folders_service() -> Mock: + folders = Mock() + folders.retrieve_folder_key.return_value = FOLDER_KEY + folders.retrieve_folder_key_async = AsyncMock(return_value=FOLDER_KEY) + return folders + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> BusinessRulesService: + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + return BusinessRulesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + + +@pytest.fixture +def evaluate_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" + + +def _response(results: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: + return { + "hasPartialSuccess": False, + "stats": {"submitted": 1, "succeeded": 1, "failed": 0, "notEvaluated": 0}, + "results": results, + "meta": {"timestamp": "2026-09-28T00:00:00Z"}, + **extra, + } + + +def _one_decision(**outputs: Any) -> list[dict[str, Any]]: + return [ + { + "id": "input-1", + "decisions": [{"decisionName": "RiskGrade", "outputs": outputs}], + } + ] + + +class TestRunContext: + @pytest.mark.parametrize( + "rule_name", + ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], + ) + def test_rejects_unsafe_rule_names( + self, service: BusinessRulesService, rule_name: str + ) -> None: + with pytest.raises(ValueError, match="name"): + service.run(rule_name, {}, folder_key=FOLDER_KEY) + + @pytest.mark.parametrize("value", [["age", 14], "age=14", 14]) + def test_rejects_non_mapping_input( + self, httpx_mock: HTTPXMock, service: BusinessRulesService, value: Any + ) -> None: + with pytest.raises(ValueError, match="input must be a mapping"): + service.run(RULE, value, folder_key=FOLDER_KEY) + + assert httpx_mock.get_requests() == [] + + def test_rejects_oversized_input(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="256 keys"): + service.run(RULE, {f"k{i}": i for i in range(257)}, folder_key=FOLDER_KEY) + + +class TestFolder: + def test_resolves_folder_path_to_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_path="Finance/Loans") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-folderpath" not in request.headers + + def test_falls_back_to_env_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + + def test_falls_back_to_env_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/Loans") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-folderpath" not in request.headers + + async def test_falls_back_to_env_folder_path_async( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + await service.run_async(RULE, {}) + + folders_service.retrieve_folder_key_async.assert_awaited_once_with( + "Finance/Loans" + ) + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_env_folder_key_wins_over_env_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Finance/Loans") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + + def test_organization_unit_id_alone_is_sent_as_given( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, organization_unit_id=3373422) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-organizationunitid"] == "3373422" + assert HEADER_FOLDER_KEY not in request.headers + + def test_folder_key_and_organization_unit_id_are_both_sent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_key=FOLDER_KEY, organization_unit_id=42) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_folder_path_and_organization_unit_id_are_both_sent( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_path="Finance", organization_unit_id=42) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_env_folder_does_not_replace_an_explicit_organization_unit_id( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, organization_unit_id=42) + + request = httpx_mock.get_request() + assert request is not None + assert HEADER_FOLDER_KEY not in request.headers + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_explain_needs_a_folder_key_not_only_an_organization_unit_id( + self, httpx_mock: HTTPXMock, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="folder key is required for explain"): + service.run(RULE, {}, organization_unit_id=42, explain=True) + + assert httpx_mock.get_requests() == [] + + def test_explain_with_organization_unit_id_takes_the_env_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, organization_unit_id=42, explain=True) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_explain_with_folder_path_sends_the_looked_up_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_path="Finance", explain=True) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert json.loads(request.content)["explain"] is True + + async def test_organization_unit_id_alone_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + await service.run_async(RULE, {}, organization_unit_id=42) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-organizationunitid"] == "42" + assert HEADER_FOLDER_KEY not in request.headers + + def test_deployed_requires_a_folder(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="deployed business rule"): + service.run(RULE, {}) + + def test_rejects_both_folder_key_and_path( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="Only one of"): + service.run(RULE, {}, folder_key=FOLDER_KEY, folder_path="Finance") + + +class TestDeployed: + def test_sends_single_input_and_maps_decisions( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + method="POST", + json=_response( + _one_decision(Grade="B", Rate=3.5), + businessRuleName="Loan Pricing", + version="1.0.3", + ), + ) + + result = service.run( + "Loan Pricing", + {"creditScore": 740}, + version="1.0.3", + decision_names=["RiskGrade"], + explain=True, + folder_key=FOLDER_KEY, + ) + + assert result.mode == RunMode.DEPLOYED + assert result.status == BusinessRuleStatus.SUCCESS + assert result.decisions[0].decision_name == "RiskGrade" + assert result.decisions[0].outputs == {"Grade": "B", "Rate": 3.5} + assert result.errors == [] + assert result.top_level_error is None + assert result.business_rule_name == "Loan Pricing" + assert result.version == "1.0.3" + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "businessRuleName": "Loan Pricing", + "version": "1.0.3", + "decisionNames": ["RiskGrade"], + "explain": True, + "inputs": [{"id": "input-1", "data": {"creditScore": 740}}], + } + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert request.headers["Authorization"] == "Bearer secret" + assert ( + request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.BusinessRulesService.run/{version}" + ) + + def test_omits_optional_fields( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "version" not in body + assert "decisionNames" not in body + assert body["explain"] is False + + async def test_run_async_resolves_folder_path( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response(_one_decision(x=1))) + + result = await service.run_async(RULE, {"a": 1}, folder_path="Finance") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance") + assert result.mode == RunMode.DEPLOYED + assert result.status == BusinessRuleStatus.SUCCESS + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + +class TestResult: + def test_partial_success_on_207( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + [ + { + "id": "input-1", + "decisions": [ + {"decisionName": "RiskGrade", "outputs": {"Grade": "B"}}, + { + "decisionName": "Payment", + "error": { + "code": "DECISION_FAILED", + "message": "null arithmetic", + }, + }, + ], + } + ] + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.PARTIAL_SUCCESS + assert result.decisions[1].error is not None + assert result.decisions[1].error.code == "DECISION_FAILED" + + def test_input_level_error_is_all_failed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + [ + { + "id": "input-1", + "errors": [ + { + "code": "INPUT_VALIDATION_FAILED", + "message": "creditScore must be a number", + } + ], + } + ] + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + assert result.errors[0].code == "INPUT_VALIDATION_FAILED" + + def test_not_evaluated_carries_top_level_error( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=207, + json=_response( + [], + notEvaluatedIds=["input-1"], + error={"code": "BATCH_TIMEOUT", "message": "ran out of time"}, + ), + ) + + result = service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert result.status == BusinessRuleStatus.ALL_FAILED + assert result.decisions == [] + assert result.top_level_error == "BATCH_TIMEOUT" + + def test_raises_enriched_exception_on_error_envelope( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ) -> None: + httpx_mock.add_response( + url=evaluate_url, + status_code=404, + json={ + "businessRuleName": "Missing", + "error": {"code": "RULE_NOT_FOUND", "message": "no such rule"}, + "meta": {"timestamp": "2026-09-28T00:00:00Z"}, + }, + ) + + with pytest.raises(EnrichedException) as exc: + service.run("Missing", {}, folder_key=FOLDER_KEY) + + assert exc.value.status_code == 404 + + +EXPLICIT_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +EXPLICIT_SPAN_ID = "00f067aa0ba902b7" +AMBIENT_TRACE_ID = "0af7651916cd43dd8448eb211c80319c" +AMBIENT_SPAN_ID = "b7ad6b7169203331" + + +@pytest.fixture +def ambient_span() -> Iterator[None]: + span = NonRecordingSpan( + SpanContext( + trace_id=int(AMBIENT_TRACE_ID, 16), + span_id=int(AMBIENT_SPAN_ID, 16), + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + ) + with trace.use_span(span): + yield + + +class TestTraceContext: + def test_explicit_trace_context_wins_over_ambient_span( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_explicit_trace_context_wins_over_uipath_trace_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", AMBIENT_TRACE_ID) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_ambient_trace_is_used_without_trace_context( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT].startswith(f"00-{AMBIENT_TRACE_ID}-") + + def test_override_does_not_leak_into_the_next_call( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + service.run(RULE, {}, folder_key=FOLDER_KEY) + + first, second = httpx_mock.get_requests() + assert EXPLICIT_TRACE_ID in first.headers[TRACEPARENT] + assert second.headers[TRACEPARENT].startswith(f"00-{AMBIENT_TRACE_ID}-") + + async def test_explicit_trace_context_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + await service.run_async(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + def test_explicit_trace_context_survives_a_retry( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, status_code=503) + httpx_mock.add_response(url=evaluate_url, json=_response([])) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, folder_key=FOLDER_KEY, trace_context=explicit) + + first, second = httpx_mock.get_requests() + expected = f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + assert first.headers[TRACEPARENT] == expected + assert second.headers[TRACEPARENT] == expected + + def test_trace_id_accepts_uuid_form_and_upper_case(self) -> None: + context = TraceContext( + trace_id="4BF92F35-77B3-4DA6-A3CE-929D0E0E4736", + parent_span_id="00F067AA0BA902B7", + ) + + assert context.to_traceparent() == ( + f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + @pytest.mark.parametrize( + ("trace_id", "parent_span_id", "message"), + [ + ("abc", EXPLICIT_SPAN_ID, "trace_id must be 32 hex"), + ("z" * 32, EXPLICIT_SPAN_ID, "trace_id must be 32 hex"), + ("0" * 32, EXPLICIT_SPAN_ID, "trace_id must not be all zeros"), + (EXPLICIT_TRACE_ID, "abc", "parent_span_id must be 16 hex"), + (EXPLICIT_TRACE_ID, "0" * 16, "parent_span_id must not be all zeros"), + ], + ) + def test_rejects_malformed_ids( + self, trace_id: str, parent_span_id: str, message: str + ) -> None: + with pytest.raises(ValidationError, match=message): + TraceContext(trace_id=trace_id, parent_span_id=parent_span_id) + + +@pytest.fixture +def rule_override() -> Iterator[None]: + overwrite = GenericResourceOverwrite( + resource_type="businessRule", + name="Loan Pricing EU", + folder_path="Finance/EU", + ) + token = _resource_overwrites.set({"businessRule.Loan Pricing": overwrite}) + try: + yield + finally: + _resource_overwrites.reset(token) + + +class TestResourceOverride: + def test_parser_accepts_business_rule_bindings(self) -> None: + overwrite = ResourceOverwriteParser.parse( + "businessRule.Loan Pricing", + {"name": "Loan Pricing EU", "folderPath": "Finance/EU"}, + ) + + assert overwrite.resource_identifier == "Loan Pricing EU" + assert overwrite.folder_identifier == "Finance/EU" + + def test_override_replaces_rule_name_and_folder( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_path="Finance") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + async def test_override_applies_to_run_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + await service.run_async(RULE, {}, folder_path="Finance") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + + def test_override_folder_replaces_callers_folder_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run(RULE, {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + async def test_override_folder_replaces_callers_folder_key_async( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + await service.run_async(RULE, {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key_async.assert_awaited_once_with("Finance/EU") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_callers_folder_key_kept_without_a_matching_override( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run("Risk Tier", {}, folder_key="callers-folder-key") + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "callers-folder-key" + + def test_other_rules_are_not_overridden( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + evaluate_url: str, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response([])) + + service.run("Risk Tier", {}, folder_path="Finance") + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Risk Tier" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 54ab45e3d..fee1df2ad 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 00cd8a8f2..c43717048 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },