mirror of
https://github.com/langgenius/dify.git
synced 2026-10-24 02:00:40 +08:00
feat(api): introduce select, file and file list form input types to Human Input node (#36322)
Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: GPT 5.4 <codex@openai.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
co-authored by
JzoNg
GPT 5.4
autofix-ci[bot]
-LAN-
parent
44725dde74
commit
3c98f96ae8
@@ -13,7 +13,6 @@ from controllers.web.error import NotFoundError
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
|
||||
from graphon.nodes.human_input.enums import FormInputType
|
||||
from libs import login as login_lib
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.workflow import WorkflowRun
|
||||
@@ -66,7 +65,7 @@ def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pyte
|
||||
reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
|
||||
inputs=[ParagraphInputConfig(output_variable_name="name")],
|
||||
actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
|
||||
@@ -593,7 +593,11 @@ class TestHitlServiceApi:
|
||||
form_id="form-1",
|
||||
form_content="Rendered",
|
||||
inputs=[
|
||||
ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None),
|
||||
ParagraphInputConfig(
|
||||
type=FormInputType.PARAGRAPH,
|
||||
output_variable_name="field",
|
||||
default=None,
|
||||
),
|
||||
],
|
||||
actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
display_in_ui=True,
|
||||
@@ -607,7 +611,7 @@ class TestHitlServiceApi:
|
||||
paused_nodes=["node-id"],
|
||||
)
|
||||
|
||||
runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0)
|
||||
runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0, variable_pool=VariablePool())
|
||||
responses = converter.workflow_pause_to_stream_response(
|
||||
event=queue_event,
|
||||
task_id="task",
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload
|
||||
from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi
|
||||
from models.human_input import RecipientType
|
||||
from tests.unit_tests.controllers.service_api.conftest import _unwrap
|
||||
@@ -20,7 +21,7 @@ from tests.unit_tests.controllers.service_api.conftest import _unwrap
|
||||
class TestWorkflowHumanInputFormApi:
|
||||
def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
definition = SimpleNamespace(
|
||||
model_dump=lambda: {
|
||||
model_dump=lambda **_kwargs: {
|
||||
"rendered_content": "Rendered form content",
|
||||
"inputs": [{"output_variable_name": "name"}],
|
||||
"default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}},
|
||||
@@ -36,6 +37,9 @@ class TestWorkflowHumanInputFormApi:
|
||||
)
|
||||
service_mock = Mock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
service_mock.resolve_form_inputs.return_value = [
|
||||
SimpleNamespace(model_dump=lambda **_kwargs: {"output_variable_name": "name"})
|
||||
]
|
||||
workflow_module = sys.modules["controllers.service_api.app.human_input_form"]
|
||||
monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object()))
|
||||
@@ -56,8 +60,54 @@ class TestWorkflowHumanInputFormApi:
|
||||
"expiration_time": int(form.expiration_time.timestamp()),
|
||||
}
|
||||
service_mock.get_form_by_token.assert_called_once_with("token-1")
|
||||
service_mock.resolve_form_inputs.assert_called_once_with(form)
|
||||
service_mock.ensure_form_active.assert_called_once_with(form)
|
||||
|
||||
def test_get_resolves_runtime_select_values(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
definition = SimpleNamespace(
|
||||
model_dump=lambda **_kwargs: {
|
||||
"rendered_content": "Rendered form content",
|
||||
"inputs": [
|
||||
{
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {"type": "variable", "selector": ["start", "options"], "value": []},
|
||||
}
|
||||
],
|
||||
"default_values": {},
|
||||
"user_actions": [{"id": "approve", "title": "Approve"}],
|
||||
}
|
||||
)
|
||||
form = SimpleNamespace(
|
||||
app_id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
expiration_time=datetime(2099, 1, 1, tzinfo=UTC),
|
||||
get_definition=lambda: definition,
|
||||
)
|
||||
resolved_input = SimpleNamespace(
|
||||
model_dump=lambda **_kwargs: {
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {"type": "variable", "selector": ["start", "options"], "value": ["approve", "reject"]},
|
||||
}
|
||||
)
|
||||
service_mock = Mock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
service_mock.resolve_form_inputs.return_value = [resolved_input]
|
||||
workflow_module = sys.modules["controllers.service_api.app.human_input_form"]
|
||||
monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = WorkflowHumanInputFormApi()
|
||||
handler = _unwrap(api.get)
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
|
||||
with app.test_request_context("/form/human_input/token-1", method="GET"):
|
||||
response = handler(api, app_model=app_model, form_token="token-1")
|
||||
|
||||
payload = json.loads(response.get_data(as_text=True))
|
||||
assert payload["inputs"][0]["option_source"]["value"] == ["approve", "reject"]
|
||||
service_mock.resolve_form_inputs.assert_called_once_with(form)
|
||||
|
||||
def test_get_form_not_in_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
form = SimpleNamespace(
|
||||
app_id="another-app",
|
||||
@@ -146,6 +196,71 @@ class TestWorkflowHumanInputFormApi:
|
||||
submission_end_user_id="end-user-1",
|
||||
)
|
||||
|
||||
def test_post_accepts_select_file_and_file_list_inputs(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
form = SimpleNamespace(
|
||||
app_id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
)
|
||||
service_mock = Mock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
workflow_module = sys.modules["controllers.service_api.app.human_input_form"]
|
||||
monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = WorkflowHumanInputFormApi()
|
||||
handler = _unwrap(api.post)
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
end_user = SimpleNamespace(id="end-user-1")
|
||||
inputs = {
|
||||
"decision": "approve",
|
||||
"attachment": {
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e",
|
||||
"type": "document",
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"transfer_method": "local_file",
|
||||
"upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee",
|
||||
"type": "document",
|
||||
},
|
||||
{
|
||||
"transfer_method": "remote_url",
|
||||
"url": "https://example.com/report.pdf",
|
||||
"type": "document",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/form/human_input/token-1",
|
||||
method="POST",
|
||||
json={"inputs": inputs, "action": "approve", "user": "external-1"},
|
||||
):
|
||||
response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1")
|
||||
|
||||
assert response == {}
|
||||
assert status == 200
|
||||
service_mock.submit_form_by_token.assert_called_once_with(
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
form_token="token-1",
|
||||
selected_action_id="approve",
|
||||
form_data=inputs,
|
||||
submission_end_user_id="end-user-1",
|
||||
)
|
||||
|
||||
def test_submit_payload_schema_documents_select_file_and_file_list_inputs(self) -> None:
|
||||
schema = HumanInputFormSubmitPayload.model_json_schema()
|
||||
|
||||
inputs_schema = schema["properties"]["inputs"]
|
||||
assert "select input" in inputs_schema["description"]
|
||||
examples = inputs_schema["examples"]
|
||||
assert examples[0]["decision"] == "approve"
|
||||
assert examples[0]["attachment"]["transfer_method"] == "local_file"
|
||||
assert examples[0]["attachment"]["upload_file_id"] == "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e"
|
||||
assert examples[0]["attachments"][1]["transfer_method"] == "remote_url"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recipient_type",
|
||||
[
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Unit tests for HITL human input file upload endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
import controllers.web.human_input_file_upload as upload_module
|
||||
from controllers.common.errors import NoFileUploadedError
|
||||
from controllers.web.human_input_file_upload import (
|
||||
HumanInputFileUploadApi,
|
||||
InvalidUploadTokenForbiddenError,
|
||||
InvalidUploadTokenUnauthorizedError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
def _upload_context() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
form_id="form-1",
|
||||
upload_token_id="token-row-1",
|
||||
owner=SimpleNamespace(id="owner-1", current_tenant_id="tenant-1"),
|
||||
)
|
||||
|
||||
|
||||
def _upload_file() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="file-1",
|
||||
name="sample.txt",
|
||||
size=7,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by="end-user-1",
|
||||
created_at=datetime(2024, 1, 1),
|
||||
tenant_id="tenant-1",
|
||||
source_url="signed-source-url",
|
||||
)
|
||||
|
||||
|
||||
def _patch_upload_service(monkeypatch: pytest.MonkeyPatch, service: MagicMock) -> tuple[MagicMock, dict[str, object]]:
|
||||
workflow_run_repository = MagicMock()
|
||||
repo_factory = MagicMock(return_value=workflow_run_repository)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _service_factory(session_factory, workflow_run_repository):
|
||||
captured["session_factory"] = session_factory
|
||||
captured["workflow_run_repository"] = workflow_run_repository
|
||||
return service
|
||||
|
||||
monkeypatch.setattr(
|
||||
upload_module.DifyAPIRepositoryFactory,
|
||||
"create_api_workflow_run_repository",
|
||||
repo_factory,
|
||||
)
|
||||
monkeypatch.setattr(upload_module, "HumanInputFileUploadService", _service_factory)
|
||||
return repo_factory, captured
|
||||
|
||||
|
||||
def test_human_input_file_upload_route_uses_unified_path() -> None:
|
||||
urls = {
|
||||
url for _resource, resource_urls, _route_doc, _kwargs in upload_module.web_ns.resources for url in resource_urls
|
||||
}
|
||||
|
||||
assert "/human-input-forms/files" in urls
|
||||
assert "/form/human_input/files/upload" not in urls
|
||||
assert "/form/human_input/files/remote-upload" not in urls
|
||||
|
||||
|
||||
def test_local_upload_requires_authorization_before_reading_files(app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"content"), "sample.txt")}
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/human-input-forms/files",
|
||||
method="POST",
|
||||
data=data,
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
with pytest.raises(InvalidUploadTokenUnauthorizedError):
|
||||
HumanInputFileUploadApi().post()
|
||||
|
||||
|
||||
def test_local_upload_ignores_source_and_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.return_value = _upload_context()
|
||||
repo_factory, captured = _patch_upload_service(monkeypatch, service)
|
||||
|
||||
file_service = MagicMock()
|
||||
file_service.upload_file.return_value = _upload_file()
|
||||
file_service_cls = MagicMock(return_value=file_service)
|
||||
monkeypatch.setattr(upload_module, "FileService", file_service_cls)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
data = {
|
||||
"file": (BytesIO(b"content"), "sample.txt"),
|
||||
"source": "datasets",
|
||||
}
|
||||
with app.test_request_context(
|
||||
"/api/human-input-forms/files",
|
||||
method="POST",
|
||||
headers={"Authorization": "bearer hitl_upload_token-1"},
|
||||
data=data,
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
result, status = HumanInputFileUploadApi().post()
|
||||
|
||||
assert status == 201
|
||||
assert result["id"] == "file-1"
|
||||
file_service.upload_file.assert_called_once()
|
||||
assert file_service.upload_file.call_args.kwargs["source"] is None
|
||||
assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1"
|
||||
repo_factory.assert_called_once()
|
||||
assert captured["workflow_run_repository"] is repo_factory.return_value
|
||||
service.record_upload_file.assert_called_once_with(
|
||||
context=service.validate_upload_token.return_value,
|
||||
file_id="file-1",
|
||||
)
|
||||
|
||||
|
||||
def test_local_upload_missing_file_raises_after_valid_token(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.return_value = _upload_context()
|
||||
_patch_upload_service(monkeypatch, service)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/human-input-forms/files",
|
||||
method="POST",
|
||||
headers={"Authorization": "bearer hitl_upload_token-1"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
with pytest.raises(NoFileUploadedError):
|
||||
HumanInputFileUploadApi().post()
|
||||
|
||||
service.validate_upload_token.assert_called_once_with("hitl_upload_token-1")
|
||||
|
||||
|
||||
def test_remote_upload_validates_token_before_fetching_remote_url(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.side_effect = InvalidUploadTokenForbiddenError()
|
||||
_patch_upload_service(monkeypatch, service)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
ssrf_proxy = MagicMock()
|
||||
monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy)
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/human-input-forms/files",
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer hitl_upload_token-1"},
|
||||
data={"url": "https://example.com/file.txt"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
with pytest.raises(InvalidUploadTokenForbiddenError):
|
||||
HumanInputFileUploadApi().post()
|
||||
|
||||
ssrf_proxy.head.assert_not_called()
|
||||
ssrf_proxy.get.assert_not_called()
|
||||
|
||||
|
||||
def test_remote_upload_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
|
||||
service = MagicMock()
|
||||
service.validate_upload_token.return_value = _upload_context()
|
||||
_patch_upload_service(monkeypatch, service)
|
||||
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.content = b"remote"
|
||||
response.request.method = "GET"
|
||||
ssrf_proxy = MagicMock()
|
||||
ssrf_proxy.head.return_value = response
|
||||
monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy)
|
||||
monkeypatch.setattr(
|
||||
upload_module.helpers,
|
||||
"guess_file_info_from_response",
|
||||
lambda _response: SimpleNamespace(filename="sample.txt", extension="txt", mimetype="text/plain", size=6),
|
||||
)
|
||||
|
||||
file_service = MagicMock()
|
||||
file_service.upload_file.return_value = _upload_file()
|
||||
file_service_cls = MagicMock(return_value=file_service)
|
||||
file_service_cls.is_file_size_within_limit.return_value = True
|
||||
monkeypatch.setattr(upload_module, "FileService", file_service_cls)
|
||||
monkeypatch.setattr(
|
||||
upload_module.file_helpers,
|
||||
"get_signed_file_url",
|
||||
lambda upload_file_id: f"signed:{upload_file_id}",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/human-input-forms/files",
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer hitl_upload_token-1"},
|
||||
data={"url": "https://example.com/file.txt"},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
result, status = HumanInputFileUploadApi().post()
|
||||
|
||||
assert status == 201
|
||||
assert result["url"] == "signed:file-1"
|
||||
file_service.upload_file.assert_called_once()
|
||||
assert file_service.upload_file.call_args.kwargs["source_url"] == "https://example.com/file.txt"
|
||||
assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1"
|
||||
service.record_upload_file.assert_called_once_with(
|
||||
context=service.validate_upload_token.return_value,
|
||||
file_id="file-1",
|
||||
)
|
||||
@@ -15,10 +15,14 @@ from werkzeug.exceptions import Forbidden
|
||||
import controllers.web.human_input_form as human_input_module
|
||||
import controllers.web.site as site_module
|
||||
from controllers.web.error import WebFormRateLimitExceededError
|
||||
from graphon.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource
|
||||
from graphon.nodes.human_input.enums import ValueSourceType
|
||||
from models.human_input import RecipientType
|
||||
from services.feature_service import FeatureModel
|
||||
from services.human_input_service import FormExpiredError
|
||||
|
||||
HumanInputFormApi = human_input_module.HumanInputFormApi
|
||||
HumanInputFormUploadTokenApi = human_input_module.HumanInputFormUploadTokenApi
|
||||
TenantStatus = human_input_module.TenantStatus
|
||||
|
||||
|
||||
@@ -63,7 +67,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self):
|
||||
def model_dump(self, mode: str | None = None):
|
||||
return {
|
||||
"form_content": "Raw content",
|
||||
"rendered_content": "Rendered {{#$output.name#}}",
|
||||
@@ -117,6 +121,8 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
# Patch service to return fake form.
|
||||
service_mock = MagicMock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
resolved_input = ParagraphInputConfig(output_variable_name="name")
|
||||
service_mock.resolve_form_inputs.return_value = [resolved_input]
|
||||
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
|
||||
|
||||
# Patch db session.
|
||||
@@ -142,7 +148,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"expiration_time",
|
||||
}
|
||||
assert body["form_content"] == "Rendered {{#$output.name#}}"
|
||||
assert body["inputs"] == [{"type": "text", "output_variable_name": "name", "default": None}]
|
||||
assert body["inputs"] == [resolved_input.model_dump(mode="json")]
|
||||
assert body["resolved_default_values"] == {"name": "Alice", "age": "30", "meta": '{"k": "v"}'}
|
||||
assert body["user_actions"] == [{"id": "approve", "title": "Approve", "button_style": "default"}]
|
||||
assert body["expiration_time"] == int(expiration_time.timestamp())
|
||||
@@ -180,13 +186,158 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
|
||||
|
||||
|
||||
def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET returns variable-backed select options resolved from runtime state."""
|
||||
|
||||
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
|
||||
configured_inputs = [
|
||||
{
|
||||
"type": "select",
|
||||
"output_variable_name": "decision",
|
||||
"option_source": {
|
||||
"type": "variable",
|
||||
"selector": ["start", "options"],
|
||||
"value": ["configured"],
|
||||
},
|
||||
}
|
||||
]
|
||||
runtime_inputs = [
|
||||
SelectInputConfig(
|
||||
output_variable_name="decision",
|
||||
option_source=StringListSource(
|
||||
type=ValueSourceType.VARIABLE,
|
||||
selector=["start", "options"],
|
||||
value=["approve", "reject"],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self, mode: str | None = None):
|
||||
return {
|
||||
"form_content": "Raw content",
|
||||
"rendered_content": "Rendered",
|
||||
"inputs": configured_inputs,
|
||||
"default_values": {},
|
||||
"user_actions": [],
|
||||
}
|
||||
|
||||
class _FakeForm:
|
||||
def __init__(self, expiration: datetime):
|
||||
self.workflow_run_id = "workflow-1"
|
||||
self.app_id = "app-1"
|
||||
self.tenant_id = "tenant-1"
|
||||
self.recipient_type = RecipientType.STANDALONE_WEB_APP
|
||||
self.expiration_time = expiration
|
||||
|
||||
def get_definition(self):
|
||||
return _FakeDefinition()
|
||||
|
||||
limiter_mock = MagicMock()
|
||||
limiter_mock.is_rate_limited.return_value = False
|
||||
monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock)
|
||||
monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
|
||||
|
||||
tenant = SimpleNamespace(
|
||||
id="tenant-1",
|
||||
status=TenantStatus.NORMAL,
|
||||
plan="basic",
|
||||
custom_config_dict={},
|
||||
)
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
|
||||
site_model = SimpleNamespace(
|
||||
title="My Site",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
icon_background="#fff",
|
||||
description="desc",
|
||||
default_language="en",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
copyright=None,
|
||||
privacy_policy=None,
|
||||
custom_disclaimer=None,
|
||||
prompt_public=False,
|
||||
show_workflow_steps=True,
|
||||
use_icon_as_answer_icon=False,
|
||||
)
|
||||
|
||||
form = _FakeForm(expiration_time)
|
||||
service_mock = MagicMock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
service_mock.resolve_form_inputs.return_value = runtime_inputs
|
||||
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
|
||||
monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({"App": app_model, "Site": site_model})))
|
||||
|
||||
def mock_get_features(tenant_id: str, exclude_vector_space: bool = False):
|
||||
return FeatureModel(can_replace_logo=True)
|
||||
|
||||
monkeypatch.setattr(site_module.FeatureService, "get_features", mock_get_features)
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
|
||||
response = HumanInputFormApi().get("token-1")
|
||||
|
||||
body = json.loads(response.get_data(as_text=True))
|
||||
assert body["inputs"] == [input_config.model_dump(mode="json") for input_config in runtime_inputs]
|
||||
service_mock.resolve_form_inputs.assert_called_once_with(form)
|
||||
|
||||
|
||||
def test_create_upload_token_returns_token_and_form_expiration(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""POST returns a HITL upload token for an active form token."""
|
||||
|
||||
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
|
||||
service_mock = MagicMock()
|
||||
service_mock.issue_upload_token.return_value = SimpleNamespace(
|
||||
upload_token="hitl_upload_token-1",
|
||||
expires_at=expiration_time,
|
||||
)
|
||||
workflow_run_repository = MagicMock()
|
||||
repo_factory = MagicMock(return_value=workflow_run_repository)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _service_factory(session_factory, workflow_run_repository):
|
||||
captured["session_factory"] = session_factory
|
||||
captured["workflow_run_repository"] = workflow_run_repository
|
||||
return service_mock
|
||||
|
||||
monkeypatch.setattr(
|
||||
human_input_module.DifyAPIRepositoryFactory,
|
||||
"create_api_workflow_run_repository",
|
||||
repo_factory,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
human_input_module,
|
||||
"HumanInputFileUploadService",
|
||||
_service_factory,
|
||||
)
|
||||
monkeypatch.setattr(human_input_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
limiter_mock = MagicMock()
|
||||
limiter_mock.is_rate_limited.return_value = False
|
||||
monkeypatch.setattr(human_input_module, "_FORM_UPLOAD_TOKEN_RATE_LIMITER", limiter_mock)
|
||||
monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
|
||||
|
||||
with app.test_request_context("/api/form/human_input/token-1/upload-token", method="POST"):
|
||||
result, status = HumanInputFormUploadTokenApi().post("token-1")
|
||||
|
||||
assert status == 200
|
||||
assert result == {
|
||||
"upload_token": "hitl_upload_token-1",
|
||||
"expires_at": int(expiration_time.timestamp()),
|
||||
}
|
||||
repo_factory.assert_called_once()
|
||||
assert captured["workflow_run_repository"] is workflow_run_repository
|
||||
service_mock.issue_upload_token.assert_called_once_with("token-1")
|
||||
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
|
||||
|
||||
|
||||
def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
|
||||
"""GET returns form payload for backstage token."""
|
||||
|
||||
expiration_time = datetime(2099, 1, 2, tzinfo=UTC)
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self):
|
||||
def model_dump(self, mode: str | None = None):
|
||||
return {
|
||||
"form_content": "Raw content",
|
||||
"rendered_content": "Rendered",
|
||||
@@ -237,6 +388,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
service_mock.resolve_form_inputs.return_value = []
|
||||
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
|
||||
|
||||
db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": site_model}))
|
||||
@@ -305,7 +457,7 @@ def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyP
|
||||
expiration_time = datetime(2099, 1, 3, tzinfo=UTC)
|
||||
|
||||
class _FakeDefinition:
|
||||
def model_dump(self):
|
||||
def model_dump(self, mode: str | None = None):
|
||||
return {
|
||||
"form_content": "Raw content",
|
||||
"rendered_content": "Rendered",
|
||||
|
||||
Reference in New Issue
Block a user