feat: introduce trigger functionality (#27644)

Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: Stream <Stream_2@qq.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
Co-authored-by: Harry <xh001x@hotmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: yessenia <yessenia.contact@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WTW0313 <twwu@dify.ai>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Yeuoly
2025-11-12 17:59:37 +08:00
committed by GitHub
parent ca7794305b
commit b76e17b25d
785 changed files with 41186 additions and 3725 deletions

View File

@@ -0,0 +1,569 @@
import json
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
from faker import Faker
from flask import Flask
from werkzeug.datastructures import FileStorage
from models.enums import AppTriggerStatus, AppTriggerType
from models.model import App
from models.trigger import AppTrigger, WorkflowWebhookTrigger
from models.workflow import Workflow
from services.account_service import AccountService, TenantService
from services.trigger.webhook_service import WebhookService
class TestWebhookService:
"""Integration tests for WebhookService using testcontainers."""
@pytest.fixture
def mock_external_dependencies(self):
"""Mock external service dependencies."""
with (
patch("services.trigger.webhook_service.AsyncWorkflowService") as mock_async_service,
patch("services.trigger.webhook_service.ToolFileManager") as mock_tool_file_manager,
patch("services.trigger.webhook_service.file_factory") as mock_file_factory,
patch("services.account_service.FeatureService") as mock_feature_service,
):
# Mock ToolFileManager
mock_tool_file_instance = MagicMock()
mock_tool_file_manager.return_value = mock_tool_file_instance
# Mock file creation
mock_tool_file = MagicMock()
mock_tool_file.id = "test_file_id"
mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file
# Mock file factory
mock_file_obj = MagicMock()
mock_file_factory.build_from_mapping.return_value = mock_file_obj
# Mock feature service
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
yield {
"async_service": mock_async_service,
"tool_file_manager": mock_tool_file_manager,
"file_factory": mock_file_factory,
"tool_file": mock_tool_file,
"file_obj": mock_file_obj,
"feature_service": mock_feature_service,
}
@pytest.fixture
def test_data(self, db_session_with_containers, mock_external_dependencies):
"""Create test data for webhook service tests."""
fake = Faker()
# Create account and tenant
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=fake.password(length=12),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
# Create app
app = App(
tenant_id=tenant.id,
name=fake.company(),
description=fake.text(),
mode="workflow",
icon="",
icon_background="",
enable_site=True,
enable_api=True,
)
db_session_with_containers.add(app)
db_session_with_containers.flush()
# Create workflow
workflow_data = {
"nodes": [
{
"id": "webhook_node",
"type": "webhook",
"data": {
"title": "Test Webhook",
"method": "post",
"content_type": "application/json",
"headers": [
{"name": "Authorization", "required": True},
{"name": "Content-Type", "required": False},
],
"params": [{"name": "version", "required": True}, {"name": "format", "required": False}],
"body": [
{"name": "message", "type": "string", "required": True},
{"name": "count", "type": "number", "required": False},
{"name": "upload", "type": "file", "required": False},
],
"status_code": 200,
"response_body": '{"status": "success"}',
"timeout": 30,
},
}
],
"edges": [],
}
workflow = Workflow(
tenant_id=tenant.id,
app_id=app.id,
type="workflow",
graph=json.dumps(workflow_data),
features=json.dumps({}),
created_by=account.id,
environment_variables=[],
conversation_variables=[],
version="1.0",
)
db_session_with_containers.add(workflow)
db_session_with_containers.flush()
# Create webhook trigger
webhook_id = fake.uuid4()[:16]
webhook_trigger = WorkflowWebhookTrigger(
app_id=app.id,
node_id="webhook_node",
tenant_id=tenant.id,
webhook_id=webhook_id,
created_by=account.id,
)
db_session_with_containers.add(webhook_trigger)
db_session_with_containers.flush()
# Create app trigger (required for non-debug mode)
app_trigger = AppTrigger(
tenant_id=tenant.id,
app_id=app.id,
node_id="webhook_node",
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
title="Test Webhook",
status=AppTriggerStatus.ENABLED,
)
db_session_with_containers.add(app_trigger)
db_session_with_containers.commit()
return {
"tenant": tenant,
"account": account,
"app": app,
"workflow": workflow,
"webhook_trigger": webhook_trigger,
"webhook_id": webhook_id,
"app_trigger": app_trigger,
}
def test_get_webhook_trigger_and_workflow_success(self, test_data, flask_app_with_containers):
"""Test successful retrieval of webhook trigger and workflow."""
webhook_id = test_data["webhook_id"]
with flask_app_with_containers.app_context():
webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow(webhook_id)
assert webhook_trigger is not None
assert webhook_trigger.webhook_id == webhook_id
assert workflow is not None
assert workflow.app_id == test_data["app"].id
assert node_config is not None
assert node_config["id"] == "webhook_node"
assert node_config["data"]["title"] == "Test Webhook"
def test_get_webhook_trigger_and_workflow_not_found(self, flask_app_with_containers):
"""Test webhook trigger not found scenario."""
with flask_app_with_containers.app_context():
with pytest.raises(ValueError, match="Webhook not found"):
WebhookService.get_webhook_trigger_and_workflow("nonexistent_webhook")
def test_extract_webhook_data_json(self):
"""Test webhook data extraction from JSON request."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/json", "Authorization": "Bearer token"},
query_string="version=1&format=json",
json={"message": "hello", "count": 42},
):
webhook_trigger = MagicMock()
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
assert webhook_data["method"] == "POST"
assert webhook_data["headers"]["Authorization"] == "Bearer token"
assert webhook_data["query_params"]["version"] == "1"
assert webhook_data["query_params"]["format"] == "json"
assert webhook_data["body"]["message"] == "hello"
assert webhook_data["body"]["count"] == 42
assert webhook_data["files"] == {}
def test_extract_webhook_data_form_urlencoded(self):
"""Test webhook data extraction from form URL encoded request."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={"username": "test", "password": "secret"},
):
webhook_trigger = MagicMock()
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
assert webhook_data["method"] == "POST"
assert webhook_data["body"]["username"] == "test"
assert webhook_data["body"]["password"] == "secret"
def test_extract_webhook_data_multipart_with_files(self, mock_external_dependencies):
"""Test webhook data extraction from multipart form with files."""
app = Flask(__name__)
# Create a mock file
file_content = b"test file content"
file_storage = FileStorage(stream=BytesIO(file_content), filename="test.txt", content_type="text/plain")
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "multipart/form-data"},
data={"message": "test", "upload": file_storage},
):
webhook_trigger = MagicMock()
webhook_trigger.tenant_id = "test_tenant"
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
assert webhook_data["method"] == "POST"
assert webhook_data["body"]["message"] == "test"
assert "upload" in webhook_data["files"]
# Verify file processing was called
mock_external_dependencies["tool_file_manager"].assert_called_once()
mock_external_dependencies["file_factory"].build_from_mapping.assert_called_once()
def test_extract_webhook_data_raw_text(self):
"""Test webhook data extraction from raw text request."""
app = Flask(__name__)
with app.test_request_context(
"/webhook", method="POST", headers={"Content-Type": "text/plain"}, data="raw text content"
):
webhook_trigger = MagicMock()
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
assert webhook_data["method"] == "POST"
assert webhook_data["body"]["raw"] == "raw text content"
def test_extract_and_validate_webhook_request_success(self):
"""Test successful webhook request validation and type conversion."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/json", "Authorization": "Bearer token"},
query_string="version=1",
json={"message": "hello"},
):
webhook_trigger = MagicMock()
node_config = {
"data": {
"method": "post",
"content_type": "application/json",
"headers": [
{"name": "Authorization", "required": True},
{"name": "Content-Type", "required": False},
],
"params": [{"name": "version", "required": True}],
"body": [{"name": "message", "type": "string", "required": True}],
}
}
result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
assert result["headers"]["Authorization"] == "Bearer token"
assert result["query_params"]["version"] == "1"
assert result["body"]["message"] == "hello"
def test_extract_and_validate_webhook_request_method_mismatch(self):
"""Test webhook validation with HTTP method mismatch."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="GET",
headers={"Content-Type": "application/json"},
):
webhook_trigger = MagicMock()
node_config = {"data": {"method": "post", "content_type": "application/json"}}
with pytest.raises(ValueError, match="HTTP method mismatch"):
WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
def test_extract_and_validate_webhook_request_missing_required_header(self):
"""Test webhook validation with missing required header."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/json"},
):
webhook_trigger = MagicMock()
node_config = {
"data": {
"method": "post",
"content_type": "application/json",
"headers": [{"name": "Authorization", "required": True}],
}
}
with pytest.raises(ValueError, match="Required header missing: Authorization"):
WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
def test_extract_and_validate_webhook_request_case_insensitive_headers(self):
"""Test webhook validation with case-insensitive header matching."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/json", "authorization": "Bearer token"},
json={"message": "hello"},
):
webhook_trigger = MagicMock()
node_config = {
"data": {
"method": "post",
"content_type": "application/json",
"headers": [{"name": "Authorization", "required": True}],
"body": [{"name": "message", "type": "string", "required": True}],
}
}
result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
assert result["headers"].get("Authorization") == "Bearer token"
def test_extract_and_validate_webhook_request_missing_required_param(self):
"""Test webhook validation with missing required query parameter."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/json"},
json={"message": "hello"},
):
webhook_trigger = MagicMock()
node_config = {
"data": {
"method": "post",
"content_type": "application/json",
"params": [{"name": "version", "required": True}],
"body": [{"name": "message", "type": "string", "required": True}],
}
}
with pytest.raises(ValueError, match="Required parameter missing: version"):
WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
def test_extract_and_validate_webhook_request_missing_required_body_param(self):
"""Test webhook validation with missing required body parameter."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/json"},
json={},
):
webhook_trigger = MagicMock()
node_config = {
"data": {
"method": "post",
"content_type": "application/json",
"body": [{"name": "message", "type": "string", "required": True}],
}
}
with pytest.raises(ValueError, match="Required body parameter missing: message"):
WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
def test_extract_and_validate_webhook_request_missing_required_file(self):
"""Test webhook validation when required file is missing from multipart request."""
app = Flask(__name__)
with app.test_request_context(
"/webhook",
method="POST",
data={"note": "test"},
content_type="multipart/form-data",
):
webhook_trigger = MagicMock()
webhook_trigger.tenant_id = "tenant"
webhook_trigger.created_by = "user"
node_config = {
"data": {
"method": "post",
"content_type": "multipart/form-data",
"body": [{"name": "upload", "type": "file", "required": True}],
}
}
result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
assert result["files"] == {}
def test_trigger_workflow_execution_success(self, test_data, mock_external_dependencies, flask_app_with_containers):
"""Test successful workflow execution trigger."""
webhook_data = {
"method": "POST",
"headers": {"Authorization": "Bearer token"},
"query_params": {"version": "1"},
"body": {"message": "hello"},
"files": {},
}
with flask_app_with_containers.app_context():
# Mock tenant owner lookup to return the test account
with patch("services.trigger.webhook_service.select") as mock_select:
mock_query = MagicMock()
mock_select.return_value.join.return_value.where.return_value = mock_query
# Mock the session to return our test account
with patch("services.trigger.webhook_service.Session") as mock_session:
mock_session_instance = MagicMock()
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_session_instance.scalar.return_value = test_data["account"]
# Should not raise any exceptions
WebhookService.trigger_workflow_execution(
test_data["webhook_trigger"], webhook_data, test_data["workflow"]
)
# Verify AsyncWorkflowService was called
mock_external_dependencies["async_service"].trigger_workflow_async.assert_called_once()
def test_trigger_workflow_execution_end_user_service_failure(
self, test_data, mock_external_dependencies, flask_app_with_containers
):
"""Test workflow execution trigger when EndUserService fails."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
with flask_app_with_containers.app_context():
# Mock EndUserService to raise an exception
with patch(
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type"
) as mock_end_user:
mock_end_user.side_effect = ValueError("Failed to create end user")
with pytest.raises(ValueError, match="Failed to create end user"):
WebhookService.trigger_workflow_execution(
test_data["webhook_trigger"], webhook_data, test_data["workflow"]
)
def test_generate_webhook_response_default(self):
"""Test webhook response generation with default values."""
node_config = {"data": {}}
response_data, status_code = WebhookService.generate_webhook_response(node_config)
assert status_code == 200
assert response_data["status"] == "success"
assert "Webhook processed successfully" in response_data["message"]
def test_generate_webhook_response_custom_json(self):
"""Test webhook response generation with custom JSON response."""
node_config = {"data": {"status_code": 201, "response_body": '{"result": "created", "id": 123}'}}
response_data, status_code = WebhookService.generate_webhook_response(node_config)
assert status_code == 201
assert response_data["result"] == "created"
assert response_data["id"] == 123
def test_generate_webhook_response_custom_text(self):
"""Test webhook response generation with custom text response."""
node_config = {"data": {"status_code": 202, "response_body": "Request accepted for processing"}}
response_data, status_code = WebhookService.generate_webhook_response(node_config)
assert status_code == 202
assert response_data["message"] == "Request accepted for processing"
def test_generate_webhook_response_invalid_json(self):
"""Test webhook response generation with invalid JSON response."""
node_config = {"data": {"status_code": 400, "response_body": '{"invalid": json}'}}
response_data, status_code = WebhookService.generate_webhook_response(node_config)
assert status_code == 400
assert response_data["message"] == '{"invalid": json}'
def test_process_file_uploads_success(self, mock_external_dependencies):
"""Test successful file upload processing."""
# Create mock files
files = {
"file1": MagicMock(filename="test1.txt", content_type="text/plain"),
"file2": MagicMock(filename="test2.jpg", content_type="image/jpeg"),
}
# Mock file reads
files["file1"].read.return_value = b"content1"
files["file2"].read.return_value = b"content2"
webhook_trigger = MagicMock()
webhook_trigger.tenant_id = "test_tenant"
result = WebhookService._process_file_uploads(files, webhook_trigger)
assert len(result) == 2
assert "file1" in result
assert "file2" in result
# Verify file processing was called for each file
assert mock_external_dependencies["tool_file_manager"].call_count == 2
assert mock_external_dependencies["file_factory"].build_from_mapping.call_count == 2
def test_process_file_uploads_with_errors(self, mock_external_dependencies):
"""Test file upload processing with errors."""
# Create mock files, one will fail
files = {
"good_file": MagicMock(filename="test.txt", content_type="text/plain"),
"bad_file": MagicMock(filename="test.bad", content_type="text/plain"),
}
files["good_file"].read.return_value = b"content"
files["bad_file"].read.side_effect = Exception("Read error")
webhook_trigger = MagicMock()
webhook_trigger.tenant_id = "test_tenant"
result = WebhookService._process_file_uploads(files, webhook_trigger)
# Should process the good file and skip the bad one
assert len(result) == 1
assert "good_file" in result
assert "bad_file" not in result
def test_process_file_uploads_empty_filename(self, mock_external_dependencies):
"""Test file upload processing with empty filename."""
files = {
"no_filename": MagicMock(filename="", content_type="text/plain"),
"none_filename": MagicMock(filename=None, content_type="text/plain"),
}
webhook_trigger = MagicMock()
webhook_trigger.tenant_id = "test_tenant"
result = WebhookService._process_file_uploads(files, webhook_trigger)
# Should skip files without filenames
assert len(result) == 0
mock_external_dependencies["tool_file_manager"].assert_not_called()

View File

@@ -584,7 +584,16 @@ class TestWorkflowService:
account = self._create_test_account(db_session_with_containers, fake)
app = self._create_test_app(db_session_with_containers, fake)
graph = {"nodes": [{"id": "start", "type": "start"}], "edges": []}
graph = {
"nodes": [
{
"id": "start",
"type": "start",
"data": {"type": "start", "title": "Start"},
}
],
"edges": [],
}
features = {"features": ["feature1", "feature2"]}
# Don't pre-calculate hash, let the service generate it
unique_hash = None
@@ -632,7 +641,25 @@ class TestWorkflowService:
# Get the actual hash that was generated
original_hash = existing_workflow.unique_hash
new_graph = {"nodes": [{"id": "start", "type": "start"}, {"id": "end", "type": "end"}], "edges": []}
new_graph = {
"nodes": [
{
"id": "start",
"type": "start",
"data": {"type": "start", "title": "Start"},
},
{
"id": "end",
"type": "end",
"data": {
"type": "end",
"title": "End",
"outputs": [{"variable": "output", "value_selector": ["start", "text"]}],
},
},
],
"edges": [],
}
new_features = {"features": ["feature1", "feature2", "feature3"]}
environment_variables = []
@@ -679,7 +706,16 @@ class TestWorkflowService:
# Get the actual hash that was generated
original_hash = existing_workflow.unique_hash
new_graph = {"nodes": [{"id": "start", "type": "start"}], "edges": []}
new_graph = {
"nodes": [
{
"id": "start",
"type": "start",
"data": {"type": "start", "title": "Start"},
}
],
"edges": [],
}
new_features = {"features": ["feature1"]}
# Use a different hash to trigger the error
mismatched_hash = "different_hash_12345"

View File

@@ -8,6 +8,7 @@ from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from libs.uuid_utils import uuidv7
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
from services.plugin.plugin_service import PluginService
from services.tools.tools_transform_service import ToolTransformService
@@ -17,15 +18,14 @@ class TestToolTransformService:
@pytest.fixture
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.tools.tools_transform_service.dify_config") as mock_dify_config,
):
# Setup default mock returns
mock_dify_config.CONSOLE_API_URL = "https://console.example.com"
with patch("services.tools.tools_transform_service.dify_config") as mock_dify_config:
with patch("services.plugin.plugin_service.dify_config", new=mock_dify_config):
# Setup default mock returns
mock_dify_config.CONSOLE_API_URL = "https://console.example.com"
yield {
"dify_config": mock_dify_config,
}
yield {
"dify_config": mock_dify_config,
}
def _create_test_tool_provider(
self, db_session_with_containers, mock_external_service_dependencies, provider_type="api"
@@ -113,13 +113,13 @@ class TestToolTransformService:
filename = "test_icon.png"
# Act: Execute the method under test
result = ToolTransformService.get_plugin_icon_url(tenant_id, filename)
result = PluginService.get_plugin_icon_url(str(tenant_id), filename)
# Assert: Verify the expected outcomes
assert result is not None
assert isinstance(result, str)
assert "console/api/workspaces/current/plugin/icon" in result
assert tenant_id in result
assert str(tenant_id) in result
assert filename in result
assert result.startswith("https://console.example.com")
@@ -144,13 +144,13 @@ class TestToolTransformService:
filename = "test_icon.png"
# Act: Execute the method under test
result = ToolTransformService.get_plugin_icon_url(tenant_id, filename)
result = PluginService.get_plugin_icon_url(str(tenant_id), filename)
# Assert: Verify the expected outcomes
assert result is not None
assert isinstance(result, str)
assert result.startswith("/console/api/workspaces/current/plugin/icon")
assert tenant_id in result
assert str(tenant_id) in result
assert filename in result
# Verify URL structure
@@ -334,7 +334,7 @@ class TestToolTransformService:
provider = {"type": ToolProviderType.BUILT_IN, "name": fake.company(), "icon": "🔧"}
# Act: Execute the method under test
ToolTransformService.repack_provider(tenant_id, provider)
ToolTransformService.repack_provider(str(tenant_id), provider)
# Assert: Verify the expected outcomes
assert "icon" in provider
@@ -358,7 +358,7 @@ class TestToolTransformService:
# Create provider entity with plugin_id
provider = ToolProviderApiEntity(
id=fake.uuid4(),
id=str(fake.uuid4()),
author=fake.name(),
name=fake.company(),
description=I18nObject(en_US=fake.text(max_nb_chars=100)),
@@ -380,14 +380,14 @@ class TestToolTransformService:
assert provider.icon is not None
assert isinstance(provider.icon, str)
assert "console/api/workspaces/current/plugin/icon" in provider.icon
assert tenant_id in provider.icon
assert str(tenant_id) in provider.icon
assert "test_icon.png" in provider.icon
# Verify dark icon handling
assert provider.icon_dark is not None
assert isinstance(provider.icon_dark, str)
assert "console/api/workspaces/current/plugin/icon" in provider.icon_dark
assert tenant_id in provider.icon_dark
assert str(tenant_id) in provider.icon_dark
assert "test_icon_dark.png" in provider.icon_dark
def test_repack_provider_entity_no_plugin_success(
@@ -423,7 +423,7 @@ class TestToolTransformService:
)
# Act: Execute the method under test
ToolTransformService.repack_provider(tenant_id, provider)
ToolTransformService.repack_provider(str(tenant_id), provider)
# Assert: Verify the expected outcomes
assert provider.icon is not None
@@ -521,7 +521,7 @@ class TestToolTransformService:
with patch("services.tools.tools_transform_service.create_provider_encrypter") as mock_encrypter:
mock_encrypter_instance = Mock()
mock_encrypter_instance.decrypt.return_value = {"api_key": "decrypted_key"}
mock_encrypter_instance.mask_tool_credentials.return_value = {"api_key": ""}
mock_encrypter_instance.mask_plugin_credentials.return_value = {"api_key": ""}
mock_encrypter.return_value = (mock_encrypter_instance, None)
# Act: Execute the method under test