refactor(api): continue decoupling dify_graph from API concerns (#33580)

Signed-off-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
This commit is contained in:
-LAN-
2026-03-25 20:32:24 +08:00
committed by GitHub
parent b7b9b003c9
commit 56593f20b0
487 changed files with 17999 additions and 9186 deletions

View File

@ -6,6 +6,9 @@ from unittest.mock import Mock, patch
import pytest
from sqlalchemy import Engine
from core.workflow.file_reference import build_file_reference
from dify_graph.file.enums import FileTransferMethod, FileType
from dify_graph.file.models import File
from dify_graph.variables.segments import ObjectSegment, StringSegment
from dify_graph.variables.types import SegmentType
from models.model import UploadFile
@ -54,25 +57,18 @@ class TestDraftVarLoaderSimple:
with patch("services.workflow_draft_variable_service.storage") as mock_storage:
mock_storage.load.return_value = test_content.encode()
with patch("factories.variable_factory.segment_to_variable") as mock_segment_to_variable:
mock_variable = Mock()
mock_variable.id = "draft-var-id"
mock_variable.name = "test_variable"
mock_variable.value = StringSegment(value=test_content)
mock_segment_to_variable.return_value = mock_variable
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
# Verify results
assert selector_tuple == ("test-node-id", "test_variable")
assert variable.id == "draft-var-id"
assert variable.name == "test_variable"
assert variable.description == "test description"
assert variable.value == test_content
# Verify results
assert selector_tuple == ("test-node-id", "test_variable")
assert variable.id == "draft-var-id"
assert variable.name == "test_variable"
assert variable.description == "test description"
assert variable.value == test_content
# Verify storage was called correctly
mock_storage.load.assert_called_once_with("storage/key/test.txt")
# Verify storage was called correctly
mock_storage.load.assert_called_once_with("storage/key/test.txt")
def test_load_offloaded_variable_object_type_unit(self, draft_var_loader):
"""Test _load_offloaded_variable with object type - isolated unit test."""
@ -97,31 +93,22 @@ class TestDraftVarLoaderSimple:
with patch("services.workflow_draft_variable_service.storage") as mock_storage:
mock_storage.load.return_value = test_json_content.encode()
mock_segment = ObjectSegment(value=test_object)
draft_var.build_segment_from_serialized_value.return_value = mock_segment
with patch.object(WorkflowDraftVariable, "build_segment_with_type") as mock_build_segment:
mock_segment = ObjectSegment(value=test_object)
mock_build_segment.return_value = mock_segment
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
with patch("factories.variable_factory.segment_to_variable") as mock_segment_to_variable:
mock_variable = Mock()
mock_variable.id = "draft-var-id"
mock_variable.name = "test_object"
mock_variable.value = mock_segment
mock_segment_to_variable.return_value = mock_variable
# Verify results
assert selector_tuple == ("test-node-id", "test_object")
assert variable.id == "draft-var-id"
assert variable.name == "test_object"
assert variable.description == "test description"
assert variable.value == test_object
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
# Verify results
assert selector_tuple == ("test-node-id", "test_object")
assert variable.id == "draft-var-id"
assert variable.name == "test_object"
assert variable.description == "test description"
assert variable.value == test_object
# Verify method calls
mock_storage.load.assert_called_once_with("storage/key/test.json")
mock_build_segment.assert_called_once_with(SegmentType.OBJECT, test_object)
# Verify method calls
mock_storage.load.assert_called_once_with("storage/key/test.json")
draft_var.build_segment_from_serialized_value.assert_called_once_with(SegmentType.OBJECT, test_object)
def test_load_offloaded_variable_missing_variable_file_unit(self, draft_var_loader):
"""Test that assertion error is raised when variable_file is None."""
@ -176,32 +163,23 @@ class TestDraftVarLoaderSimple:
with patch("services.workflow_draft_variable_service.storage") as mock_storage:
mock_storage.load.return_value = test_json_content.encode()
from dify_graph.variables.segments import FloatSegment
with patch.object(WorkflowDraftVariable, "build_segment_with_type") as mock_build_segment:
from dify_graph.variables.segments import FloatSegment
mock_segment = FloatSegment(value=test_number)
draft_var.build_segment_from_serialized_value.return_value = mock_segment
mock_segment = FloatSegment(value=test_number)
mock_build_segment.return_value = mock_segment
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
with patch("factories.variable_factory.segment_to_variable") as mock_segment_to_variable:
mock_variable = Mock()
mock_variable.id = "draft-var-id"
mock_variable.name = "test_number"
mock_variable.value = mock_segment
mock_segment_to_variable.return_value = mock_variable
# Verify results
assert selector_tuple == ("test-node-id", "test_number")
assert variable.id == "draft-var-id"
assert variable.name == "test_number"
assert variable.description == "test number description"
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
# Verify results
assert selector_tuple == ("test-node-id", "test_number")
assert variable.id == "draft-var-id"
assert variable.name == "test_number"
assert variable.description == "test number description"
# Verify method calls
mock_storage.load.assert_called_once_with("storage/key/test_number.json")
mock_build_segment.assert_called_once_with(SegmentType.NUMBER, test_number)
# Verify method calls
mock_storage.load.assert_called_once_with("storage/key/test_number.json")
draft_var.build_segment_from_serialized_value.assert_called_once_with(SegmentType.NUMBER, test_number)
def test_load_offloaded_variable_array_type_unit(self, draft_var_loader):
"""Test _load_offloaded_variable with array type - isolated unit test."""
@ -226,32 +204,83 @@ class TestDraftVarLoaderSimple:
with patch("services.workflow_draft_variable_service.storage") as mock_storage:
mock_storage.load.return_value = test_json_content.encode()
from dify_graph.variables.segments import ArrayAnySegment
with patch.object(WorkflowDraftVariable, "build_segment_with_type") as mock_build_segment:
from dify_graph.variables.segments import ArrayAnySegment
mock_segment = ArrayAnySegment(value=test_array)
draft_var.build_segment_from_serialized_value.return_value = mock_segment
mock_segment = ArrayAnySegment(value=test_array)
mock_build_segment.return_value = mock_segment
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
with patch("factories.variable_factory.segment_to_variable") as mock_segment_to_variable:
mock_variable = Mock()
mock_variable.id = "draft-var-id"
mock_variable.name = "test_array"
mock_variable.value = mock_segment
mock_segment_to_variable.return_value = mock_variable
# Verify results
assert selector_tuple == ("test-node-id", "test_array")
assert variable.id == "draft-var-id"
assert variable.name == "test_array"
assert variable.description == "test array description"
# Execute the method
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
# Verify method calls
mock_storage.load.assert_called_once_with("storage/key/test_array.json")
draft_var.build_segment_from_serialized_value.assert_called_once_with(SegmentType.ARRAY_ANY, test_array)
# Verify results
assert selector_tuple == ("test-node-id", "test_array")
assert variable.id == "draft-var-id"
assert variable.name == "test_array"
assert variable.description == "test array description"
def test_load_offloaded_variable_file_type_rebuilds_storage_backed_payload(self, draft_var_loader):
upload_file = Mock(spec=UploadFile)
upload_file.key = "storage/key/test_file.json"
# Verify method calls
mock_storage.load.assert_called_once_with("storage/key/test_array.json")
mock_build_segment.assert_called_once_with(SegmentType.ARRAY_ANY, test_array)
variable_file = Mock(spec=WorkflowDraftVariableFile)
variable_file.value_type = SegmentType.FILE
variable_file.upload_file = upload_file
draft_var = WorkflowDraftVariable()
draft_var.id = "draft-var-id"
draft_var.app_id = "app-1"
draft_var.node_id = "test-node-id"
draft_var.name = "test_file"
draft_var.description = "test file description"
draft_var._set_selector(["test-node-id", "test_file"])
draft_var.variable_file = variable_file
persisted_file = File(
id="file-1",
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
reference=build_file_reference(record_id="upload-1", storage_key="legacy-storage-key"),
filename="test.txt",
extension=".txt",
mime_type="text/plain",
size=12,
)
rebuilt_file = File(
id="file-1",
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
reference=build_file_reference(record_id="upload-1"),
filename="test.txt",
extension=".txt",
mime_type="text/plain",
size=12,
storage_key="canonical-storage-key",
)
raw_file = {
**persisted_file.model_dump(mode="json"),
"tenant_id": "legacy-tenant",
}
with (
patch("services.workflow_draft_variable_service.storage") as mock_storage,
patch("models.workflow._resolve_workflow_app_tenant_id", return_value="tenant-1"),
patch("models.workflow.build_file_from_stored_mapping", return_value=rebuilt_file) as rebuild_file,
):
mock_storage.load.return_value = json.dumps(raw_file).encode()
selector_tuple, variable = draft_var_loader._load_offloaded_variable(draft_var)
assert selector_tuple == ("test-node-id", "test_file")
assert variable.id == "draft-var-id"
assert variable.name == "test_file"
assert variable.description == "test file description"
assert variable.value == rebuilt_file
rebuild_file.assert_called_once_with(file_mapping=raw_file, tenant_id="tenant-1")
def test_load_variables_with_offloaded_variables_unit(self, draft_var_loader):
"""Test load_variables method with mix of regular and offloaded variables."""

View File

@ -7,8 +7,15 @@ import pytest
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from dify_graph.constants import SYSTEM_VARIABLE_NODE_ID
from dify_graph.enums import BuiltinNodeTypes, SystemVariableKey
from core.workflow.system_variables import SystemVariableKey
from core.workflow.variable_prefixes import (
CONVERSATION_VARIABLE_NODE_ID,
ENVIRONMENT_VARIABLE_NODE_ID,
SYSTEM_VARIABLE_NODE_ID,
)
from dify_graph.enums import BuiltinNodeTypes
from dify_graph.file.enums import FileTransferMethod, FileType
from dify_graph.file.models import File
from dify_graph.variables.segments import StringSegment
from dify_graph.variables.types import SegmentType
from libs.uuid_utils import uuidv7
@ -86,6 +93,20 @@ class TestDraftVariableSaver:
expected_node_id=_NODE_ID,
expected_name="start_input",
),
TestCase(
name="name with `env.` prefix should return the environment node_id",
input_node_id=_NODE_ID,
input_name="env.API_KEY",
expected_node_id=ENVIRONMENT_VARIABLE_NODE_ID,
expected_name="API_KEY",
),
TestCase(
name="name with `conversation.` prefix should return the conversation node_id",
input_node_id=_NODE_ID,
input_name="conversation.session_id",
expected_node_id=CONVERSATION_VARIABLE_NODE_ID,
expected_name="session_id",
),
TestCase(
name="dummy_variable should return the original input node_id",
input_node_id=_NODE_ID,
@ -112,6 +133,47 @@ class TestDraftVariableSaver:
assert node_id == c.expected_node_id, fail_msg
assert name == c.expected_name, fail_msg
def test_build_variables_from_start_mapping_rebuilds_system_files(self):
mock_session = MagicMock(spec=Session)
mock_user = MagicMock(spec=Account)
mock_user.id = str(uuid.uuid4())
saver = DraftVariableSaver(
session=mock_session,
app_id=self._get_test_app_id(),
node_id="start",
node_type=BuiltinNodeTypes.START,
node_execution_id="exec-1",
user=mock_user,
)
rebuilt_file = File(
id="file-1",
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
reference="upload-1",
filename="test.txt",
extension=".txt",
mime_type="text/plain",
size=12,
storage_key="canonical-storage-key",
)
raw_file = {
**rebuilt_file.model_dump(mode="json"),
"tenant_id": "legacy-tenant",
}
with (
patch.object(saver, "_resolve_app_tenant_id", return_value="tenant-1"),
patch(
"services.workflow_draft_variable_service.build_file_from_stored_mapping",
return_value=rebuilt_file,
) as rebuild_file,
):
draft_vars = saver._build_variables_from_start_mapping({"sys.files": [raw_file]})
sys_var = draft_vars[0]
assert sys_var.get_value().value[0] == rebuilt_file
rebuild_file.assert_called_once_with(file_mapping=raw_file, tenant_id="tenant-1")
@pytest.fixture
def mock_session(self):
"""Mock SQLAlchemy session."""
@ -218,6 +280,46 @@ class TestDraftVariableSaver:
str(SystemVariableKey.WORKFLOW_EXECUTION_ID),
}
@patch("services.workflow_draft_variable_service._batch_upsert_draft_variable", autospec=True)
def test_start_node_save_normalizes_reserved_prefix_outputs(self, mock_batch_upsert):
mock_session = MagicMock(spec=Session)
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.tenant_id = "test-tenant-id"
saver = DraftVariableSaver(
session=mock_session,
app_id="test-app-id",
node_id="start-node-id",
node_type=BuiltinNodeTypes.START,
node_execution_id="exec-id",
user=mock_user,
)
saver.save(
outputs={
"env.API_KEY": "secret",
"conversation.session_id": "conversation-1",
"sys.workflow_run_id": "run-id-123",
}
)
mock_batch_upsert.assert_called_once()
draft_vars = mock_batch_upsert.call_args[0][1]
assert len(draft_vars) == 3
env_var = next(v for v in draft_vars if v.node_id == ENVIRONMENT_VARIABLE_NODE_ID)
assert env_var.name == "API_KEY"
assert env_var.editable is False
conversation_var = next(v for v in draft_vars if v.node_id == CONVERSATION_VARIABLE_NODE_ID)
assert conversation_var.name == "session_id"
assert conversation_var.node_execution_id is None
sys_var = next(v for v in draft_vars if v.node_id == SYSTEM_VARIABLE_NODE_ID)
assert sys_var.name == str(SystemVariableKey.WORKFLOW_EXECUTION_ID)
class TestWorkflowDraftVariableService:
def _get_test_app_id(self):

