mirror of
https://github.com/langgenius/dify.git
synced 2026-07-30 08:49:31 +08:00
test: use sqlite3 session in test_human_input_timeout_tasks (#38703)
This commit is contained in:
parent
b6bfae7e3c
commit
958d8a7997
@ -2,74 +2,22 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import core.db.session_factory as session_factory_module
|
||||
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from models.human_input import HumanInputForm
|
||||
from tasks import human_input_timeout_tasks as task_module
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, items: list[Any]):
|
||||
self._items = items
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return self._items
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, items: list[Any], capture: dict[str, Any]):
|
||||
self._items = items
|
||||
self._capture = capture
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalars(self, stmt):
|
||||
self._capture["stmt"] = stmt
|
||||
return _FakeScalarResult(self._items)
|
||||
|
||||
|
||||
class _FakeSessionFactory:
|
||||
def __init__(self, items: list[Any], capture: dict[str, Any]):
|
||||
self._items = items
|
||||
self._capture = capture
|
||||
self._capture["session_factory"] = self
|
||||
|
||||
def __call__(self):
|
||||
session = _FakeSession(self._items, self._capture)
|
||||
self._capture["session"] = session
|
||||
return session
|
||||
|
||||
|
||||
class _FakeFormRepo:
|
||||
def __init__(self, form_map: dict[str, Any] | None = None):
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self._form_map = form_map or {}
|
||||
|
||||
def mark_timeout(self, *, form_id: str, timeout_status: HumanInputFormStatus, reason: str | None = None):
|
||||
self.calls.append(
|
||||
{
|
||||
"form_id": form_id,
|
||||
"timeout_status": timeout_status,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
form = self._form_map.get(form_id)
|
||||
return SimpleNamespace(
|
||||
form_id=form_id,
|
||||
workflow_run_id=getattr(form, "workflow_run_id", None),
|
||||
conversation_id=getattr(form, "conversation_id", None),
|
||||
node_id=getattr(form, "node_id", None),
|
||||
)
|
||||
|
||||
|
||||
class _FakeService:
|
||||
def __init__(self, _session_factory, form_repository=None):
|
||||
def __init__(self):
|
||||
self.enqueued: list[str] = []
|
||||
self.agent_app_resumed: list[tuple[str, str]] = []
|
||||
|
||||
@ -90,22 +38,49 @@ def _build_form(
|
||||
workflow_run_id: str | None,
|
||||
node_id: str,
|
||||
conversation_id: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
) -> HumanInputForm:
|
||||
form_definition = FormDefinition(
|
||||
form_content="",
|
||||
rendered_content="",
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
return HumanInputForm(
|
||||
id=form_id,
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
form_kind=form_kind,
|
||||
created_at=created_at,
|
||||
expiration_time=expiration_time,
|
||||
workflow_run_id=workflow_run_id,
|
||||
conversation_id=conversation_id,
|
||||
node_id=node_id,
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_task_database(
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(session_factory_module, "_session_maker", repository_session_maker)
|
||||
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
|
||||
|
||||
def test_is_global_timeout_uses_created_at():
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
form = SimpleNamespace(created_at=now - timedelta(seconds=61), workflow_run_id="run-1")
|
||||
form = _build_form(
|
||||
form_id="form-1",
|
||||
form_kind=HumanInputFormKind.RUNTIME,
|
||||
created_at=now - timedelta(seconds=61),
|
||||
expiration_time=now + timedelta(hours=1),
|
||||
workflow_run_id="run-1",
|
||||
node_id="node-1",
|
||||
)
|
||||
|
||||
assert task_module._is_global_timeout(form, 60, now=now) is True
|
||||
|
||||
@ -119,11 +94,16 @@ def test_is_global_timeout_uses_created_at():
|
||||
assert task_module._is_global_timeout(form, 0, now=now) is False
|
||||
|
||||
|
||||
def test_check_and_handle_human_input_timeouts_marks_and_routes(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
|
||||
def test_check_and_handle_human_input_timeouts_marks_and_routes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_task_database: None,
|
||||
sqlite_engine: Engine,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
|
||||
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600)
|
||||
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
forms = [
|
||||
_build_form(
|
||||
@ -151,74 +131,131 @@ def test_check_and_handle_human_input_timeouts_marks_and_routes(monkeypatch: pyt
|
||||
node_id="node-delivery",
|
||||
),
|
||||
]
|
||||
sqlite_session.add_all(forms)
|
||||
sqlite_session.commit()
|
||||
|
||||
capture: dict[str, Any] = {}
|
||||
monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory(forms, capture))
|
||||
repo = HumanInputFormSubmissionRepository()
|
||||
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
|
||||
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
|
||||
service = _FakeService()
|
||||
service_factory = MagicMock(return_value=service)
|
||||
global_timeout_handler = MagicMock()
|
||||
|
||||
form_map = {form.id: form for form in forms}
|
||||
repo = _FakeFormRepo(form_map=form_map)
|
||||
|
||||
def _repo_factory():
|
||||
return repo
|
||||
|
||||
service = _FakeService(None)
|
||||
|
||||
def _service_factory(_session_factory, form_repository=None):
|
||||
return service
|
||||
|
||||
global_calls: list[dict[str, Any]] = []
|
||||
|
||||
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", _repo_factory)
|
||||
monkeypatch.setattr(task_module, "HumanInputService", _service_factory)
|
||||
monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **kwargs: global_calls.append(kwargs))
|
||||
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
|
||||
monkeypatch.setattr(task_module, "HumanInputService", service_factory)
|
||||
monkeypatch.setattr(task_module, "_handle_global_timeout", global_timeout_handler)
|
||||
|
||||
task_module.check_and_handle_human_input_timeouts(limit=100)
|
||||
|
||||
assert {(call["form_id"], call["timeout_status"], call["reason"]) for call in repo.calls} == {
|
||||
assert {
|
||||
(call.kwargs["form_id"], call.kwargs["timeout_status"], call.kwargs["reason"])
|
||||
for call in mark_timeout_spy.call_args_list
|
||||
} == {
|
||||
("form-global", HumanInputFormStatus.EXPIRED, "global_timeout"),
|
||||
("form-node", HumanInputFormStatus.TIMEOUT, "node_timeout"),
|
||||
("form-delivery", HumanInputFormStatus.TIMEOUT, "delivery_test_timeout"),
|
||||
}
|
||||
assert service.enqueued == ["run-node"]
|
||||
assert global_calls == [
|
||||
{
|
||||
"form_id": "form-global",
|
||||
"workflow_run_id": "run-global",
|
||||
"node_id": "node-global",
|
||||
"session_factory": capture.get("session_factory"),
|
||||
}
|
||||
]
|
||||
global_timeout_handler.assert_called_once()
|
||||
global_timeout_call = global_timeout_handler.call_args.kwargs
|
||||
assert global_timeout_call["form_id"] == "form-global"
|
||||
assert global_timeout_call["workflow_run_id"] == "run-global"
|
||||
assert global_timeout_call["node_id"] == "node-global"
|
||||
task_session_maker = global_timeout_call["session_factory"]
|
||||
assert isinstance(task_session_maker, sessionmaker)
|
||||
assert task_session_maker.kw["bind"] is sqlite_engine
|
||||
service_factory.assert_called_once_with(task_session_maker, form_repository=repo)
|
||||
|
||||
stmt = capture.get("stmt")
|
||||
assert stmt is not None
|
||||
stmt_text = str(stmt)
|
||||
assert "created_at <=" in stmt_text
|
||||
assert "expiration_time <=" in stmt_text
|
||||
assert "ORDER BY human_input_forms.id" in stmt_text
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.get(HumanInputForm, "form-global").status == HumanInputFormStatus.EXPIRED
|
||||
assert sqlite_session.get(HumanInputForm, "form-node").status == HumanInputFormStatus.TIMEOUT
|
||||
assert sqlite_session.get(HumanInputForm, "form-delivery").status == HumanInputFormStatus.TIMEOUT
|
||||
|
||||
|
||||
def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
|
||||
def test_check_and_handle_human_input_timeouts_orders_by_id_before_limit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_task_database: None,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
|
||||
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
|
||||
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
capture: dict[str, Any] = {}
|
||||
monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory([], capture))
|
||||
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", _FakeFormRepo)
|
||||
monkeypatch.setattr(task_module, "HumanInputService", _FakeService)
|
||||
monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **_kwargs: None)
|
||||
forms = [
|
||||
_build_form(
|
||||
form_id=form_id,
|
||||
form_kind=HumanInputFormKind.DELIVERY_TEST,
|
||||
created_at=now - timedelta(minutes=1),
|
||||
expiration_time=now - timedelta(seconds=1),
|
||||
workflow_run_id=None,
|
||||
node_id=f"node-{form_id}",
|
||||
)
|
||||
for form_id in ("form-b", "form-a")
|
||||
]
|
||||
sqlite_session.add_all(forms)
|
||||
sqlite_session.commit()
|
||||
|
||||
repo = HumanInputFormSubmissionRepository()
|
||||
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
|
||||
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
|
||||
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
|
||||
monkeypatch.setattr(task_module, "HumanInputService", MagicMock(return_value=_FakeService()))
|
||||
|
||||
task_module.check_and_handle_human_input_timeouts(limit=1)
|
||||
|
||||
stmt = capture.get("stmt")
|
||||
assert stmt is not None
|
||||
stmt_text = str(stmt)
|
||||
assert "created_at <=" not in stmt_text
|
||||
mark_timeout_spy.assert_called_once_with(
|
||||
form_id="form-a",
|
||||
timeout_status=HumanInputFormStatus.TIMEOUT,
|
||||
reason="delivery_test_timeout",
|
||||
)
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.get(HumanInputForm, "form-a").status == HumanInputFormStatus.TIMEOUT
|
||||
assert sqlite_session.get(HumanInputForm, "form-b").status == HumanInputFormStatus.WAITING
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
|
||||
def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_task_database: None,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
|
||||
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
|
||||
|
||||
old_unexpired_form = _build_form(
|
||||
form_id="form-old",
|
||||
form_kind=HumanInputFormKind.RUNTIME,
|
||||
created_at=now - timedelta(hours=2),
|
||||
expiration_time=now + timedelta(hours=1),
|
||||
workflow_run_id="run-old",
|
||||
node_id="node-old",
|
||||
)
|
||||
sqlite_session.add(old_unexpired_form)
|
||||
sqlite_session.commit()
|
||||
|
||||
repo = HumanInputFormSubmissionRepository()
|
||||
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
|
||||
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
|
||||
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
|
||||
monkeypatch.setattr(task_module, "HumanInputService", MagicMock(return_value=_FakeService()))
|
||||
global_timeout_handler = MagicMock()
|
||||
monkeypatch.setattr(task_module, "_handle_global_timeout", global_timeout_handler)
|
||||
|
||||
task_module.check_and_handle_human_input_timeouts(limit=1)
|
||||
|
||||
mark_timeout_spy.assert_not_called()
|
||||
global_timeout_handler.assert_not_called()
|
||||
sqlite_session.refresh(old_unexpired_form)
|
||||
assert old_unexpired_form.status == HumanInputFormStatus.WAITING
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
|
||||
def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to_agent_app_resume(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_task_database: None,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
# ENG-635 (review): a conversation-owned Agent v2 chat ask_human form has no
|
||||
# workflow_run_id. On timeout it must enqueue the Agent App resume (so the
|
||||
@ -227,24 +264,23 @@ def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
|
||||
monkeypatch.setattr(task_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 3600)
|
||||
monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
forms = [
|
||||
_build_form(
|
||||
form_id="form-chat",
|
||||
form_kind=HumanInputFormKind.RUNTIME,
|
||||
created_at=now - timedelta(minutes=5),
|
||||
expiration_time=now - timedelta(seconds=1),
|
||||
workflow_run_id=None,
|
||||
conversation_id="conv-1",
|
||||
node_id="agent",
|
||||
),
|
||||
]
|
||||
capture: dict[str, Any] = {}
|
||||
monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory(forms, capture))
|
||||
form = _build_form(
|
||||
form_id="form-chat",
|
||||
form_kind=HumanInputFormKind.RUNTIME,
|
||||
created_at=now - timedelta(minutes=5),
|
||||
expiration_time=now - timedelta(seconds=1),
|
||||
workflow_run_id=None,
|
||||
conversation_id="conv-1",
|
||||
node_id="agent",
|
||||
)
|
||||
sqlite_session.add(form)
|
||||
sqlite_session.commit()
|
||||
|
||||
repo = _FakeFormRepo(form_map={form.id: form for form in forms})
|
||||
service = _FakeService(None)
|
||||
repo = HumanInputFormSubmissionRepository()
|
||||
mark_timeout_spy = MagicMock(wraps=repo.mark_timeout)
|
||||
monkeypatch.setattr(repo, "mark_timeout", mark_timeout_spy)
|
||||
service = _FakeService()
|
||||
monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", lambda: repo)
|
||||
monkeypatch.setattr(task_module, "HumanInputService", lambda *_args, **_kwargs: service)
|
||||
monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **_kwargs: None)
|
||||
@ -252,8 +288,10 @@ def test_check_and_handle_human_input_timeouts_routes_conversation_owned_form_to
|
||||
task_module.check_and_handle_human_input_timeouts(limit=100)
|
||||
|
||||
# Node timeout (conversation forms are never "global"), routed to Agent App resume.
|
||||
assert repo.calls == [
|
||||
{"form_id": "form-chat", "timeout_status": HumanInputFormStatus.TIMEOUT, "reason": "node_timeout"}
|
||||
]
|
||||
mark_timeout_spy.assert_called_once_with(
|
||||
form_id="form-chat", timeout_status=HumanInputFormStatus.TIMEOUT, reason="node_timeout"
|
||||
)
|
||||
assert service.agent_app_resumed == [("conv-1", "form-chat")]
|
||||
assert service.enqueued == []
|
||||
sqlite_session.refresh(form)
|
||||
assert form.status == HumanInputFormStatus.TIMEOUT
|
||||
|
||||
Loading…
Reference in New Issue
Block a user