mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
2180 lines
85 KiB
Python
2180 lines
85 KiB
Python
import dataclasses
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
from unittest.mock import Mock
|
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
|
|
import pytest
|
|
from pydantic import JsonValue
|
|
from sqlalchemy import inspect, select
|
|
from sqlalchemy.orm import Session, configure_mappers
|
|
|
|
from constants import HIDDEN_VALUE
|
|
from core.helper import encrypter
|
|
from core.workflow.file_reference import build_file_reference
|
|
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
|
|
from extensions.storage.storage_type import StorageType
|
|
from factories import variable_factory
|
|
from factories.variable_factory import build_segment
|
|
from fields.workflow_run_fields import (
|
|
node_execution_response_source,
|
|
workflow_run_pagination_response_source,
|
|
workflow_run_response_source,
|
|
)
|
|
from graphon.enums import (
|
|
BuiltinNodeTypes,
|
|
WorkflowExecutionStatus,
|
|
WorkflowNodeExecutionStatus,
|
|
)
|
|
from graphon.file import File, FileTransferMethod, FileType
|
|
from graphon.variables import FloatVariable, IntegerVariable, SecretVariable, SegmentType, StringVariable
|
|
from graphon.variables.segments import IntegerSegment, Segment
|
|
from models import ConversationVariable
|
|
from models import Workflow as _workflow_feature_compatibility_Workflow
|
|
from models.account import Account
|
|
from models.base import Base, TypeBase
|
|
from models.enums import CreatorUserRole, EndUserType, ExecutionOffLoadType, WorkflowRunTriggeredFrom
|
|
from models.model import EndUser, UploadFile
|
|
from models.tools import WorkflowToolProvider
|
|
from models.workflow import (
|
|
Workflow as _workflow_Workflow,
|
|
)
|
|
from models.workflow import (
|
|
Workflow as _workflow_models_Workflow,
|
|
)
|
|
from models.workflow import (
|
|
WorkflowAppLog,
|
|
WorkflowAppLogCreatedFrom,
|
|
WorkflowDraftVariable,
|
|
WorkflowNodeExecutionModel,
|
|
WorkflowNodeExecutionOffload,
|
|
WorkflowNodeExecutionTriggeredFrom,
|
|
WorkflowRun,
|
|
WorkflowType,
|
|
WorkflowVersionCounter,
|
|
is_system_variable_editable,
|
|
)
|
|
from models.workflow import WorkflowType as ModelWorkflowType
|
|
|
|
|
|
class TestConversationVariableCases:
|
|
@staticmethod
|
|
def test_from_variable_coerces_non_uuid_primary_key() -> None:
|
|
variable = variable_factory.build_conversation_variable_from_mapping(
|
|
{
|
|
"id": "opt-comp-prompt-var",
|
|
"name": "optimization_comparison_prompt",
|
|
"value_type": SegmentType.STRING,
|
|
"value": "-",
|
|
}
|
|
)
|
|
|
|
row = ConversationVariable.from_variable(app_id="app_id", conversation_id="conversation_id", variable=variable)
|
|
|
|
expected = str(uuid5(NAMESPACE_URL, "dify:conversation-variable:optimization_comparison_prompt"))
|
|
UUID(row.id)
|
|
assert row.id == expected
|
|
assert variable.id == "opt-comp-prompt-var"
|
|
assert row.to_variable().id == "opt-comp-prompt-var"
|
|
|
|
@staticmethod
|
|
def test_from_variable_and_to_variable() -> None:
|
|
variable = variable_factory.build_conversation_variable_from_mapping(
|
|
{
|
|
"id": str(uuid4()),
|
|
"name": "name",
|
|
"value_type": SegmentType.OBJECT,
|
|
"value": {
|
|
"key": {
|
|
"key": "value",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
conversation_variable = ConversationVariable.from_variable(
|
|
app_id="app_id", conversation_id="conversation_id", variable=variable
|
|
)
|
|
|
|
assert conversation_variable.to_variable() == variable
|
|
|
|
|
|
class TestWorkflowCases:
|
|
@staticmethod
|
|
def test_environment_variables() -> None:
|
|
# tenant_id context variable removed - using current_user.current_tenant_id directly
|
|
|
|
# Create a Workflow instance
|
|
workflow = _workflow_Workflow(
|
|
tenant_id="tenant_id",
|
|
app_id="app_id",
|
|
type=ModelWorkflowType("workflow"),
|
|
version="draft",
|
|
graph="{}",
|
|
_features="{}",
|
|
created_by="account_id",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
)
|
|
|
|
# Create some EnvironmentVariable instances
|
|
variable1 = StringVariable(name="var1", value="value1", id=str(uuid4()), selector=["env", "var1"])
|
|
variable2 = IntegerVariable(name="var2", value=123, id=str(uuid4()), selector=["env", "var2"])
|
|
variable3 = SecretVariable(name="var3", value="secret", id=str(uuid4()), selector=["env", "var3"])
|
|
variable4 = FloatVariable(name="var4", value=float("3.14"), id=str(uuid4()), selector=["env", "var4"])
|
|
|
|
with (
|
|
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
|
|
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"),
|
|
):
|
|
# Set the environment_variables property of the Workflow instance
|
|
variables = [variable1, variable2, variable3, variable4]
|
|
workflow.environment_variables = variables
|
|
|
|
# Get the environment_variables property and assert its value
|
|
assert workflow.environment_variables == variables
|
|
|
|
@staticmethod
|
|
def test_llm_environment_variable_round_trip() -> None:
|
|
workflow = _workflow_Workflow(
|
|
tenant_id="tenant_id",
|
|
app_id="app_id",
|
|
type=ModelWorkflowType("workflow"),
|
|
version="draft",
|
|
graph="{}",
|
|
_features="{}",
|
|
created_by="account_id",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
)
|
|
variable = LLMEnvironmentVariable(
|
|
name="for_research",
|
|
value={"provider": "langgenius/anthropic/anthropic", "name": "claude-sonnet", "mode": "chat"},
|
|
id=str(uuid4()),
|
|
selector=["env", "for_research"],
|
|
)
|
|
|
|
workflow.environment_variables = [variable]
|
|
|
|
assert workflow.environment_variables == [variable]
|
|
assert json.loads(workflow._environment_variables)["for_research"]["value_type"] == "llm"
|
|
assert workflow.to_dict()["environment_variables"][0]["value_type"] == "llm"
|
|
|
|
@staticmethod
|
|
def test_update_environment_variables() -> None:
|
|
# tenant_id context variable removed - using current_user.current_tenant_id directly
|
|
|
|
# Create a Workflow instance
|
|
workflow = _workflow_Workflow(
|
|
tenant_id="tenant_id",
|
|
app_id="app_id",
|
|
type=ModelWorkflowType("workflow"),
|
|
version="draft",
|
|
graph="{}",
|
|
_features="{}",
|
|
created_by="account_id",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
)
|
|
|
|
# Create some EnvironmentVariable instances
|
|
variable1 = StringVariable(name="var1", value="value1", id=str(uuid4()), selector=["env", "var1"])
|
|
variable2 = IntegerVariable(name="var2", value=123, id=str(uuid4()), selector=["env", "var2"])
|
|
variable3 = SecretVariable(
|
|
name="var3",
|
|
value="secret",
|
|
id=str(uuid4()),
|
|
selector=["env", "var3"],
|
|
description="old description",
|
|
)
|
|
variable4 = FloatVariable(name="var4", value=float("3.14"), id=str(uuid4()), selector=["env", "var4"])
|
|
|
|
with (
|
|
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
|
|
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"),
|
|
):
|
|
variables = [variable1, variable2, variable3, variable4]
|
|
|
|
# Set the environment_variables property of the Workflow instance
|
|
workflow.environment_variables = variables
|
|
assert workflow.environment_variables == [variable1, variable2, variable3, variable4]
|
|
|
|
# Update the name and description of variable3 and keep the value as it is
|
|
variables[2] = variable3.model_copy(
|
|
update={
|
|
"name": "new name",
|
|
"description": "new description",
|
|
"value": HIDDEN_VALUE,
|
|
}
|
|
)
|
|
|
|
workflow.environment_variables = variables
|
|
assert workflow.environment_variables[2].name == "new name"
|
|
assert workflow.environment_variables[2].description == "new description"
|
|
assert workflow.environment_variables[2].value == variable3.value
|
|
|
|
@staticmethod
|
|
def test_to_dict() -> None:
|
|
# tenant_id context variable removed - using current_user.current_tenant_id directly
|
|
|
|
# Create a Workflow instance
|
|
workflow = _workflow_Workflow(
|
|
tenant_id="tenant_id",
|
|
app_id="app_id",
|
|
type=ModelWorkflowType("workflow"),
|
|
version="draft",
|
|
graph="{}",
|
|
_features="{}",
|
|
created_by="account_id",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
)
|
|
|
|
# Create some EnvironmentVariable instances
|
|
|
|
with (
|
|
mock.patch("core.helper.encrypter.encrypt_token", return_value="encrypted_token"),
|
|
mock.patch("core.helper.encrypter.decrypt_token", return_value="secret"),
|
|
):
|
|
# Set the environment_variables property of the Workflow instance
|
|
workflow.environment_variables = [
|
|
SecretVariable(name="secret", value="secret", id=str(uuid4())),
|
|
StringVariable(name="text", value="text", id=str(uuid4())),
|
|
]
|
|
|
|
workflow_dict = workflow.to_dict()
|
|
assert workflow_dict["environment_variables"][0]["value"] == ""
|
|
assert workflow_dict["environment_variables"][1]["value"] == "text"
|
|
|
|
workflow_dict = workflow.to_dict(include_secret=True)
|
|
assert workflow_dict["environment_variables"][0]["value"] == "secret"
|
|
assert workflow_dict["environment_variables"][1]["value"] == "text"
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("sqlite_session", [(_workflow_Workflow, Account)], indirect=True)
|
|
def test_workflow_account_accessors_use_caller_session(sqlite_session: Session) -> None:
|
|
created_account = Account(name="Created Account", email="created@example.com")
|
|
created_account.id = "created-account-id"
|
|
updated_account = Account(name="Updated Account", email="updated@example.com")
|
|
updated_account.id = "updated-account-id"
|
|
decoy_account = Account(name="Decoy Account", email="decoy@example.com")
|
|
decoy_account.id = "decoy-account-id"
|
|
workflow = _workflow_Workflow(
|
|
tenant_id="tenant_id",
|
|
app_id="app_id",
|
|
type=ModelWorkflowType("workflow"),
|
|
version="draft",
|
|
graph="{}",
|
|
_features="{}",
|
|
created_by="created-account-id",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
updated_by="updated-account-id",
|
|
)
|
|
sqlite_session.add_all([decoy_account, updated_account, workflow, created_account])
|
|
sqlite_session.flush()
|
|
|
|
assert workflow.created_by_account(sqlite_session) is created_account
|
|
assert workflow.updated_by_account(sqlite_session) is updated_account
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("sqlite_session", [(_workflow_Workflow, WorkflowToolProvider)], indirect=True)
|
|
def test_workflow_tool_published_accessor_uses_caller_session(sqlite_session: Session) -> None:
|
|
workflow = _workflow_Workflow(
|
|
tenant_id="tenant_id",
|
|
app_id="app_id",
|
|
type=ModelWorkflowType("workflow"),
|
|
version="draft",
|
|
graph="{}",
|
|
_features="{}",
|
|
created_by="account_id",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
)
|
|
matching_provider = WorkflowToolProvider(
|
|
name="matching-provider",
|
|
label="Matching provider",
|
|
icon="tool",
|
|
app_id=workflow.app_id,
|
|
version="1",
|
|
user_id="account-id",
|
|
tenant_id=workflow.tenant_id,
|
|
description="Matching workflow tool",
|
|
)
|
|
decoy_provider = WorkflowToolProvider(
|
|
name="decoy-provider",
|
|
label="Decoy provider",
|
|
icon="tool",
|
|
app_id="other-app",
|
|
version="1",
|
|
user_id="account-id",
|
|
tenant_id=workflow.tenant_id,
|
|
description="Different app",
|
|
)
|
|
sqlite_session.add_all([decoy_provider, workflow, matching_provider])
|
|
sqlite_session.flush()
|
|
|
|
with pytest.warns(DeprecationWarning, match="not accurate"):
|
|
# The deprecated accessor is the compatibility surface under test.
|
|
assert workflow.tool_published(sqlite_session) is True # pyrefly: ignore[deprecated]
|
|
|
|
@staticmethod
|
|
def test_normalize_environment_variable_mappings_converts_full_mask_to_hidden_value() -> None:
|
|
normalized = _workflow_Workflow.normalize_environment_variable_mappings(
|
|
[
|
|
{
|
|
"id": str(uuid4()),
|
|
"name": "secret",
|
|
"value": encrypter.full_mask_token(),
|
|
"value_type": "secret",
|
|
}
|
|
]
|
|
)
|
|
|
|
assert normalized[0]["value"] == HIDDEN_VALUE
|
|
|
|
@staticmethod
|
|
def test_normalize_environment_variable_mappings_keeps_hidden_value() -> None:
|
|
normalized = _workflow_Workflow.normalize_environment_variable_mappings(
|
|
[
|
|
{
|
|
"id": str(uuid4()),
|
|
"name": "secret",
|
|
"value": HIDDEN_VALUE,
|
|
"value_type": "secret",
|
|
}
|
|
]
|
|
)
|
|
|
|
assert normalized[0]["value"] == HIDDEN_VALUE
|
|
|
|
class TestWorkflowNodeExecution:
|
|
def test_execution_metadata_dict(self) -> None:
|
|
node_exec = WorkflowNodeExecutionModel(
|
|
execution_metadata=None,
|
|
)
|
|
assert node_exec.execution_metadata_dict == {}
|
|
|
|
original = {"a": 1, "b": ["2"]}
|
|
node_exec.execution_metadata = json.dumps(original)
|
|
assert node_exec.execution_metadata_dict == original
|
|
|
|
class TestIsSystemVariableEditable:
|
|
def test_is_system_variable(self) -> None:
|
|
cases = [
|
|
("query", True),
|
|
("files", True),
|
|
("dialogue_count", False),
|
|
("conversation_id", False),
|
|
("user_id", False),
|
|
("app_id", False),
|
|
("workflow_id", False),
|
|
("workflow_run_id", False),
|
|
]
|
|
for name, editable in cases:
|
|
assert editable == is_system_variable_editable(name)
|
|
|
|
assert is_system_variable_editable("invalid_or_new_system_variable") is False
|
|
|
|
class TestWorkflowDraftVariableGetValue:
|
|
def test_get_value_by_case(self) -> None:
|
|
@dataclasses.dataclass
|
|
class TestCase:
|
|
name: str
|
|
value: Segment
|
|
|
|
test_file = File(
|
|
file_type=FileType.IMAGE,
|
|
transfer_method=FileTransferMethod.REMOTE_URL,
|
|
remote_url="https://example.com/example.jpg",
|
|
filename="example.jpg",
|
|
extension=".jpg",
|
|
mime_type="image/jpeg",
|
|
size=100,
|
|
)
|
|
cases: list[TestCase] = [
|
|
TestCase(
|
|
name="number/int",
|
|
value=build_segment(1),
|
|
),
|
|
TestCase(
|
|
name="number/float",
|
|
value=build_segment(1.0),
|
|
),
|
|
TestCase(
|
|
name="string",
|
|
value=build_segment("a"),
|
|
),
|
|
TestCase(
|
|
name="object",
|
|
value=build_segment({}),
|
|
),
|
|
TestCase(
|
|
name="file",
|
|
value=build_segment(test_file),
|
|
),
|
|
TestCase(
|
|
name="array[any]",
|
|
value=build_segment([1, "a"]),
|
|
),
|
|
TestCase(
|
|
name="array[string]",
|
|
value=build_segment(["a", "b"]),
|
|
),
|
|
TestCase(
|
|
name="array[number]/int",
|
|
value=build_segment([1, 2]),
|
|
),
|
|
TestCase(
|
|
name="array[number]/float",
|
|
value=build_segment([1.0, 2.0]),
|
|
),
|
|
TestCase(
|
|
name="array[number]/mixed",
|
|
value=build_segment([1, 2.0]),
|
|
),
|
|
TestCase(
|
|
name="array[object]",
|
|
value=build_segment([{}, {"a": 1}]),
|
|
),
|
|
TestCase(
|
|
name="none",
|
|
value=build_segment(None),
|
|
),
|
|
]
|
|
|
|
for idx, c in enumerate(cases, 1):
|
|
fail_msg = f"test case {c.name} failed, index={idx}"
|
|
draft_var = WorkflowDraftVariable()
|
|
draft_var.set_value(c.value)
|
|
assert c.value == draft_var.get_value(), fail_msg
|
|
|
|
def test_file_variable_preserves_all_fields(self) -> None:
|
|
"""Test that File type variables preserve all fields during encoding/decoding."""
|
|
|
|
# Create a File with specific field values
|
|
test_file = File(
|
|
file_id="test_file_id",
|
|
file_type=FileType.IMAGE,
|
|
transfer_method=FileTransferMethod.REMOTE_URL,
|
|
remote_url="https://example.com/test.jpg",
|
|
filename="test.jpg",
|
|
extension=".jpg",
|
|
mime_type="image/jpeg",
|
|
size=12345, # Specific size to test preservation
|
|
storage_key="test_storage_key",
|
|
)
|
|
|
|
# Create a FileSegment and WorkflowDraftVariable
|
|
file_segment = build_segment(test_file)
|
|
draft_var = WorkflowDraftVariable()
|
|
draft_var.set_value(file_segment)
|
|
|
|
# Retrieve the value and verify all fields are preserved
|
|
retrieved_segment = draft_var.get_value()
|
|
retrieved_file = retrieved_segment.value
|
|
|
|
# Verify all important fields are preserved
|
|
assert retrieved_file.id == test_file.id
|
|
assert retrieved_file.type == test_file.type
|
|
assert retrieved_file.transfer_method == test_file.transfer_method
|
|
assert retrieved_file.remote_url == test_file.remote_url
|
|
assert retrieved_file.filename == test_file.filename
|
|
assert retrieved_file.extension == test_file.extension
|
|
assert retrieved_file.mime_type == test_file.mime_type
|
|
assert retrieved_file.size == test_file.size # This was the main issue being fixed
|
|
# Note: storage_key is not serialized in model_dump() so it won't be preserved
|
|
|
|
# Verify the segments have the same type and the important fields match
|
|
assert file_segment.value_type == retrieved_segment.value_type
|
|
|
|
def test_file_variable_rebuilds_storage_backed_payloads_with_app_tenant(self) -> None:
|
|
persisted_file = File(
|
|
file_id="test_file_id",
|
|
file_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(
|
|
file_id="test_file_id",
|
|
file_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",
|
|
)
|
|
draft_var = WorkflowDraftVariable(
|
|
app_id="app-1",
|
|
)
|
|
draft_var.set_value(build_segment(persisted_file))
|
|
draft_var._WorkflowDraftVariable__value = None # pyrefly: ignore[missing-attribute]
|
|
|
|
with (
|
|
mock.patch("models.workflow._resolve_workflow_app_tenant_id", return_value="tenant-1"),
|
|
mock.patch("models.workflow.build_file_from_stored_mapping", return_value=rebuilt_file) as rebuild_file,
|
|
):
|
|
retrieved_segment = draft_var.get_value()
|
|
|
|
assert retrieved_segment.value == rebuilt_file
|
|
rebuild_file.assert_called_once()
|
|
assert rebuild_file.call_args.kwargs["tenant_id"] == "tenant-1"
|
|
|
|
def test_get_and_set_value(self) -> None:
|
|
draft_var = WorkflowDraftVariable()
|
|
int_var = IntegerSegment(value=1)
|
|
draft_var.set_value(int_var)
|
|
value = draft_var.get_value()
|
|
assert value == int_var
|
|
|
|
|
|
"""Regression coverage for ``models.workflow.WorkflowAppLog`` account accessors.
|
|
|
|
Ensures the ``@property``→session-parameter refactor preserves the role-based dispatch:
|
|
``created_by_account`` looks up an Account only when role is ACCOUNT; ``created_by_end_user``
|
|
looks up an EndUser only when role is END_USER.
|
|
|
|
Both accessors are exercised against the real ``sqlite_session`` fixture (a genuine
|
|
SQLAlchemy ``Session`` bound to a pristine full-schema SQLite database) so the assertions
|
|
cover actual query behaviour rather than a mock's recorded call.
|
|
"""
|
|
|
|
|
|
def _log(role: CreatorUserRole, created_by: str) -> WorkflowAppLog:
|
|
"""Construct a WorkflowAppLog without touching the database."""
|
|
return WorkflowAppLog(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
app_id="00000000-0000-0000-0000-000000000002",
|
|
workflow_id="00000000-0000-0000-0000-000000000003",
|
|
workflow_run_id="00000000-0000-0000-0000-000000000004",
|
|
created_from=WorkflowAppLogCreatedFrom.WEB_APP,
|
|
created_by_role=role,
|
|
created_by=created_by,
|
|
)
|
|
|
|
|
|
class TestWorkflowAppLogModelsCases:
|
|
class TestCreatedByAccount:
|
|
def test_returns_account_lookup_when_role_is_account(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
log = _log(CreatorUserRole.ACCOUNT, created_by=account.id)
|
|
|
|
result = log.created_by_account(session=sqlite_session)
|
|
|
|
assert result is not None
|
|
assert result.id == account.id
|
|
|
|
def test_returns_none_when_role_is_end_user(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
log = _log(CreatorUserRole.END_USER, created_by=account.id)
|
|
|
|
assert log.created_by_account(session=sqlite_session) is None
|
|
|
|
class TestCreatedByEndUser:
|
|
def test_returns_end_user_lookup_when_role_is_end_user(self, sqlite_session: Session) -> None:
|
|
end_user = EndUser(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
type=EndUserType.BROWSER,
|
|
session_id="session-1",
|
|
)
|
|
sqlite_session.add(end_user)
|
|
sqlite_session.flush()
|
|
log = _log(CreatorUserRole.END_USER, created_by=end_user.id)
|
|
|
|
result = log.created_by_end_user(session=sqlite_session)
|
|
|
|
assert result is not None
|
|
assert result.id == end_user.id
|
|
|
|
def test_returns_none_when_role_is_account(self, sqlite_session: Session) -> None:
|
|
end_user = EndUser(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
type=EndUserType.BROWSER,
|
|
session_id="session-1",
|
|
)
|
|
sqlite_session.add(end_user)
|
|
sqlite_session.flush()
|
|
log = _log(CreatorUserRole.ACCOUNT, created_by=end_user.id)
|
|
|
|
assert log.created_by_end_user(session=sqlite_session) is None
|
|
|
|
|
|
"""
|
|
This unit test verifies Workflow compatibility before and after supporting multiple file types.
|
|
"""
|
|
|
|
|
|
OLD_VERSION_WORKFLOW_FEATURES: dict[str, JsonValue] = {
|
|
"file_upload": {
|
|
"image": {
|
|
"enabled": True,
|
|
"number_limits": 6,
|
|
"transfer_methods": ["remote_url", "local_file"],
|
|
}
|
|
},
|
|
"opening_statement": "",
|
|
"retriever_resource": {"enabled": True},
|
|
"sensitive_word_avoidance": {"enabled": False},
|
|
"speech_to_text": {"enabled": False},
|
|
"suggested_questions": [],
|
|
"suggested_questions_after_answer": {"enabled": False},
|
|
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
|
|
}
|
|
|
|
NEW_VERSION_WORKFLOW_FEATURES: dict[str, JsonValue] = {
|
|
"file_upload": {
|
|
"enabled": True,
|
|
"allowed_file_types": ["image"],
|
|
"allowed_file_extensions": [],
|
|
"allowed_file_upload_methods": ["remote_url", "local_file"],
|
|
"number_limits": 6,
|
|
},
|
|
"opening_statement": "",
|
|
"retriever_resource": {"enabled": True},
|
|
"sensitive_word_avoidance": {"enabled": False},
|
|
"speech_to_text": {"enabled": False},
|
|
"suggested_questions": [],
|
|
"suggested_questions_after_answer": {"enabled": False},
|
|
"text_to_speech": {"enabled": False, "language": "", "voice": ""},
|
|
}
|
|
|
|
|
|
class TestWorkflowFeatureCompatibilityCases:
|
|
@staticmethod
|
|
def test_workflow_features() -> None:
|
|
workflow = _workflow_feature_compatibility_Workflow(
|
|
tenant_id="",
|
|
app_id="",
|
|
type=WorkflowType.WORKFLOW,
|
|
version="",
|
|
graph="",
|
|
_features=json.dumps(OLD_VERSION_WORKFLOW_FEATURES),
|
|
created_by="",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
)
|
|
|
|
assert workflow.features_dict == NEW_VERSION_WORKFLOW_FEATURES
|
|
|
|
|
|
"""
|
|
Comprehensive unit tests for Workflow models.
|
|
|
|
This test suite covers:
|
|
- Workflow model validation
|
|
- WorkflowRun state transitions
|
|
- NodeExecution relationships
|
|
- Graph configuration validation
|
|
"""
|
|
|
|
|
|
class TestWorkflowModelsCases:
|
|
class TestWorkflowModelValidation:
|
|
"""Test suite for Workflow model validation and basic operations."""
|
|
|
|
def test_workflow_creation_with_required_fields(self) -> None:
|
|
"""Test creating a workflow with all required fields."""
|
|
# Arrange
|
|
tenant_id = str(uuid4())
|
|
app_id = str(uuid4())
|
|
created_by = str(uuid4())
|
|
graph = json.dumps({"nodes": [], "edges": []})
|
|
features = json.dumps({"file_upload": {"enabled": True}})
|
|
|
|
# Act
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=graph,
|
|
features=features,
|
|
created_by=created_by,
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Assert
|
|
assert workflow.tenant_id == tenant_id
|
|
assert workflow.app_id == app_id
|
|
assert workflow.type == WorkflowType.WORKFLOW
|
|
assert workflow.version == "draft"
|
|
assert workflow.graph == graph
|
|
assert workflow.created_by == created_by
|
|
assert workflow.created_at is not None
|
|
assert workflow.updated_at is not None
|
|
|
|
def test_workflow_type_enum_values(self) -> None:
|
|
"""Test WorkflowType enum values."""
|
|
# Assert
|
|
assert WorkflowType.WORKFLOW == "workflow"
|
|
assert WorkflowType.CHAT.value == "chat"
|
|
assert WorkflowType.RAG_PIPELINE.value == "rag-pipeline"
|
|
|
|
def test_workflow_type_value_of(self) -> None:
|
|
"""Test WorkflowType.value_of method."""
|
|
# Act & Assert
|
|
assert WorkflowType.value_of("workflow") == WorkflowType.WORKFLOW
|
|
assert WorkflowType.value_of("chat") == WorkflowType.CHAT
|
|
assert WorkflowType.value_of("rag-pipeline") == WorkflowType.RAG_PIPELINE
|
|
|
|
with pytest.raises(ValueError, match="invalid workflow type value"):
|
|
WorkflowType.value_of("invalid_type")
|
|
|
|
def test_workflow_graph_dict_property(self) -> None:
|
|
"""Test graph_dict property parses JSON correctly."""
|
|
# Arrange
|
|
graph_data = {"nodes": [{"id": "start", "type": "start"}], "edges": list[object]()}
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=json.dumps(graph_data),
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Act
|
|
result = workflow.graph_dict
|
|
|
|
# Assert
|
|
assert result == graph_data
|
|
assert "nodes" in result
|
|
assert len(result["nodes"]) == 1
|
|
|
|
def test_workflow_features_dict_property(self) -> None:
|
|
"""Test features_dict property parses JSON correctly."""
|
|
# Arrange
|
|
features_data = {"file_upload": {"enabled": True, "max_files": 5}}
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph="{}",
|
|
features=json.dumps(features_data),
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Act
|
|
result = workflow.features_dict
|
|
|
|
# Assert
|
|
assert result == features_data
|
|
assert result["file_upload"]["enabled"] is True
|
|
assert result["file_upload"]["max_files"] == 5
|
|
|
|
def test_workflow_with_marked_name_and_comment(self) -> None:
|
|
"""Test workflow creation with marked name and comment."""
|
|
# Arrange & Act
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="v1.0",
|
|
graph="{}",
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
marked_name="Production Release",
|
|
marked_comment="Initial production version",
|
|
)
|
|
|
|
# Assert
|
|
assert workflow.marked_name == "Production Release"
|
|
assert workflow.marked_comment == "Initial production version"
|
|
|
|
def test_workflow_version_draft_constant(self) -> None:
|
|
"""Test VERSION_DRAFT constant."""
|
|
# Assert
|
|
assert _workflow_models_Workflow.VERSION_DRAFT == "draft"
|
|
|
|
class TestWorkflowRunStateTransitions:
|
|
"""Test suite for WorkflowRun state transitions and lifecycle."""
|
|
|
|
def test_workflow_run_creation_with_required_fields(self) -> None:
|
|
"""Test creating a workflow run with required fields."""
|
|
# Arrange
|
|
tenant_id = str(uuid4())
|
|
app_id = str(uuid4())
|
|
workflow_id = str(uuid4())
|
|
created_by = str(uuid4())
|
|
|
|
# Act
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
workflow_id=workflow_id,
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
|
version="draft",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=created_by,
|
|
)
|
|
|
|
# Assert
|
|
assert workflow_run.tenant_id == tenant_id
|
|
assert workflow_run.app_id == app_id
|
|
assert workflow_run.workflow_id == workflow_id
|
|
assert workflow_run.type == WorkflowType.WORKFLOW
|
|
assert workflow_run.triggered_from == WorkflowRunTriggeredFrom.DEBUGGING
|
|
assert workflow_run.status == WorkflowExecutionStatus.RUNNING
|
|
assert workflow_run.created_by == created_by
|
|
|
|
def test_workflow_run_state_transition_running_to_succeeded(self) -> None:
|
|
"""Test state transition from running to succeeded."""
|
|
# Arrange
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Act
|
|
workflow_run.status = WorkflowExecutionStatus.SUCCEEDED
|
|
workflow_run.finished_at = datetime.now(UTC)
|
|
workflow_run.elapsed_time = 2.5
|
|
|
|
# Assert
|
|
assert workflow_run.status == WorkflowExecutionStatus.SUCCEEDED
|
|
assert workflow_run.finished_at is not None
|
|
assert workflow_run.elapsed_time == 2.5
|
|
|
|
def test_workflow_run_state_transition_running_to_failed(self) -> None:
|
|
"""Test state transition from running to failed with error."""
|
|
# Arrange
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Act
|
|
workflow_run.status = WorkflowExecutionStatus.FAILED
|
|
workflow_run.error = "Node execution failed: Invalid input"
|
|
workflow_run.finished_at = datetime.now(UTC)
|
|
|
|
# Assert
|
|
assert workflow_run.status == WorkflowExecutionStatus.FAILED
|
|
assert workflow_run.error == "Node execution failed: Invalid input"
|
|
assert workflow_run.finished_at is not None
|
|
|
|
def test_workflow_run_state_transition_running_to_stopped(self) -> None:
|
|
"""Test state transition from running to stopped."""
|
|
# Arrange
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
|
version="draft",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Act
|
|
workflow_run.status = WorkflowExecutionStatus.STOPPED
|
|
workflow_run.finished_at = datetime.now(UTC)
|
|
|
|
# Assert
|
|
assert workflow_run.status == WorkflowExecutionStatus.STOPPED
|
|
assert workflow_run.finished_at is not None
|
|
|
|
def test_workflow_run_state_transition_running_to_paused(self) -> None:
|
|
"""Test state transition from running to paused."""
|
|
# Arrange
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Act
|
|
workflow_run.status = WorkflowExecutionStatus.PAUSED
|
|
|
|
# Assert
|
|
assert workflow_run.status == WorkflowExecutionStatus.PAUSED
|
|
assert workflow_run.finished_at is None # Not finished when paused
|
|
|
|
def test_workflow_run_state_transition_paused_to_running(self) -> None:
|
|
"""Test state transition from paused back to running."""
|
|
# Arrange
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.PAUSED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Act
|
|
workflow_run.status = WorkflowExecutionStatus.RUNNING
|
|
|
|
# Assert
|
|
assert workflow_run.status == WorkflowExecutionStatus.RUNNING
|
|
|
|
def test_workflow_run_with_partial_succeeded_status(self) -> None:
|
|
"""Test workflow run with partial-succeeded status."""
|
|
# Arrange & Act
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.PARTIAL_SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
exceptions_count=2,
|
|
)
|
|
|
|
# Assert
|
|
assert workflow_run.status == WorkflowExecutionStatus.PARTIAL_SUCCEEDED
|
|
assert workflow_run.exceptions_count == 2
|
|
|
|
def test_workflow_run_with_inputs_and_outputs(self) -> None:
|
|
"""Test workflow run with inputs and outputs as JSON."""
|
|
# Arrange
|
|
inputs = {"query": "What is AI?", "context": "technology"}
|
|
outputs = {"answer": "AI is Artificial Intelligence", "confidence": 0.95}
|
|
|
|
# Act
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=str(uuid4()),
|
|
inputs=json.dumps(inputs),
|
|
outputs=json.dumps(outputs),
|
|
)
|
|
|
|
# Assert
|
|
assert workflow_run.inputs_dict == inputs
|
|
assert workflow_run.outputs_dict == outputs
|
|
|
|
def test_workflow_run_graph_dict_property(self) -> None:
|
|
"""Test graph_dict property for workflow run."""
|
|
# Arrange
|
|
graph = {"nodes": [{"id": "start", "type": "start"}], "edges": list[object]()}
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
|
|
version="draft",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
graph=json.dumps(graph),
|
|
)
|
|
|
|
# Act
|
|
result = workflow_run.graph_dict
|
|
|
|
# Assert
|
|
assert result == graph
|
|
assert "nodes" in result
|
|
|
|
def test_workflow_run_to_dict_serialization(self) -> None:
|
|
"""Test WorkflowRun to_dict method."""
|
|
# Arrange
|
|
workflow_run_id = str(uuid4())
|
|
tenant_id = str(uuid4())
|
|
app_id = str(uuid4())
|
|
workflow_id = str(uuid4())
|
|
created_by = str(uuid4())
|
|
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
workflow_id=workflow_id,
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=created_by,
|
|
total_tokens=1500,
|
|
total_steps=5,
|
|
)
|
|
workflow_run.id = workflow_run_id
|
|
|
|
# Act
|
|
result = workflow_run.to_dict()
|
|
|
|
# Assert
|
|
assert result["id"] == workflow_run_id
|
|
assert result["tenant_id"] == tenant_id
|
|
assert result["app_id"] == app_id
|
|
assert result["workflow_id"] == workflow_id
|
|
assert result["status"] == WorkflowExecutionStatus.SUCCEEDED
|
|
assert result["total_tokens"] == 1500
|
|
assert result["total_steps"] == 5
|
|
|
|
def test_workflow_run_from_dict_deserialization(self) -> None:
|
|
"""Test WorkflowRun from_dict method."""
|
|
# Arrange
|
|
data = {
|
|
"id": str(uuid4()),
|
|
"tenant_id": str(uuid4()),
|
|
"app_id": str(uuid4()),
|
|
"workflow_id": str(uuid4()),
|
|
"type": WorkflowType.WORKFLOW,
|
|
"triggered_from": WorkflowRunTriggeredFrom.APP_RUN,
|
|
"version": "v1.0",
|
|
"graph": {"nodes": list[object](), "edges": list[object]()},
|
|
"inputs": {"query": "test"},
|
|
"status": WorkflowExecutionStatus.SUCCEEDED,
|
|
"outputs": {"result": "success"},
|
|
"error": None,
|
|
"elapsed_time": 3.5,
|
|
"total_tokens": 2000,
|
|
"total_steps": 10,
|
|
"created_by_role": CreatorUserRole.ACCOUNT,
|
|
"created_by": str(uuid4()),
|
|
"created_at": datetime.now(UTC),
|
|
"finished_at": datetime.now(UTC),
|
|
"exceptions_count": 0,
|
|
}
|
|
|
|
# Act
|
|
workflow_run = WorkflowRun.from_dict(data)
|
|
|
|
# Assert
|
|
assert workflow_run.id == data["id"]
|
|
assert workflow_run.workflow_id == data["workflow_id"]
|
|
assert workflow_run.status == WorkflowExecutionStatus.SUCCEEDED
|
|
assert workflow_run.total_tokens == 2000
|
|
|
|
class TestNodeExecutionRelationships:
|
|
"""Test suite for WorkflowNodeExecutionModel relationships and data."""
|
|
|
|
def test_node_execution_creation_with_required_fields(self) -> None:
|
|
"""Test creating a node execution with required fields."""
|
|
# Arrange
|
|
tenant_id = str(uuid4())
|
|
app_id = str(uuid4())
|
|
workflow_id = str(uuid4())
|
|
workflow_run_id = str(uuid4())
|
|
created_by = str(uuid4())
|
|
|
|
# Act
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
workflow_id=workflow_id,
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=workflow_run_id,
|
|
index=1,
|
|
node_id="start",
|
|
node_type=BuiltinNodeTypes.START,
|
|
title="Start Node",
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=created_by,
|
|
)
|
|
|
|
# Assert
|
|
assert node_execution.tenant_id == tenant_id
|
|
assert node_execution.app_id == app_id
|
|
assert node_execution.workflow_id == workflow_id
|
|
assert node_execution.workflow_run_id == workflow_run_id
|
|
assert node_execution.node_id == "start"
|
|
assert node_execution.node_type == BuiltinNodeTypes.START
|
|
assert node_execution.index == 1
|
|
|
|
def test_node_execution_with_predecessor_relationship(self) -> None:
|
|
"""Test node execution with predecessor node relationship."""
|
|
# Arrange
|
|
predecessor_node_id = "start"
|
|
current_node_id = "llm_1"
|
|
|
|
# Act
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=2,
|
|
predecessor_node_id=predecessor_node_id,
|
|
node_id=current_node_id,
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
title="LLM Node",
|
|
status=WorkflowNodeExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Assert
|
|
assert node_execution.predecessor_node_id == predecessor_node_id
|
|
assert node_execution.node_id == current_node_id
|
|
assert node_execution.index == 2
|
|
|
|
def test_node_execution_single_step_debugging(self) -> None:
|
|
"""Test node execution for single-step debugging (no workflow_run_id)."""
|
|
# Arrange & Act
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP,
|
|
workflow_run_id=None, # Single-step has no workflow run
|
|
index=1,
|
|
node_id="llm_test",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
title="Test LLM",
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Assert
|
|
assert node_execution.triggered_from == WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP.value
|
|
assert node_execution.workflow_run_id is None
|
|
|
|
def test_node_execution_with_inputs_outputs_process_data(self) -> None:
|
|
"""Test node execution with inputs, outputs, and process_data."""
|
|
# Arrange
|
|
inputs = {"query": "What is AI?", "temperature": 0.7}
|
|
outputs = {"answer": "AI is Artificial Intelligence"}
|
|
process_data = {"tokens_used": 150, "model": "gpt-4"}
|
|
|
|
# Act
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=1,
|
|
node_id="llm_1",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
title="LLM Node",
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
inputs=json.dumps(inputs),
|
|
outputs=json.dumps(outputs),
|
|
process_data=json.dumps(process_data),
|
|
)
|
|
|
|
# Assert
|
|
assert node_execution.inputs_dict == inputs
|
|
assert node_execution.outputs_dict == outputs
|
|
assert node_execution.process_data_dict == process_data
|
|
|
|
def test_node_execution_status_transitions(self) -> None:
|
|
"""Test node execution status transitions."""
|
|
# Arrange
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=1,
|
|
node_id="code_1",
|
|
node_type=BuiltinNodeTypes.CODE,
|
|
title="Code Node",
|
|
status=WorkflowNodeExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Act - transition to succeeded
|
|
node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
|
|
node_execution.elapsed_time = 1.2
|
|
node_execution.finished_at = datetime.now(UTC)
|
|
|
|
# Assert
|
|
assert node_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
|
assert node_execution.elapsed_time == 1.2
|
|
assert node_execution.finished_at is not None
|
|
|
|
def test_node_execution_with_error(self) -> None:
|
|
"""Test node execution with error status."""
|
|
# Arrange
|
|
error_message = "Code execution failed: SyntaxError on line 5"
|
|
|
|
# Act
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=3,
|
|
node_id="code_1",
|
|
node_type=BuiltinNodeTypes.CODE,
|
|
title="Code Node",
|
|
status=WorkflowNodeExecutionStatus.FAILED,
|
|
error=error_message,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Assert
|
|
assert node_execution.status == WorkflowNodeExecutionStatus.FAILED
|
|
assert node_execution.error == error_message
|
|
|
|
def test_node_execution_with_metadata(self) -> None:
|
|
"""Test node execution with execution metadata."""
|
|
# Arrange
|
|
metadata = {
|
|
"total_tokens": 500,
|
|
"total_price": 0.01,
|
|
"currency": "USD",
|
|
"tool_info": {"provider": "openai", "tool": "gpt-4"},
|
|
}
|
|
|
|
# Act
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=1,
|
|
node_id="llm_1",
|
|
node_type=BuiltinNodeTypes.LLM,
|
|
title="LLM Node",
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
execution_metadata=json.dumps(metadata),
|
|
)
|
|
|
|
# Assert
|
|
assert node_execution.execution_metadata_dict == metadata
|
|
assert node_execution.execution_metadata_dict["total_tokens"] == 500
|
|
|
|
def test_node_execution_metadata_dict_empty(self) -> None:
|
|
"""Test execution_metadata_dict returns empty dict when metadata is None."""
|
|
# Arrange
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=1,
|
|
node_id="start",
|
|
node_type=BuiltinNodeTypes.START,
|
|
title="Start",
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
execution_metadata=None,
|
|
)
|
|
|
|
# Act
|
|
result = node_execution.execution_metadata_dict
|
|
|
|
# Assert
|
|
assert result == {}
|
|
|
|
def test_node_execution_different_node_types(self) -> None:
|
|
"""Test node execution with different node types."""
|
|
# Test various node types
|
|
node_types = [
|
|
(BuiltinNodeTypes.START, "Start Node"),
|
|
(BuiltinNodeTypes.LLM, "LLM Node"),
|
|
(BuiltinNodeTypes.CODE, "Code Node"),
|
|
(BuiltinNodeTypes.TOOL, "Tool Node"),
|
|
(BuiltinNodeTypes.IF_ELSE, "Conditional Node"),
|
|
(BuiltinNodeTypes.END, "End Node"),
|
|
]
|
|
|
|
for node_type, title in node_types:
|
|
# Act
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=1,
|
|
node_id=f"{node_type}_1",
|
|
node_type=node_type,
|
|
title=title,
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
)
|
|
|
|
# Assert
|
|
assert node_execution.node_type == node_type
|
|
assert node_execution.title == title
|
|
|
|
class TestGraphConfigurationValidation:
|
|
"""Test suite for graph configuration validation."""
|
|
|
|
def test_workflow_graph_with_nodes_and_edges(self) -> None:
|
|
"""Test workflow graph configuration with nodes and edges."""
|
|
# Arrange
|
|
graph_config = {
|
|
"nodes": [
|
|
{"id": "start", "type": "start", "data": {"title": "Start"}},
|
|
{"id": "llm_1", "type": "llm", "data": {"title": "LLM Node", "model": "gpt-4"}},
|
|
{"id": "end", "type": "end", "data": {"title": "End"}},
|
|
],
|
|
"edges": [
|
|
{"source": "start", "target": "llm_1"},
|
|
{"source": "llm_1", "target": "end"},
|
|
],
|
|
}
|
|
|
|
# Act
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=json.dumps(graph_config),
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Assert
|
|
graph_dict = workflow.graph_dict
|
|
assert len(graph_dict["nodes"]) == 3
|
|
assert len(graph_dict["edges"]) == 2
|
|
assert graph_dict["nodes"][0]["id"] == "start"
|
|
assert graph_dict["edges"][0]["source"] == "start"
|
|
assert graph_dict["edges"][0]["target"] == "llm_1"
|
|
|
|
def test_workflow_graph_empty_configuration(self) -> None:
|
|
"""Test workflow with empty graph configuration."""
|
|
# Arrange
|
|
graph_config = {"nodes": list[object](), "edges": list[object]()}
|
|
|
|
# Act
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=json.dumps(graph_config),
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Assert
|
|
graph_dict = workflow.graph_dict
|
|
assert graph_dict["nodes"] == []
|
|
assert graph_dict["edges"] == []
|
|
|
|
def test_workflow_graph_complex_node_data(self) -> None:
|
|
"""Test workflow graph with complex node data structures."""
|
|
# Arrange
|
|
graph_config = {
|
|
"nodes": [
|
|
{
|
|
"id": "llm_1",
|
|
"type": "llm",
|
|
"data": {
|
|
"title": "Advanced LLM",
|
|
"model": {"provider": "openai", "name": "gpt-4", "mode": "chat"},
|
|
"prompt_template": [
|
|
{"role": "system", "text": "You are a helpful assistant"},
|
|
{"role": "user", "text": "{{query}}"},
|
|
],
|
|
"model_parameters": {"temperature": 0.7, "max_tokens": 2000},
|
|
},
|
|
}
|
|
],
|
|
"edges": list[object](),
|
|
}
|
|
|
|
# Act
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=json.dumps(graph_config),
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Assert
|
|
graph_dict = workflow.graph_dict
|
|
node_data = graph_dict["nodes"][0]["data"]
|
|
assert node_data["model"]["provider"] == "openai"
|
|
assert node_data["model_parameters"]["temperature"] == 0.7
|
|
assert len(node_data["prompt_template"]) == 2
|
|
|
|
def test_workflow_run_graph_preservation(self) -> None:
|
|
"""Test that WorkflowRun preserves graph configuration from Workflow."""
|
|
# Arrange
|
|
original_graph = {
|
|
"nodes": [
|
|
{"id": "start", "type": "start"},
|
|
{"id": "end", "type": "end"},
|
|
],
|
|
"edges": [{"source": "start", "target": "end"}],
|
|
}
|
|
|
|
# Act
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
graph=json.dumps(original_graph),
|
|
)
|
|
|
|
# Assert
|
|
assert workflow_run.graph_dict == original_graph
|
|
assert len(workflow_run.graph_dict["nodes"]) == 2
|
|
|
|
def test_workflow_graph_with_conditional_branches(self) -> None:
|
|
"""Test workflow graph with conditional branching (if-else)."""
|
|
# Arrange
|
|
graph_config = {
|
|
"nodes": [
|
|
{"id": "start", "type": "start"},
|
|
{"id": "if_else_1", "type": "if-else", "data": {"conditions": list[object]()}},
|
|
{"id": "branch_true", "type": "llm"},
|
|
{"id": "branch_false", "type": "code"},
|
|
{"id": "end", "type": "end"},
|
|
],
|
|
"edges": [
|
|
{"source": "start", "target": "if_else_1"},
|
|
{"source": "if_else_1", "sourceHandle": "true", "target": "branch_true"},
|
|
{"source": "if_else_1", "sourceHandle": "false", "target": "branch_false"},
|
|
{"source": "branch_true", "target": "end"},
|
|
{"source": "branch_false", "target": "end"},
|
|
],
|
|
}
|
|
|
|
# Act
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=json.dumps(graph_config),
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Assert
|
|
graph_dict = workflow.graph_dict
|
|
assert len(graph_dict["nodes"]) == 5
|
|
assert len(graph_dict["edges"]) == 5
|
|
# Verify conditional edges
|
|
conditional_edges = [e for e in graph_dict["edges"] if "sourceHandle" in e]
|
|
assert len(conditional_edges) == 2
|
|
|
|
def test_workflow_graph_with_loop_structure(self) -> None:
|
|
"""Test workflow graph with loop/iteration structure."""
|
|
# Arrange
|
|
graph_config = {
|
|
"nodes": [
|
|
{"id": "start", "type": "start"},
|
|
{"id": "iteration_1", "type": "iteration", "data": {"iterator": "items"}},
|
|
{"id": "loop_body", "type": "llm"},
|
|
{"id": "end", "type": "end"},
|
|
],
|
|
"edges": [
|
|
{"source": "start", "target": "iteration_1"},
|
|
{"source": "iteration_1", "target": "loop_body"},
|
|
{"source": "loop_body", "target": "iteration_1"},
|
|
{"source": "iteration_1", "target": "end"},
|
|
],
|
|
}
|
|
|
|
# Act
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=json.dumps(graph_config),
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Assert
|
|
graph_dict = workflow.graph_dict
|
|
iteration_node = next(n for n in graph_dict["nodes"] if n["type"] == "iteration")
|
|
assert iteration_node["data"]["iterator"] == "items"
|
|
|
|
def test_workflow_graph_dict_with_null_graph(self) -> None:
|
|
"""Test graph_dict property when graph is None."""
|
|
# Arrange
|
|
workflow = _workflow_models_Workflow.new(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
version="draft",
|
|
graph=None, # pyrefly: ignore[bad-argument-type]
|
|
features="{}",
|
|
created_by=str(uuid4()),
|
|
environment_variables=[],
|
|
conversation_variables=[],
|
|
rag_pipeline_variables=[],
|
|
)
|
|
|
|
# Act
|
|
result = workflow.graph_dict
|
|
|
|
# Assert
|
|
assert result == {}
|
|
|
|
def test_workflow_run_inputs_dict_with_null_inputs(self) -> None:
|
|
"""Test inputs_dict property when inputs is None."""
|
|
# Arrange
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
inputs=None,
|
|
)
|
|
|
|
# Act
|
|
result = workflow_run.inputs_dict
|
|
|
|
# Assert
|
|
assert result == {}
|
|
|
|
def test_workflow_run_outputs_dict_with_null_outputs(self) -> None:
|
|
"""Test outputs_dict property when outputs is None."""
|
|
# Arrange
|
|
workflow_run = WorkflowRun(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="v1.0",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
outputs=None,
|
|
)
|
|
|
|
# Act
|
|
result = workflow_run.outputs_dict
|
|
|
|
# Assert
|
|
assert result == {}
|
|
|
|
def test_node_execution_inputs_dict_with_null_inputs(self) -> None:
|
|
"""Test node execution inputs_dict when inputs is None."""
|
|
# Arrange
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=1,
|
|
node_id="start",
|
|
node_type=BuiltinNodeTypes.START,
|
|
title="Start",
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
inputs=None,
|
|
)
|
|
|
|
# Act
|
|
result = node_execution.inputs_dict
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_node_execution_outputs_dict_with_null_outputs(self) -> None:
|
|
"""Test node execution outputs_dict when outputs is None."""
|
|
# Arrange
|
|
node_execution = WorkflowNodeExecutionModel(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=str(uuid4()),
|
|
index=1,
|
|
node_id="start",
|
|
node_type=BuiltinNodeTypes.START,
|
|
title="Start",
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=str(uuid4()),
|
|
outputs=None,
|
|
)
|
|
|
|
# Act
|
|
result = node_execution.outputs_dict
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
|
|
"""Regression coverage for ``WorkflowNodeExecutionModel`` account accessors.
|
|
|
|
Ensures the ``@property``→session-parameter refactor preserves the role-based dispatch:
|
|
``created_by_account`` looks up an Account only when role is ACCOUNT; ``created_by_end_user``
|
|
looks up an EndUser only when role is END_USER. Also covers the session-carrying response
|
|
source used by the console endpoints that validate ``WorkflowRunNodeExecutionResponse``
|
|
with ``from_attributes=True``.
|
|
"""
|
|
|
|
|
|
def _execution(role: CreatorUserRole, created_by: str) -> WorkflowNodeExecutionModel:
|
|
"""Construct a WorkflowNodeExecutionModel without touching the database."""
|
|
return WorkflowNodeExecutionModel(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
app_id="00000000-0000-0000-0000-000000000002",
|
|
workflow_id=str(uuid4()),
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id=None,
|
|
index=1,
|
|
predecessor_node_id=None,
|
|
node_execution_id=None,
|
|
node_id="n1",
|
|
node_type="start",
|
|
title="Start",
|
|
inputs=None,
|
|
process_data=None,
|
|
outputs=None,
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
error=None,
|
|
elapsed_time=0.0,
|
|
execution_metadata=None,
|
|
created_by_role=role,
|
|
created_by=created_by,
|
|
)
|
|
|
|
|
|
class TestWorkflowNodeExecutionAccessorsCases:
|
|
class TestCreatedByAccount:
|
|
def test_returns_account_lookup_when_role_is_account(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
execution = _execution(CreatorUserRole.ACCOUNT, created_by=account.id)
|
|
|
|
result = execution.created_by_account(session=sqlite_session)
|
|
|
|
assert result is not None
|
|
assert result.id == account.id
|
|
|
|
def test_returns_none_when_role_is_end_user(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
execution = _execution(CreatorUserRole.END_USER, created_by=account.id)
|
|
|
|
assert execution.created_by_account(session=sqlite_session) is None
|
|
|
|
class TestCreatedByEndUser:
|
|
def test_returns_end_user_lookup_when_role_is_end_user(self, sqlite_session: Session) -> None:
|
|
end_user = EndUser(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
type=EndUserType.BROWSER,
|
|
session_id="session-1",
|
|
)
|
|
sqlite_session.add(end_user)
|
|
sqlite_session.flush()
|
|
execution = _execution(CreatorUserRole.END_USER, created_by=end_user.id)
|
|
|
|
result = execution.created_by_end_user(session=sqlite_session)
|
|
|
|
assert result is not None
|
|
assert result.id == end_user.id
|
|
|
|
def test_returns_none_when_role_is_account(self, sqlite_session: Session) -> None:
|
|
end_user = EndUser(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
type=EndUserType.BROWSER,
|
|
session_id="session-1",
|
|
)
|
|
sqlite_session.add(end_user)
|
|
sqlite_session.flush()
|
|
execution = _execution(CreatorUserRole.ACCOUNT, created_by=end_user.id)
|
|
|
|
assert execution.created_by_end_user(session=sqlite_session) is None
|
|
|
|
class TestNodeExecutionResponseSource:
|
|
def test_accessors_resolve_via_wrapped_session_and_other_attributes_proxy(
|
|
self, sqlite_session: Session
|
|
) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
execution = _execution(CreatorUserRole.ACCOUNT, created_by=account.id)
|
|
|
|
source = node_execution_response_source(execution, session=sqlite_session)
|
|
|
|
resolved = source.created_by_account
|
|
assert resolved is not None
|
|
assert resolved.id == account.id
|
|
assert source.created_by_end_user is None
|
|
assert source.node_id == "n1"
|
|
|
|
|
|
"""SQLite-backed tests for workflow node execution offload behavior."""
|
|
|
|
|
|
TABLES = (WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, UploadFile)
|
|
|
|
|
|
def _persist_upload_file(session: Session, *, key: str = "offload/process-data.json") -> UploadFile:
|
|
upload_file = UploadFile(
|
|
tenant_id="tenant-1",
|
|
storage_type=StorageType.LOCAL,
|
|
key=key,
|
|
name="process-data.json",
|
|
size=18,
|
|
extension="json",
|
|
mime_type="application/json",
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by="account-1",
|
|
created_at=datetime.now(UTC),
|
|
used=True,
|
|
)
|
|
session.add(upload_file)
|
|
session.flush()
|
|
return upload_file
|
|
|
|
|
|
def _persist_execution(
|
|
session: Session,
|
|
*,
|
|
process_data: str = '{"test": "data"}',
|
|
offloads: tuple[tuple[ExecutionOffLoadType, str], ...] = (),
|
|
) -> WorkflowNodeExecutionModel:
|
|
execution = WorkflowNodeExecutionModel(
|
|
tenant_id="tenant-1",
|
|
app_id="app-1",
|
|
workflow_id="workflow-1",
|
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
|
workflow_run_id="run-1",
|
|
index=1,
|
|
predecessor_node_id=None,
|
|
node_execution_id="node-execution-1",
|
|
node_id="node-1",
|
|
node_type="code",
|
|
title="Code",
|
|
inputs='{"input": "value"}',
|
|
process_data=process_data,
|
|
outputs='{"output": "value"}',
|
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
|
error=None,
|
|
elapsed_time=0,
|
|
execution_metadata=None,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by="account-1",
|
|
finished_at=None,
|
|
)
|
|
session.add(execution)
|
|
session.flush()
|
|
|
|
session.add_all(
|
|
[
|
|
WorkflowNodeExecutionOffload(
|
|
tenant_id=execution.tenant_id,
|
|
app_id=execution.app_id,
|
|
node_execution_id=execution.id,
|
|
type_=type_,
|
|
file_id=file_id,
|
|
)
|
|
for type_, file_id in offloads
|
|
]
|
|
)
|
|
session.commit()
|
|
session.expunge_all()
|
|
|
|
stmt = WorkflowNodeExecutionModel.preload_offload_data(
|
|
select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id == execution.id)
|
|
)
|
|
loaded_execution = session.scalar(stmt)
|
|
assert loaded_execution is not None
|
|
return loaded_execution
|
|
|
|
|
|
class TestWorkflowNodeExecutionOffloadCases:
|
|
@pytest.mark.parametrize("sqlite_session", [TABLES], indirect=True)
|
|
class TestWorkflowNodeExecutionModel:
|
|
"""Exercise truncation flags and content restoration using persisted offload rows."""
|
|
|
|
def test_process_data_truncated_property_false_when_no_offload_data(self, sqlite_session: Session) -> None:
|
|
execution = _persist_execution(sqlite_session)
|
|
|
|
assert execution.process_data_truncated is False
|
|
|
|
def test_process_data_truncated_property_false_when_no_process_data_file(self, sqlite_session: Session) -> None:
|
|
execution = _persist_execution(
|
|
sqlite_session,
|
|
offloads=(
|
|
(ExecutionOffLoadType.INPUTS, "inputs-file"),
|
|
(ExecutionOffLoadType.OUTPUTS, "outputs-file"),
|
|
),
|
|
)
|
|
|
|
assert execution.process_data_truncated is False
|
|
|
|
def test_process_data_truncated_property_true_when_process_data_file_exists(
|
|
self, sqlite_session: Session
|
|
) -> None:
|
|
execution = _persist_execution(
|
|
sqlite_session,
|
|
offloads=((ExecutionOffLoadType.PROCESS_DATA, "process-data-file-id"),),
|
|
)
|
|
|
|
assert execution.process_data_truncated is True
|
|
|
|
def test_load_full_process_data_with_no_offload_data(self, sqlite_session: Session) -> None:
|
|
execution = _persist_execution(sqlite_session)
|
|
storage = Mock()
|
|
|
|
result = execution.load_full_process_data(sqlite_session, storage)
|
|
|
|
assert result == {"test": "data"}
|
|
storage.load.assert_not_called()
|
|
|
|
def test_load_full_process_data_with_no_file(self, sqlite_session: Session) -> None:
|
|
execution = _persist_execution(
|
|
sqlite_session,
|
|
offloads=((ExecutionOffLoadType.INPUTS, "inputs-file"),),
|
|
)
|
|
storage = Mock()
|
|
|
|
result = execution.load_full_process_data(sqlite_session, storage)
|
|
|
|
assert result == {"test": "data"}
|
|
storage.load.assert_not_called()
|
|
|
|
def test_load_full_process_data_with_file(self, sqlite_session: Session) -> None:
|
|
upload_file = _persist_upload_file(sqlite_session)
|
|
execution = _persist_execution(
|
|
sqlite_session,
|
|
process_data='{"truncated": true}',
|
|
offloads=((ExecutionOffLoadType.PROCESS_DATA, upload_file.id),),
|
|
)
|
|
full_process_data = {"full": "data", "large_field": "x" * 10000}
|
|
storage = Mock()
|
|
storage.load.return_value = json.dumps(full_process_data)
|
|
|
|
result = execution.load_full_process_data(sqlite_session, storage)
|
|
|
|
assert result == full_process_data
|
|
storage.load.assert_called_once_with(upload_file.key)
|
|
|
|
def test_consistency_with_inputs_outputs_truncation(self, sqlite_session: Session) -> None:
|
|
execution = _persist_execution(
|
|
sqlite_session,
|
|
offloads=(
|
|
(ExecutionOffLoadType.INPUTS, "inputs-file"),
|
|
(ExecutionOffLoadType.OUTPUTS, "outputs-file"),
|
|
(ExecutionOffLoadType.PROCESS_DATA, "process-data-file"),
|
|
),
|
|
)
|
|
|
|
assert execution.inputs_truncated is True
|
|
assert execution.outputs_truncated is True
|
|
assert execution.process_data_truncated is True
|
|
|
|
def test_mixed_truncation_states(self, sqlite_session: Session) -> None:
|
|
execution = _persist_execution(
|
|
sqlite_session,
|
|
offloads=((ExecutionOffLoadType.PROCESS_DATA, "process-data-file"),),
|
|
)
|
|
|
|
assert execution.inputs_truncated is False
|
|
assert execution.outputs_truncated is False
|
|
assert execution.process_data_truncated is True
|
|
|
|
def test_preload_offload_data_and_files_loads_process_data_file(self, sqlite_session: Session) -> None:
|
|
upload_file = _persist_upload_file(sqlite_session)
|
|
execution = _persist_execution(
|
|
sqlite_session,
|
|
offloads=((ExecutionOffLoadType.PROCESS_DATA, upload_file.id),),
|
|
)
|
|
execution_id = execution.id
|
|
sqlite_session.expunge_all()
|
|
|
|
stmt = WorkflowNodeExecutionModel.preload_offload_data_and_files(
|
|
select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id == execution_id)
|
|
)
|
|
preloaded_execution = sqlite_session.scalar(stmt)
|
|
|
|
assert preloaded_execution is not None
|
|
assert len(preloaded_execution.offload_data) == 1
|
|
assert preloaded_execution.offload_data[0].file is not None
|
|
assert preloaded_execution.offload_data[0].file.key == upload_file.key
|
|
|
|
|
|
"""Regression coverage for ``WorkflowRun`` account accessors.
|
|
|
|
Ensures the ``@property``→session-parameter refactor preserves the role-based dispatch:
|
|
``created_by_account`` looks up an Account only when role is ACCOUNT; ``created_by_end_user``
|
|
looks up an EndUser only when role is END_USER. Also covers the session-carrying response
|
|
sources used by the console endpoints that validate the run detail / pagination responses
|
|
with ``from_attributes=True``.
|
|
"""
|
|
|
|
|
|
def _run(role: CreatorUserRole, created_by: str) -> WorkflowRun:
|
|
"""Construct a WorkflowRun without touching the database."""
|
|
return WorkflowRun(
|
|
id=str(uuid4()),
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
app_id="00000000-0000-0000-0000-000000000002",
|
|
workflow_id=str(uuid4()),
|
|
type=ModelWorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="1.0",
|
|
status=WorkflowExecutionStatus.SUCCEEDED,
|
|
created_by_role=role,
|
|
created_by=created_by,
|
|
)
|
|
|
|
|
|
class TestWorkflowRunAccessorsCases:
|
|
class TestCreatedByAccount:
|
|
def test_returns_account_lookup_when_role_is_account(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
run = _run(CreatorUserRole.ACCOUNT, created_by=account.id)
|
|
|
|
result = run.created_by_account(session=sqlite_session)
|
|
|
|
assert result is not None
|
|
assert result.id == account.id
|
|
|
|
def test_returns_none_when_role_is_end_user(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
run = _run(CreatorUserRole.END_USER, created_by=account.id)
|
|
|
|
assert run.created_by_account(session=sqlite_session) is None
|
|
|
|
class TestCreatedByEndUser:
|
|
def test_returns_end_user_lookup_when_role_is_end_user(self, sqlite_session: Session) -> None:
|
|
end_user = EndUser(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
type=EndUserType.BROWSER,
|
|
session_id="session-1",
|
|
)
|
|
sqlite_session.add(end_user)
|
|
sqlite_session.flush()
|
|
run = _run(CreatorUserRole.END_USER, created_by=end_user.id)
|
|
|
|
result = run.created_by_end_user(session=sqlite_session)
|
|
|
|
assert result is not None
|
|
assert result.id == end_user.id
|
|
|
|
def test_returns_none_when_role_is_account(self, sqlite_session: Session) -> None:
|
|
end_user = EndUser(
|
|
tenant_id="00000000-0000-0000-0000-000000000001",
|
|
type=EndUserType.BROWSER,
|
|
session_id="session-1",
|
|
)
|
|
sqlite_session.add(end_user)
|
|
sqlite_session.flush()
|
|
run = _run(CreatorUserRole.ACCOUNT, created_by=end_user.id)
|
|
|
|
assert run.created_by_end_user(session=sqlite_session) is None
|
|
|
|
class TestWorkflowRunResponseSources:
|
|
def test_detail_source_resolves_accessors_and_proxies_other_attributes(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
run = _run(CreatorUserRole.ACCOUNT, created_by=account.id)
|
|
|
|
source = workflow_run_response_source(run, session=sqlite_session)
|
|
|
|
resolved = source.created_by_account
|
|
assert resolved is not None
|
|
assert resolved.id == account.id
|
|
assert source.created_by_end_user is None
|
|
assert source.version == "1.0"
|
|
|
|
def test_pagination_source_wraps_each_run(self, sqlite_session: Session) -> None:
|
|
account = Account(name="Test Account", email="test@example.com")
|
|
sqlite_session.add(account)
|
|
sqlite_session.flush()
|
|
runs = [_run(CreatorUserRole.ACCOUNT, created_by=account.id) for _ in range(2)]
|
|
pagination = SimpleNamespace(data=runs, limit=20, has_more=False)
|
|
|
|
payload = workflow_run_pagination_response_source(pagination, session=sqlite_session)
|
|
|
|
assert payload["limit"] == 20
|
|
assert payload["has_more"] is False
|
|
assert [item.created_by_account.id for item in payload["data"]] == [account.id, account.id]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("features", "expected"),
|
|
[
|
|
pytest.param({}, {}, id="missing-upload"),
|
|
pytest.param({"file_upload": None}, {"file_upload": None}, id="null-upload"),
|
|
pytest.param({"file_upload": False}, {"file_upload": False}, id="non-object-upload"),
|
|
pytest.param({"file_upload": {}}, {"file_upload": {}}, id="missing-image"),
|
|
pytest.param({"file_upload": {"image": None}}, {"file_upload": {"image": None}}, id="null-image"),
|
|
pytest.param({"file_upload": {"image": False}}, {"file_upload": {"image": False}}, id="non-object-image"),
|
|
pytest.param(
|
|
{"file_upload": {"enabled": False, "image": {"enabled": False}}},
|
|
{"file_upload": {"enabled": False, "image": {"enabled": False}}},
|
|
id="disabled-image",
|
|
),
|
|
pytest.param(OLD_VERSION_WORKFLOW_FEATURES, NEW_VERSION_WORKFLOW_FEATURES, id="legacy-image"),
|
|
pytest.param(NEW_VERSION_WORKFLOW_FEATURES, NEW_VERSION_WORKFLOW_FEATURES, id="modern-upload"),
|
|
],
|
|
)
|
|
def test_normalized_features_preserves_stored_payload_without_dirtying_workflow(
|
|
sqlite_session: Session,
|
|
features: dict[str, JsonValue],
|
|
expected: dict[str, JsonValue],
|
|
) -> None:
|
|
stored_features = json.dumps(features)
|
|
workflow = _workflow_feature_compatibility_Workflow(
|
|
tenant_id=str(uuid4()),
|
|
app_id=str(uuid4()),
|
|
type=WorkflowType.CHAT,
|
|
version="published",
|
|
graph="{}",
|
|
_features=stored_features,
|
|
created_by=str(uuid4()),
|
|
)
|
|
sqlite_session.add(workflow)
|
|
sqlite_session.commit()
|
|
|
|
assert workflow.normalized_features_dict == expected
|
|
assert workflow.serialized_features == stored_features
|
|
assert workflow not in sqlite_session.dirty
|
|
|
|
sqlite_session.expire(workflow)
|
|
assert workflow.serialized_features == stored_features
|
|
|
|
|
|
class TestWorkflowTypeBaseCases:
|
|
@staticmethod
|
|
def test_workflow_models_use_typebase_registry_and_generated_defaults() -> None:
|
|
models = (
|
|
_workflow_Workflow,
|
|
WorkflowVersionCounter,
|
|
WorkflowRun,
|
|
WorkflowNodeExecutionModel,
|
|
WorkflowDraftVariable,
|
|
)
|
|
|
|
typebase_models = {mapper.class_ for mapper in TypeBase.registry.mappers}
|
|
base_models = {mapper.class_ for mapper in Base.registry.mappers}
|
|
|
|
assert all(model in typebase_models for model in models)
|
|
assert all(model not in base_models for model in models)
|
|
|
|
instances = (_workflow_Workflow(), WorkflowRun(), WorkflowNodeExecutionModel(), WorkflowDraftVariable())
|
|
assert all(instance.id for instance in instances)
|
|
assert len({instance.id for instance in instances}) == len(instances)
|
|
assert isinstance(instances[0].created_at, datetime)
|
|
assert isinstance(instances[-1].created_at, datetime)
|
|
|
|
@staticmethod
|
|
def test_workflow_constructor_serializes_variable_collections_and_configures_offload_relationship() -> None:
|
|
workflow = _workflow_Workflow(
|
|
_features="{}",
|
|
_environment_variables=[],
|
|
_conversation_variables=[],
|
|
_rag_pipeline_variables=[],
|
|
)
|
|
|
|
assert workflow._features == "{}"
|
|
assert json.loads(workflow._environment_variables) == {}
|
|
assert json.loads(workflow._conversation_variables) == {}
|
|
assert json.loads(workflow._rag_pipeline_variables) == {}
|
|
|
|
configure_mappers()
|
|
relationship = inspect(WorkflowNodeExecutionModel).relationships["offload_data"]
|
|
assert relationship.mapper.class_ is WorkflowNodeExecutionOffload
|
|
assert relationship.back_populates == "execution"
|