View File

@ -5,16 +5,16 @@ from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import sessionmaker
from dify_graph.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
from dify_graph.enums import BuiltinNodeTypes
from dify_graph.nodes.human_input.entities import (
from core.workflow.human_input_compat import (
EmailDeliveryConfig,
EmailDeliveryMethod,
EmailRecipients,
ExternalRecipient,
HumanInputNodeData,
MemberRecipient,
)
from dify_graph.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
from dify_graph.enums import BuiltinNodeTypes
from dify_graph.nodes.human_input.entities import HumanInputNodeData
from services import workflow_service as workflow_service_module
from services.workflow_service import WorkflowService
@ -23,7 +23,7 @@ def _make_service() -> WorkflowService:
return WorkflowService(session_maker=sessionmaker())
def _build_node_config(delivery_methods: list[EmailDeliveryMethod]) -> NodeConfigDict:
def _build_node_config(delivery_methods: list[EmailDeliveryMethod], *, legacy: bool = False) -> NodeConfigDict:
node_data = HumanInputNodeData(
title="Human Input",
delivery_methods=delivery_methods,
@ -31,6 +31,14 @@ def _build_node_config(delivery_methods: list[EmailDeliveryMethod]) -> NodeConfi
inputs=[],
user_actions=[],
).model_dump(mode="json")
if legacy:
for delivery_method in node_data["delivery_methods"]:
recipients = delivery_method.get("config", {}).get("recipients", {})
if "include_bound_group" in recipients:
recipients["whole_workspace"] = recipients.pop("include_bound_group")
for recipient in recipients.get("items", []):
if "reference_id" in recipient:
recipient["user_id"] = recipient.pop("reference_id")
node_data["type"] = BuiltinNodeTypes.HUMAN_INPUT
return NodeConfigDictAdapter.validate_python({"id": "node-1", "data": node_data})
@ -41,7 +49,7 @@ def _make_email_method(enabled: bool = True, debug_mode: bool = False) -> EmailD
enabled=enabled,
config=EmailDeliveryConfig(
recipients=EmailRecipients(
whole_workspace=False,
include_bound_group=False,
items=[ExternalRecipient(email="tester@example.com")],
),
subject="Test subject",
@ -69,7 +77,7 @@ def test_human_input_delivery_requires_draft_workflow():
def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyPatch):
service = _make_service()
delivery_method = _make_email_method(enabled=False)
node_config = _build_node_config([delivery_method])
node_config = _build_node_config([delivery_method], legacy=True)
workflow = MagicMock()
workflow.get_node_config_by_id.return_value = node_config
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
@ -105,7 +113,7 @@ def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyP
def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.MonkeyPatch):
service = _make_service()
delivery_method = _make_email_method(enabled=True)
node_config = _build_node_config([delivery_method])
node_config = _build_node_config([delivery_method], legacy=True)
workflow = MagicMock()
workflow.get_node_config_by_id.return_value = node_config
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
@ -144,7 +152,7 @@ def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.Mon
def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytest.MonkeyPatch):
service = _make_service()
delivery_method = _make_email_method(enabled=True, debug_mode=True)
node_config = _build_node_config([delivery_method])
node_config = _build_node_config([delivery_method], legacy=True)
workflow = MagicMock()
workflow.get_node_config_by_id.return_value = node_config
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
@ -178,8 +186,8 @@ def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytes
sent_method = test_service_instance.send_test.call_args.kwargs["method"]
assert isinstance(sent_method, EmailDeliveryMethod)
assert sent_method.config.debug_mode is True
assert sent_method.config.recipients.whole_workspace is False
assert sent_method.config.recipients.include_bound_group is False
assert len(sent_method.config.recipients.items) == 1
recipient = sent_method.config.recipients.items[0]
assert isinstance(recipient, MemberRecipient)
assert recipient.user_id == account.id
assert recipient.reference_id == account.id