mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(copilot): console JSON routes (create/view/actions/messages)
This commit is contained in:
1 parent
6431d146dc
commit
388edeef80
2 files changed
+348
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""Unit tests for the workflow copilot console JSON routes.
|
||||
|
||||
These test the UNDECORATED module-level route-logic functions
|
||||
(``_create``/``_view``/``_action``/``_message``) and the ``workflow_copilot_required``
|
||||
gate directly -- NOT the decorated ``Resource`` methods. Calling a decorated
|
||||
resource method would trigger the real ``@login_required``/``@setup_required``
|
||||
auth stack, which needs a live Flask request context and a logged-in user;
|
||||
that is exercised by integration/e2e tests, not here.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import controllers.console.workflow_copilot as mod
|
||||
from core.workflow_copilot.errors import BusyError, ConflictError, NotFoundError
|
||||
from core.workflow_copilot.models import Actor
|
||||
from services.workflow_copilot.service import SessionView
|
||||
|
||||
|
||||
def _session_view(session_id: str = "s1") -> SessionView:
|
||||
return SessionView(
|
||||
session_id=session_id,
|
||||
app_id="a1",
|
||||
version=1,
|
||||
state="fix.diagnose",
|
||||
canvas_read_only=True,
|
||||
run_status="executing",
|
||||
interrupted=False,
|
||||
conversation=[],
|
||||
)
|
||||
|
||||
|
||||
def _account(account_id: str = "acc-1") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.id = account_id
|
||||
return m
|
||||
|
||||
|
||||
def _actor() -> Actor:
|
||||
return mod._actor(_account(), "ten-1")
|
||||
|
||||
|
||||
def test_actor_builds_from_current_user_and_tenant():
|
||||
account = _account("acc-1")
|
||||
actor = mod._actor(account, "ten-1")
|
||||
assert actor == Actor(account_id="acc-1", tenant_id="ten-1")
|
||||
|
||||
|
||||
def test_create_returns_201_and_view(monkeypatch):
|
||||
svc = MagicMock()
|
||||
svc.create_fix_session.return_value = _session_view()
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
|
||||
actor = _actor()
|
||||
body, status = mod._create({"app_id": "a1", "failed_run_id": "TR-1", "checklist_errors": []}, actor)
|
||||
|
||||
assert status == 201
|
||||
assert body["session_id"] == "s1"
|
||||
called = svc.create_fix_session.call_args
|
||||
assert called.kwargs["app_id"] == "a1"
|
||||
assert isinstance(called.kwargs["actor"], Actor)
|
||||
assert called.kwargs["actor"] == actor
|
||||
|
||||
|
||||
def test_create_rejects_non_dict_body():
|
||||
actor = _actor()
|
||||
svc = MagicMock()
|
||||
body, status = mod._create(None, actor)
|
||||
assert (body, status) == ({"code": "bad_request"}, 400)
|
||||
svc.create_fix_session.assert_not_called()
|
||||
|
||||
|
||||
def test_action_rejects_non_dict_body(monkeypatch):
|
||||
svc = MagicMock()
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
actor = _actor()
|
||||
|
||||
body, status = mod._action("s1", "notadict", actor)
|
||||
|
||||
assert (body, status) == ({"code": "bad_request"}, 400)
|
||||
svc.submit_action.assert_not_called()
|
||||
|
||||
|
||||
def test_action_conflict_maps_409(monkeypatch):
|
||||
svc = MagicMock()
|
||||
svc.submit_action.side_effect = ConflictError("stale")
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
actor = _actor()
|
||||
|
||||
body, status = mod._action("s1", {"kind": "run_verify", "payload": {}, "base_version": 1}, actor)
|
||||
|
||||
assert status == 409
|
||||
assert body["code"] == "conflict"
|
||||
|
||||
|
||||
def test_action_busy_maps_409_session_busy(monkeypatch):
|
||||
svc = MagicMock()
|
||||
svc.submit_action.side_effect = BusyError("busy")
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
actor = _actor()
|
||||
|
||||
body, status = mod._action("s1", {"kind": "publish", "payload": {}, "base_version": 2}, actor)
|
||||
|
||||
assert status == 409
|
||||
assert body["code"] == "session_busy"
|
||||
|
||||
|
||||
def test_view_notfound_maps_generic_404(monkeypatch):
|
||||
svc = MagicMock()
|
||||
svc.get_session_view.side_effect = NotFoundError("session xyz not found")
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
actor = _actor()
|
||||
|
||||
body, status = mod._view("s1", actor)
|
||||
|
||||
assert status == 404
|
||||
assert body == {"code": "not_found"}
|
||||
# the message text must NOT leak into the response
|
||||
assert "xyz" not in str(body)
|
||||
|
||||
|
||||
def test_message_returns_200_and_view(monkeypatch):
|
||||
svc = MagicMock()
|
||||
svc.submit_message.return_value = _session_view()
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
actor = _actor()
|
||||
|
||||
body, status = mod._message("s1", {"text": "hi", "base_version": 2}, actor)
|
||||
|
||||
assert status == 200
|
||||
assert body["session_id"] == "s1"
|
||||
svc.submit_message.assert_called_once_with("s1", actor, "hi", 2)
|
||||
|
||||
|
||||
def test_message_rejects_non_dict_body(monkeypatch):
|
||||
svc = MagicMock()
|
||||
monkeypatch.setattr(mod, "build_service", lambda: svc)
|
||||
actor = _actor()
|
||||
|
||||
body, status = mod._message("s1", None, actor)
|
||||
|
||||
assert (body, status) == ({"code": "bad_request"}, 400)
|
||||
svc.submit_message.assert_not_called()
|
||||
|
||||
|
||||
# --- workflow_copilot_required gate -----------------------------------------
|
||||
#
|
||||
# NOTE: the brief/dispatch assumed `@with_current_user`/`@with_current_tenant_id`
|
||||
# inject their values as KEYWORD arguments. Reading the real implementation in
|
||||
# controllers/console/wraps.py shows otherwise: both call
|
||||
# `view(self, injected_value, *args, **kwargs)`, i.e. they PREPEND the value as
|
||||
# a POSITIONAL argument immediately after `self`. This is confirmed by the
|
||||
# existing multi-decorator example in controllers/console/app/workflow_comment.py
|
||||
# (`def post(self, current_tenant_id, current_user, app_model, comment_id)`).
|
||||
# `workflow_copilot_required` sits directly below `@with_current_tenant_id`, so
|
||||
# it must accept `current_tenant_id` positionally (as the argument right after
|
||||
# `self`), not as a keyword-only argument. The gate is tested against that real
|
||||
# contract below.
|
||||
|
||||
|
||||
def test_workflow_copilot_required_blocks_when_feature_disabled(monkeypatch):
|
||||
def dummy(_self, _current_tenant_id, *_args, **_kwargs):
|
||||
return {"ok": True}, 200
|
||||
|
||||
wrapped = mod.workflow_copilot_required(dummy)
|
||||
|
||||
features = MagicMock()
|
||||
features.workflow_copilot_enabled = False
|
||||
monkeypatch.setattr(mod.FeatureService, "get_features", lambda _tenant_id: features)
|
||||
|
||||
result = wrapped(object(), "t")
|
||||
|
||||
assert result == ({"code": "feature_unavailable"}, 403)
|
||||
|
||||
|
||||
def test_workflow_copilot_required_passes_through_when_enabled(monkeypatch):
|
||||
def dummy(_self, current_tenant_id, *_args, **_kwargs):
|
||||
return {"ok": True, "current_tenant_id": current_tenant_id}, 200
|
||||
|
||||
wrapped = mod.workflow_copilot_required(dummy)
|
||||
|
||||
features = MagicMock()
|
||||
features.workflow_copilot_enabled = True
|
||||
monkeypatch.setattr(mod.FeatureService, "get_features", lambda _tenant_id: features)
|
||||
|
||||
result = wrapped(object(), "t")
|
||||
|
||||
assert result == ({"ok": True, "current_tenant_id": "t"}, 200)
|
||||
Reference in new issue
Block a user