chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableWorkflowClient.
|
||||
|
||||
Covers starting workflows, awaiting output (including error/timeout paths),
|
||||
parsing pending human-in-the-loop (HITL) requests from custom status, and
|
||||
sanitizing HITL responses before delivery.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import WorkflowEvent
|
||||
|
||||
from agent_framework_durabletask import DurableWorkflowClient
|
||||
from agent_framework_durabletask._workflows.naming import workflow_orchestrator_name
|
||||
from agent_framework_durabletask._workflows.serialization import serialize_value, serialize_workflow_event
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Receipt:
|
||||
"""Module-level dataclass so it is picklable by serialize_value."""
|
||||
|
||||
order_id: int
|
||||
total: float
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client() -> Mock:
|
||||
"""Create a mock TaskHubGrpcClient."""
|
||||
return Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_client(mock_client: Mock) -> DurableWorkflowClient:
|
||||
"""Create a DurableWorkflowClient wrapping the mock client."""
|
||||
return DurableWorkflowClient(mock_client)
|
||||
|
||||
|
||||
class TestStartWorkflow:
|
||||
"""Test starting workflow orchestrations."""
|
||||
|
||||
def test_start_workflow_schedules_orchestrator(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""start_workflow schedules the per-workflow orchestration by name."""
|
||||
mock_client.schedule_new_orchestration.return_value = "instance-1"
|
||||
|
||||
result = workflow_client.start_workflow(input="hello", workflow_name="orders")
|
||||
|
||||
assert result == "instance-1"
|
||||
mock_client.schedule_new_orchestration.assert_called_once_with(
|
||||
workflow_orchestrator_name("orders"), input="hello", instance_id=None
|
||||
)
|
||||
|
||||
def test_start_workflow_passes_non_string_input_unchanged(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""Non-string payloads are forwarded as-is (no string coercion)."""
|
||||
mock_client.schedule_new_orchestration.return_value = "instance-2"
|
||||
payload = {"order_id": 42, "items": ["a", "b"]}
|
||||
|
||||
workflow_client.start_workflow(input=payload, workflow_name="orders")
|
||||
|
||||
_, kwargs = mock_client.schedule_new_orchestration.call_args
|
||||
assert kwargs["input"] == payload
|
||||
|
||||
def test_start_workflow_strips_forged_subworkflow_envelope(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""Reserved sub-workflow envelope keys in client input are stripped at the boundary.
|
||||
|
||||
Only an internal child dispatch may carry these keys; if untrusted input could,
|
||||
it would smuggle a payload onto the orchestrator's trusted (pickle) path.
|
||||
"""
|
||||
mock_client.schedule_new_orchestration.return_value = "i"
|
||||
forged = {"__subworkflow_input__": {"__pickled__": "evil", "__type__": "x"}, "real": 1}
|
||||
|
||||
workflow_client.start_workflow(input=forged, workflow_name="orders")
|
||||
|
||||
_, kwargs = mock_client.schedule_new_orchestration.call_args
|
||||
assert kwargs["input"] == {"real": 1}
|
||||
assert "__subworkflow_input__" not in kwargs["input"]
|
||||
|
||||
def test_start_workflow_forwards_instance_id(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""An explicit instance id is forwarded to the underlying client."""
|
||||
mock_client.schedule_new_orchestration.return_value = "explicit-id"
|
||||
|
||||
workflow_client.start_workflow(input="x", workflow_name="orders", instance_id="explicit-id")
|
||||
|
||||
_, kwargs = mock_client.schedule_new_orchestration.call_args
|
||||
assert kwargs["instance_id"] == "explicit-id"
|
||||
|
||||
|
||||
class TestWorkflowNameTargeting:
|
||||
"""Resolving the target workflow name from a default or per-call value."""
|
||||
|
||||
def test_uses_constructor_default(self, mock_client: Mock) -> None:
|
||||
"""A client default workflow name is used when none is passed per call."""
|
||||
client = DurableWorkflowClient(mock_client, workflow_name="billing")
|
||||
mock_client.schedule_new_orchestration.return_value = "i"
|
||||
|
||||
client.start_workflow(input="x")
|
||||
|
||||
mock_client.schedule_new_orchestration.assert_called_once_with(
|
||||
workflow_orchestrator_name("billing"), input="x", instance_id=None
|
||||
)
|
||||
|
||||
def test_per_call_overrides_default(self, mock_client: Mock) -> None:
|
||||
"""A per-call workflow name overrides the constructor default."""
|
||||
client = DurableWorkflowClient(mock_client, workflow_name="billing")
|
||||
mock_client.schedule_new_orchestration.return_value = "i"
|
||||
|
||||
client.start_workflow(input="x", workflow_name="orders")
|
||||
|
||||
mock_client.schedule_new_orchestration.assert_called_once_with(
|
||||
workflow_orchestrator_name("orders"), input="x", instance_id=None
|
||||
)
|
||||
|
||||
def test_raises_when_no_name_resolvable(self, workflow_client: DurableWorkflowClient) -> None:
|
||||
"""With no default and no per-call name, starting raises a clear error."""
|
||||
with pytest.raises(ValueError, match="No workflow name"):
|
||||
workflow_client.start_workflow(input="x")
|
||||
|
||||
|
||||
class TestOwnershipValidation:
|
||||
"""Opt-in validation that an instance belongs to the targeted workflow."""
|
||||
|
||||
def test_runtime_status_returns_none_for_foreign_instance(self, mock_client: Mock) -> None:
|
||||
"""A status query scoped to a workflow returns None for a foreign instance."""
|
||||
client = DurableWorkflowClient(mock_client, workflow_name="orders")
|
||||
state = Mock()
|
||||
state.name = workflow_orchestrator_name("billing") # different workflow
|
||||
state.runtime_status.name = "RUNNING"
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
assert client.get_runtime_status("instance-1") is None
|
||||
|
||||
def test_runtime_status_returns_status_for_owned_instance(self, mock_client: Mock) -> None:
|
||||
"""A status query returns the status for an instance of the targeted workflow."""
|
||||
client = DurableWorkflowClient(mock_client, workflow_name="orders")
|
||||
state = Mock()
|
||||
state.name = workflow_orchestrator_name("orders")
|
||||
state.runtime_status.name = "RUNNING"
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
assert client.get_runtime_status("instance-1") == "RUNNING"
|
||||
|
||||
def test_pending_hitl_empty_for_foreign_instance(self, mock_client: Mock) -> None:
|
||||
"""Pending HITL is empty for an instance of a different workflow."""
|
||||
client = DurableWorkflowClient(mock_client, workflow_name="orders")
|
||||
state = Mock()
|
||||
state.name = workflow_orchestrator_name("billing")
|
||||
state.serialized_custom_status = json.dumps({"pending_requests": {"req-1": {"source_executor_id": "x"}}})
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
assert client.get_pending_hitl_requests("instance-1") == []
|
||||
|
||||
def test_send_hitl_rejects_foreign_instance(self, mock_client: Mock) -> None:
|
||||
"""Sending a HITL response to a foreign instance raises and does not deliver."""
|
||||
client = DurableWorkflowClient(mock_client, workflow_name="orders")
|
||||
state = Mock()
|
||||
state.name = workflow_orchestrator_name("billing")
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
with pytest.raises(ValueError, match="does not belong"):
|
||||
client.send_hitl_response("instance-1", "req-1", {"approved": True})
|
||||
|
||||
mock_client.raise_orchestration_event.assert_not_called()
|
||||
|
||||
def test_send_hitl_allows_owned_instance(self, mock_client: Mock) -> None:
|
||||
"""Sending a HITL response to an owned instance delivers the event."""
|
||||
client = DurableWorkflowClient(mock_client, workflow_name="orders")
|
||||
state = Mock()
|
||||
state.name = workflow_orchestrator_name("orders")
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
client.send_hitl_response("instance-1", "req-1", {"approved": True})
|
||||
|
||||
mock_client.raise_orchestration_event.assert_called_once()
|
||||
|
||||
|
||||
class TestAwaitWorkflowOutput:
|
||||
"""Test awaiting workflow completion and output."""
|
||||
|
||||
def test_returns_deserialized_output_on_completion(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A COMPLETED workflow returns its deserialized output."""
|
||||
metadata = Mock()
|
||||
metadata.runtime_status.name = "COMPLETED"
|
||||
metadata.serialized_output = json.dumps(["result"])
|
||||
mock_client.wait_for_orchestration_completion.return_value = metadata
|
||||
|
||||
output = workflow_client.await_workflow_output("instance-1")
|
||||
|
||||
assert output == ["result"]
|
||||
|
||||
def test_returns_none_when_no_output(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""A COMPLETED workflow with no output returns None."""
|
||||
metadata = Mock()
|
||||
metadata.runtime_status.name = "COMPLETED"
|
||||
metadata.serialized_output = None
|
||||
mock_client.wait_for_orchestration_completion.return_value = metadata
|
||||
|
||||
assert workflow_client.await_workflow_output("instance-1") is None
|
||||
|
||||
def test_reconstructs_typed_outputs(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""Typed outputs encoded by the activity come back as objects, not marker dicts."""
|
||||
receipt = _Receipt(order_id=7, total=19.99)
|
||||
# The shared activity stores each yielded output via serialize_value(), so a
|
||||
# typed object is persisted as a checkpoint-marker dict.
|
||||
metadata = Mock()
|
||||
metadata.runtime_status.name = "COMPLETED"
|
||||
metadata.serialized_output = json.dumps([serialize_value(receipt)])
|
||||
mock_client.wait_for_orchestration_completion.return_value = metadata
|
||||
|
||||
output = workflow_client.await_workflow_output("instance-1")
|
||||
|
||||
assert output == [receipt]
|
||||
assert isinstance(output[0], _Receipt)
|
||||
|
||||
def test_raises_timeout_when_not_completed(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""A None metadata (no completion) raises TimeoutError."""
|
||||
mock_client.wait_for_orchestration_completion.return_value = None
|
||||
|
||||
with pytest.raises(TimeoutError, match="did not complete"):
|
||||
workflow_client.await_workflow_output("instance-1", timeout_seconds=5)
|
||||
|
||||
def test_raises_runtime_error_on_failed_status(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A non-COMPLETED status raises RuntimeError."""
|
||||
metadata = Mock()
|
||||
metadata.runtime_status.name = "FAILED"
|
||||
metadata.serialized_output = "boom"
|
||||
mock_client.wait_for_orchestration_completion.return_value = metadata
|
||||
|
||||
with pytest.raises(RuntimeError, match="status FAILED"):
|
||||
workflow_client.await_workflow_output("instance-1")
|
||||
|
||||
|
||||
class TestGetRuntimeStatus:
|
||||
"""Test reading the workflow's runtime status."""
|
||||
|
||||
def test_returns_status_name(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""The runtime status name is returned when state is available."""
|
||||
state = Mock()
|
||||
state.runtime_status.name = "RUNNING"
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
assert workflow_client.get_runtime_status("instance-1") == "RUNNING"
|
||||
|
||||
def test_returns_none_when_no_state(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""No orchestration state yields None (status unknown)."""
|
||||
mock_client.get_orchestration_state.return_value = None
|
||||
|
||||
assert workflow_client.get_runtime_status("instance-1") is None
|
||||
|
||||
|
||||
class TestGetPendingHitlRequests:
|
||||
"""Test parsing pending HITL requests from custom status."""
|
||||
|
||||
def _state_with_status(self, status: object) -> Mock:
|
||||
state = Mock()
|
||||
state.serialized_custom_status = json.dumps(status) if status is not None else None
|
||||
return state
|
||||
|
||||
def test_returns_empty_when_no_state(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""No orchestration state yields an empty list."""
|
||||
mock_client.get_orchestration_state.return_value = None
|
||||
|
||||
assert workflow_client.get_pending_hitl_requests("instance-1") == []
|
||||
|
||||
def test_returns_empty_when_status_blank(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""A blank custom status yields an empty list."""
|
||||
state = Mock()
|
||||
state.serialized_custom_status = ""
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
assert workflow_client.get_pending_hitl_requests("instance-1") == []
|
||||
|
||||
def test_returns_empty_on_invalid_json(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""Malformed custom status JSON yields an empty list."""
|
||||
state = Mock()
|
||||
state.serialized_custom_status = "{not-json"
|
||||
mock_client.get_orchestration_state.return_value = state
|
||||
|
||||
assert workflow_client.get_pending_hitl_requests("instance-1") == []
|
||||
|
||||
def test_parses_pending_requests(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""Pending requests are normalized into the documented shape."""
|
||||
status = {
|
||||
"pending_requests": {
|
||||
"req-1": {
|
||||
"request_id": "req-1",
|
||||
"source_executor_id": "approver",
|
||||
"data": {"prompt": "approve?"},
|
||||
"request_type": "ApprovalRequest",
|
||||
"response_type": "ApprovalResponse",
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
|
||||
|
||||
requests = workflow_client.get_pending_hitl_requests("instance-1")
|
||||
|
||||
assert requests == [
|
||||
{
|
||||
"request_id": "req-1",
|
||||
"source_executor_id": "approver",
|
||||
"data": {"prompt": "approve?"},
|
||||
"request_type": "ApprovalRequest",
|
||||
"response_type": "ApprovalResponse",
|
||||
}
|
||||
]
|
||||
|
||||
def test_falls_back_to_dict_key_for_request_id(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""When a request omits request_id, the dict key is used."""
|
||||
status = {"pending_requests": {"req-key": {"source_executor_id": "x"}}}
|
||||
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
|
||||
|
||||
requests = workflow_client.get_pending_hitl_requests("instance-1")
|
||||
|
||||
assert requests[0]["request_id"] == "req-key"
|
||||
|
||||
def test_ignores_non_dict_entries(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
|
||||
"""Non-dict request entries are skipped."""
|
||||
status = {"pending_requests": {"req-1": "not-a-dict"}}
|
||||
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
|
||||
|
||||
assert workflow_client.get_pending_hitl_requests("instance-1") == []
|
||||
|
||||
def test_returns_empty_when_pending_not_dict(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A non-dict pending_requests field yields an empty list."""
|
||||
status = {"pending_requests": ["unexpected"]}
|
||||
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
|
||||
|
||||
assert workflow_client.get_pending_hitl_requests("instance-1") == []
|
||||
|
||||
|
||||
class TestSendHitlResponse:
|
||||
"""Test delivering HITL responses."""
|
||||
|
||||
def test_raises_orchestration_event_with_request_id(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""The response is delivered as an external event named by request id."""
|
||||
workflow_client.send_hitl_response("instance-1", "req-1", {"approved": True})
|
||||
|
||||
mock_client.raise_orchestration_event.assert_called_once()
|
||||
_, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
assert kwargs["event_name"] == "req-1"
|
||||
assert kwargs["data"] == {"approved": True}
|
||||
|
||||
def test_strips_pickle_markers_before_delivery(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A crafted pickle-marker payload is neutralized before reaching the worker.
|
||||
|
||||
The HITL response is sent to the worker which deserializes it, so a payload
|
||||
carrying the checkpoint ``__pickled__`` marker must be stripped client-side
|
||||
(regression guard for the strip_pickle_markers call in send_hitl_response).
|
||||
"""
|
||||
malicious = {"__pickled__": "<crafted-base64-payload>", "approved": True}
|
||||
|
||||
workflow_client.send_hitl_response("instance-1", "req-1", malicious)
|
||||
|
||||
_, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
# The whole marker-bearing dict is neutralized (replaced with None) rather
|
||||
# than forwarded, so it can never reach pickle.loads on the worker.
|
||||
assert kwargs["data"] is None
|
||||
|
||||
|
||||
class TestStreamWorkflow:
|
||||
"""Test streaming typed workflow events by polling custom status."""
|
||||
|
||||
def _state(self, *, status: str, events: list[dict] | None = None) -> Mock:
|
||||
state = Mock()
|
||||
state.runtime_status.name = status
|
||||
if events is None:
|
||||
state.serialized_custom_status = None
|
||||
else:
|
||||
state.serialized_custom_status = json.dumps({"state": "running", "events": events})
|
||||
return state
|
||||
|
||||
async def test_streams_events_in_order_until_terminal(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""Events accrue across polls and stream in order; streaming ends at a terminal state."""
|
||||
# Each poll returns a growing accumulated event list, then a terminal status.
|
||||
mock_client.get_orchestration_state.side_effect = [
|
||||
self._state(status="RUNNING", events=[{"type": "executor_invoked", "executor_id": "a"}]),
|
||||
self._state(
|
||||
status="RUNNING",
|
||||
events=[
|
||||
{"type": "executor_invoked", "executor_id": "a"},
|
||||
{"type": "executor_completed", "executor_id": "a"},
|
||||
],
|
||||
),
|
||||
self._state(
|
||||
status="COMPLETED",
|
||||
events=[
|
||||
{"type": "executor_invoked", "executor_id": "a"},
|
||||
{"type": "executor_completed", "executor_id": "a"},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)]
|
||||
|
||||
# Each accumulated event is yielded exactly once, in order, as a typed event.
|
||||
assert all(isinstance(e, WorkflowEvent) for e in seen)
|
||||
assert [e.type for e in seen] == ["executor_invoked", "executor_completed"]
|
||||
assert [e.executor_id for e in seen] == ["a", "a"]
|
||||
|
||||
async def test_terminal_with_no_status_yields_nothing(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A workflow that is already terminal with no custom status streams no events."""
|
||||
mock_client.get_orchestration_state.return_value = self._state(status="COMPLETED")
|
||||
|
||||
seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)]
|
||||
|
||||
assert seen == []
|
||||
|
||||
async def test_streams_typed_event_data_roundtrip(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""An output event's data is reconstructed into its original typed object."""
|
||||
receipt = _Receipt(order_id=7, total=42.5)
|
||||
serialized_event = serialize_workflow_event(WorkflowEvent("output", data=receipt, executor_id="processor"))
|
||||
mock_client.get_orchestration_state.side_effect = [
|
||||
self._state(status="RUNNING", events=[serialized_event]),
|
||||
self._state(status="COMPLETED", events=[serialized_event]),
|
||||
]
|
||||
|
||||
seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)]
|
||||
|
||||
assert len(seen) == 1
|
||||
assert isinstance(seen[0], WorkflowEvent)
|
||||
assert seen[0].type == "output"
|
||||
assert seen[0].executor_id == "processor"
|
||||
assert seen[0].data == receipt
|
||||
|
||||
|
||||
class TestRunWorkflow:
|
||||
"""Test the async run_workflow convenience (start + optional wait)."""
|
||||
|
||||
async def test_waits_and_returns_output_by_default(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""By default run_workflow starts the workflow and returns its deserialized output."""
|
||||
mock_client.schedule_new_orchestration.return_value = "instance-1"
|
||||
metadata = Mock()
|
||||
metadata.name = workflow_orchestrator_name("orders")
|
||||
metadata.runtime_status.name = "COMPLETED"
|
||||
metadata.serialized_output = json.dumps(["done"])
|
||||
mock_client.wait_for_orchestration_completion.return_value = metadata
|
||||
|
||||
result = await workflow_client.run_workflow(input="hello", workflow_name="orders")
|
||||
|
||||
assert result == ["done"]
|
||||
mock_client.schedule_new_orchestration.assert_called_once()
|
||||
mock_client.wait_for_orchestration_completion.assert_called_once()
|
||||
|
||||
async def test_no_wait_returns_instance_id_without_awaiting(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""With wait=False, run_workflow returns the instance id and does not await completion."""
|
||||
mock_client.schedule_new_orchestration.return_value = "instance-2"
|
||||
|
||||
result = await workflow_client.run_workflow(input="hello", workflow_name="orders", wait=False)
|
||||
|
||||
assert result == "instance-2"
|
||||
mock_client.wait_for_orchestration_completion.assert_not_called()
|
||||
|
||||
|
||||
class TestSubworkflowHitl:
|
||||
"""Sub-workflow HITL: qualified request ids in/out (B2 single-surface addressing)."""
|
||||
|
||||
@staticmethod
|
||||
def _states(mock_client: Mock, by_instance: dict[str, dict | None]) -> None:
|
||||
"""Wire get_orchestration_state to return a state per instance id.
|
||||
|
||||
Each value is the custom-status dict for that instance (or None for no
|
||||
status). ``name`` is unset so ownership validation is skipped (these tests
|
||||
construct the client without a workflow_name default).
|
||||
"""
|
||||
|
||||
def _get_state(instance_id: str) -> Mock | None:
|
||||
if instance_id not in by_instance:
|
||||
return None
|
||||
status = by_instance[instance_id]
|
||||
state = Mock()
|
||||
state.serialized_custom_status = json.dumps(status) if status is not None else None
|
||||
return state
|
||||
|
||||
mock_client.get_orchestration_state.side_effect = _get_state
|
||||
|
||||
def test_collects_nested_request_with_qualified_id(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A request pending in a child sub-workflow surfaces with an {executor}~{ordinal}~{id} id."""
|
||||
self._states(
|
||||
mock_client,
|
||||
{
|
||||
"parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}},
|
||||
"child-1": {
|
||||
"state": "waiting_for_human_input",
|
||||
"pending_requests": {"req-9": {"request_id": "req-9", "source_executor_id": "inner_node"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
requests = workflow_client.get_pending_hitl_requests("parent")
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0]["request_id"] == "sub~0~req-9"
|
||||
assert requests[0]["source_executor_id"] == "inner_node"
|
||||
|
||||
def test_collects_parent_and_nested_requests_together(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""Top-level and nested pending requests are both returned (nested qualified)."""
|
||||
self._states(
|
||||
mock_client,
|
||||
{
|
||||
"parent": {
|
||||
"state": "waiting_for_human_input",
|
||||
"pending_requests": {"top-1": {"request_id": "top-1", "source_executor_id": "outer_node"}},
|
||||
"subworkflows": {"sub": ["child-1"]},
|
||||
},
|
||||
"child-1": {
|
||||
"state": "waiting_for_human_input",
|
||||
"pending_requests": {"inner-1": {"request_id": "inner-1", "source_executor_id": "inner_node"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
ids = {r["request_id"] for r in workflow_client.get_pending_hitl_requests("parent")}
|
||||
|
||||
assert ids == {"top-1", "sub~0~inner-1"}
|
||||
|
||||
def test_collects_deeply_nested_request_with_full_path(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""Two levels of nesting accumulate a full {a}~{i}~{b}~{j}~{id} path."""
|
||||
self._states(
|
||||
mock_client,
|
||||
{
|
||||
"parent": {"state": "running", "subworkflows": {"mid": ["child-1"]}},
|
||||
"child-1": {"state": "running", "subworkflows": {"leaf": ["child-2"]}},
|
||||
"child-2": {
|
||||
"state": "waiting_for_human_input",
|
||||
"pending_requests": {"deep": {"request_id": "deep", "source_executor_id": "leaf_node"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
requests = workflow_client.get_pending_hitl_requests("parent")
|
||||
|
||||
assert [r["request_id"] for r in requests] == ["mid~0~leaf~0~deep"]
|
||||
|
||||
def test_send_qualified_response_routes_to_child_instance(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A qualified id resolves to the owning child instance and bare request id."""
|
||||
self._states(
|
||||
mock_client,
|
||||
{"parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}}},
|
||||
)
|
||||
|
||||
workflow_client.send_hitl_response("parent", "sub~0~req-9", {"approved": True})
|
||||
|
||||
mock_client.raise_orchestration_event.assert_called_once()
|
||||
args, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
assert args[0] == "child-1"
|
||||
assert kwargs["event_name"] == "req-9"
|
||||
assert kwargs["data"] == {"approved": True}
|
||||
|
||||
def test_send_deeply_qualified_response_routes_to_leaf(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A two-level qualified id lands on the leaf child with the bare id."""
|
||||
self._states(
|
||||
mock_client,
|
||||
{
|
||||
"parent": {"state": "running", "subworkflows": {"mid": ["child-1"]}},
|
||||
"child-1": {"state": "running", "subworkflows": {"leaf": ["child-2"]}},
|
||||
},
|
||||
)
|
||||
|
||||
workflow_client.send_hitl_response("parent", "mid~0~leaf~0~deep", {"ok": 1})
|
||||
|
||||
args, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
assert args[0] == "child-2"
|
||||
assert kwargs["event_name"] == "deep"
|
||||
|
||||
def test_send_qualified_response_unknown_subworkflow_raises(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A qualified id for an inactive sub-workflow raises and delivers nothing."""
|
||||
self._states(mock_client, {"parent": {"state": "running"}}) # no subworkflows map
|
||||
|
||||
with pytest.raises(ValueError, match="No active sub-workflow"):
|
||||
workflow_client.send_hitl_response("parent", "sub~0~req-9", {"approved": True})
|
||||
|
||||
mock_client.raise_orchestration_event.assert_not_called()
|
||||
|
||||
def test_unqualified_response_still_targets_named_instance(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A plain (unqualified) request id targets the given instance directly."""
|
||||
self._states(mock_client, {"parent": {"state": "waiting_for_human_input"}})
|
||||
|
||||
workflow_client.send_hitl_response("parent", "req-1", {"approved": True})
|
||||
|
||||
args, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
assert args[0] == "parent"
|
||||
assert kwargs["event_name"] == "req-1"
|
||||
|
||||
def test_multiple_children_of_one_executor_stay_addressable(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""Two children dispatched by one node are qualified by ordinal, not collapsed."""
|
||||
self._states(
|
||||
mock_client,
|
||||
{
|
||||
"parent": {"state": "running", "subworkflows": {"sub": ["child-1", "child-2"]}},
|
||||
"child-1": {
|
||||
"state": "waiting_for_human_input",
|
||||
"pending_requests": {"r1": {"request_id": "r1", "source_executor_id": "a"}},
|
||||
},
|
||||
"child-2": {
|
||||
"state": "waiting_for_human_input",
|
||||
"pending_requests": {"r2": {"request_id": "r2", "source_executor_id": "b"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
ids = {r["request_id"] for r in workflow_client.get_pending_hitl_requests("parent")}
|
||||
assert ids == {"sub~0~r1", "sub~1~r2"}
|
||||
|
||||
# The second child (ordinal 1) is reachable, not shadowed by the first.
|
||||
workflow_client.send_hitl_response("parent", "sub~1~r2", {"ok": 1})
|
||||
args, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
assert args[0] == "child-2"
|
||||
assert kwargs["event_name"] == "r2"
|
||||
|
||||
def test_nested_leaf_request_id_with_double_colon_round_trips(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A functional sub-workflow's ``auto::N`` leaf id survives qualification and routing."""
|
||||
self._states(
|
||||
mock_client,
|
||||
{
|
||||
"parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}},
|
||||
"child-1": {
|
||||
"state": "waiting_for_human_input",
|
||||
"pending_requests": {"auto::0": {"request_id": "auto::0", "source_executor_id": "fn"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
requests = workflow_client.get_pending_hitl_requests("parent")
|
||||
assert [r["request_id"] for r in requests] == ["sub~0~auto::0"]
|
||||
|
||||
workflow_client.send_hitl_response("parent", "sub~0~auto::0", {"ok": 1})
|
||||
args, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
assert args[0] == "child-1"
|
||||
assert kwargs["event_name"] == "auto::0"
|
||||
|
||||
def test_top_level_auto_request_id_is_not_treated_as_nested(
|
||||
self, workflow_client: DurableWorkflowClient, mock_client: Mock
|
||||
) -> None:
|
||||
"""A top-level ``auto::N`` id (contains ``::`` but no ``~``) routes to the instance itself."""
|
||||
self._states(mock_client, {"parent": {"state": "waiting_for_human_input"}})
|
||||
|
||||
workflow_client.send_hitl_response("parent", "auto::0", {"approved": True})
|
||||
|
||||
args, kwargs = mock_client.raise_orchestration_event.call_args
|
||||
assert args[0] == "parent"
|
||||
assert kwargs["event_name"] == "auto::0"
|
||||
Reference in New Issue
Block a user