Compare commits

...
Author SHA1 Message Date
Yunlu WenandGitHub 3aa26fb637 chore: bump versions to 1.15.0 (#37929) 2026-06-25 12:02:38 +00:00
Wu TianweiandGitHub 1d2cc1e475 feat: add hydration support and fallback for Countdown component (#37943) 2026-06-25 10:40:48 +00:00
林玮 (Jade Lin)andGitHub aa37c1d833 fix(api): tongyi credential compatibility (#37942) 2026-06-25 10:25:24 +00:00
yyhandGitHub 48dfbd60d6 fix: polish tool provider selection and detail drawer (#37940) 2026-06-25 10:07:32 +00:00
Mr_xieGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e90c7ab8a7 refactor(tests): replace logger mocks with caplog (#37468) (#37922)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 09:48:43 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
40119fef44 feat: update monitor permission key and plugin permission key (#37937)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 09:41:38 +00:00
72a03c2d6a refactor(test): replace SimpleNamespace with real UploadFile model in test_dataset_models.py (#37935)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-25 09:35:24 +00:00
Wu TianweiandGitHub affdc89f84 feat: enhance app permissions and access controls (#37933) 2026-06-25 09:28:58 +00:00
QuantumGhostandGitHub b33e8f0ddb fix: improve error handling for workflow execution (#37919) 2026-06-25 07:36:34 +00:00
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI
8f74e176ca fix: remove redundant snippets permissions (#37921)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-06-25 07:27:16 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
b9bcf31c72 chore(i18n): sync translations with en-US (#37916)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <[email protected]>
2026-06-25 07:07:23 +00:00
GatewayandGitHub abf2986299 build(deps): update Bleach sanitizer security fix (#37860) 2026-06-25 06:32:48 +00:00
Wu TianweiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
599d92ef6b fix: Fix frontend rbac issues (#37872)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 05:51:39 +00:00
JingyiandGitHub 93dd955deb docs: add Dify Cloud support contact (#37913) 2026-06-25 05:19:31 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
75909ce10e chore(i18n): sync translations with en-US (#37891)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <[email protected]>
2026-06-25 03:24:37 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d93989bfc0 chore: add type to test (#37876)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 03:01:09 +00:00
31a50a3b20 fix: eagerly validate conversation before generator to prevent hanging (#37224)
Co-authored-by: yunlu.wen <[email protected]>
2026-06-25 02:57:33 +00:00
Stephen ZhouandGitHub 3d8316333f fix: keep body background consistent on overscroll (#37909) 2026-06-25 02:20:39 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
9fc2925b00 feat: delete member delete rbac binding (#37904)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 02:08:07 +00:00
JingyiandGitHub d349e892f4 fix: respect legacy plugin permissions without RBAC (#37903) 2026-06-25 01:18:40 +00:00
Willow LopezGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Asuka Minato
2483c091aa refactor: replace logger patches with pytest caplog in tests (#37890)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <[email protected]>
2026-06-24 21:40:06 +00:00
Stephen ZhouandGitHub a421362847 chore(web): prune unused i18n translations (#37888) 2026-06-24 16:13:22 +00:00
4964359961 refactor(tests): use caplog in web login tests (#37889)
Co-authored-by: lin-hongkuan <[email protected]>
2026-06-24 14:44:01 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1b81ac033f chore(knip): add mdx support clean unused code (#37882)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 12:30:11 +00:00
JoelandGitHub 2eb564696e fix: banner has ui problem in small screen (#37879) 2026-06-24 11:19:33 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d87764b0f8 feat: filter dataset operator and add miss permission key (#37867)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 09:56:28 +00:00
JiangandGitHub d135dab241 fix: Lindorm vector store errors caused by the update of opensearch-py (#37862) 2026-06-24 09:54:06 +00:00
726 changed files with 5686 additions and 22268 deletions
+6 -5
View File
@@ -102,11 +102,11 @@ describe('ComponentName', () => {
})
})
// Props tests (REQUIRED)
// Props tests (REQUIRED when props change observable behavior)
describe('Props', () => {
it('should apply custom className', () => {
render(<Component className="custom" />)
expect(screen.getByRole('button')).toHaveClass('custom')
it('should disable the action when disabled', () => {
render(<Component disabled />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
@@ -220,6 +220,7 @@ Every test should clearly separate:
### 2. Black-Box Testing
- Test observable behavior, not implementation details
- Test product contracts, not cosmetic implementation. Do not add or expand unit tests only to lock pure style classes, spacing, colors, backgrounds, or layout micro-adjustments. Cover visual-only fixes with browser/manual verification, screenshots, or E2E/visual checks when risk justifies it. Add unit tests only when the change affects user-observable behavior, accessibility semantics, state, data flow, routing, or a stable component API contract.
- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`)
- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`.
- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment.
@@ -273,7 +274,7 @@ it('should disable input when isReadOnly is true')
### Always Required (All Components)
1. **Rendering**: Component renders without crashing
1. **Props**: Required props, optional props, default values
1. **Props**: Required props, optional props, default values that change observable behavior. Do not test pass-through styling props such as `className` unless they are an explicit, stable component API whose absence would break a real integration contract.
1. **Edge Cases**: null, undefined, empty values, boundary conditions
### Conditional (When Present)
+4
View File
@@ -109,6 +109,10 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production
- name: Web production unused declarations check
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production-unused-check
ts-common-style:
name: TS Common
runs-on: depot-ubuntu-24.04
+1 -1
View File
@@ -116,7 +116,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
## Using Dify
- **Cloud <br/>**
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan.
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:[email protected]?subject=%5BGitHub%5DDify%20Cloud%20Support).
- **Self-hosting Dify Community Edition<br/>**
Quickly get Dify running in your environment with this [starter guide](#quick-start).
+1 -1
View File
@@ -1094,7 +1094,7 @@ class AppTraceApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def post(self, app_model: App):
# add app trace
+1 -1
View File
@@ -70,7 +70,7 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def get(self, app_model: App):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
@@ -181,7 +181,7 @@ class WorkflowAppLogApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_LOG_AND_ANNOTATION)
@get_app_model(mode=[AppMode.WORKFLOW])
def get(self, app_model: App):
"""
@@ -225,7 +225,7 @@ class WorkflowArchivedLogApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_LOG_AND_ANNOTATION)
@get_app_model(mode=[AppMode.WORKFLOW])
def get(self, app_model: App):
"""
@@ -169,7 +169,7 @@ class EndpointCollectionApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -198,7 +198,7 @@ class DeprecatedEndpointCreateApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -290,7 +290,7 @@ class EndpointItemApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -310,7 +310,7 @@ class EndpointItemApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -340,7 +340,7 @@ class DeprecatedEndpointDeleteApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -371,7 +371,7 @@ class DeprecatedEndpointUpdateApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -394,7 +394,7 @@ class EndpointEnableApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -422,7 +422,7 @@ class EndpointDisableApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
+4 -9
View File
@@ -793,7 +793,6 @@ class PluginFetchInstallTasksApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
@@ -811,7 +810,6 @@ class PluginFetchInstallTaskApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str, task_id: str):
@@ -827,7 +825,6 @@ class PluginDeleteInstallTaskApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str, task_id: str):
@@ -843,7 +840,6 @@ class PluginDeleteAllInstallTaskItemsApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -859,7 +855,6 @@ class PluginDeleteInstallTaskItemApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str, task_id: str, identifier: str):
@@ -876,7 +871,7 @@ class PluginUpgradeFromMarketplaceApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -899,7 +894,7 @@ class PluginUpgradeFromGithubApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -927,7 +922,7 @@ class PluginUninstallApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_DELETE, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -995,7 +990,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user
@with_current_tenant_id
+16 -14
View File
@@ -201,21 +201,23 @@ def _legacy_workspace_roles(
This keeps the new `/rbac/roles` endpoint compatible with the original
Dify role model when enterprise RBAC is disabled.
"""
legacy_roles = [
svc.RBACRole(
id=role_name,
tenant_id="",
type=svc.RBACRoleType.WORKSPACE.value,
category="global_system_default",
name=role_name,
description="",
is_builtin=True,
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
role_tag="owner" if role_name == "owner" else "",
legacy_roles = []
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator"):
if not dify_config.DATASET_OPERATOR_ENABLED and role_name == "dataset_operator":
continue
legacy_roles.append(
svc.RBACRole(
id=role_name,
tenant_id="",
type=svc.RBACRoleType.WORKSPACE.value,
category="global_system_default",
name=role_name,
description="",
is_builtin=True,
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
role_tag="owner" if role_name == "owner" else "",
)
)
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
]
if not include_owner:
legacy_roles = [r for r in legacy_roles if r.name != "owner"]
+7
View File
@@ -32,6 +32,7 @@ from libs.helper import uuid_value
from models.model import App, AppMode, EndUser
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.conversation_service import ConversationService
from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@@ -202,6 +203,12 @@ class ChatApi(WebApiResource):
args["auto_generate_name"] = False
try:
# Eagerly validate conversation to avoid hanging on invalid conversation_id
if payload.conversation_id:
ConversationService.get_conversation(
app_model=app_model, conversation_id=payload.conversation_id, user=end_user
)
response = AppGenerateService.generate(
app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming
)
+2 -1
View File
@@ -240,7 +240,8 @@ class HostingConfiguration:
if len(quotas) > 0:
credentials = {
"dashscope_api_key": dify_config.HOSTED_TONGYI_API_KEY,
"use_international_endpoint": dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT,
# SNP-494: keep temporary compatibility with tongyi plugin string credential checks.
"use_international_endpoint": str(dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT).lower(),
}
return HostingProvider(enabled=True, credentials=credentials, quota_unit=quota_unit, quotas=quotas)
+4
View File
@@ -28,6 +28,8 @@ class RBACPermission(StrEnum):
APP_IMPORT_EXPORT_DSL = "app_import_export_dsl"
APP_EDIT = "app_edit"
APP_MONITOR = "app_monitor"
APP_TRACING_CONFIG = "app_tracing_config"
APP_LOG_AND_ANNOTATION = "app_log_and_annotation"
APP_DELETE = "app_delete"
APP_ACCESS_CONFIG = "app_access_config"
@@ -57,7 +59,9 @@ class RBACPermission(StrEnum):
PLUGIN_INSTALL = "plugin_install"
PLUGIN_PREFERENCES = "plugin_preferences"
PLUGIN_MODEL_CONFIG = "plugin_model_config"
PLUGIN_MANAGE = "plugin_manage"
PLUGIN_DELETE = "plugin_delete"
PLUGIN_DEBUG = "plugin_debug"
CREDENTIAL_USE = "credential_use"
@@ -181,30 +181,34 @@ class TestTencentDataTrace:
mock_trace_utils.convert_to_trace_id.return_value = 123
mock_trace_utils.create_link.return_value = "link"
with patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"):
with patch.object(tencent_data_trace, "_process_workflow_nodes") as mock_proc:
with patch.object(tencent_data_trace, "_record_workflow_trace_duration") as mock_dur:
mock_span_builder.build_workflow_spans.return_value = [MagicMock(), MagicMock()]
with (
patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"),
patch.object(tencent_data_trace, "_process_workflow_nodes") as mock_proc,
patch.object(tencent_data_trace, "_record_workflow_trace_duration") as mock_dur,
):
mock_span_builder.build_workflow_spans.return_value = [MagicMock(), MagicMock()]
tencent_data_trace.workflow_trace(trace_info)
tencent_data_trace.workflow_trace(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("run-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_workflow_spans.assert_called_once()
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_proc.assert_called_once_with(trace_info, 123)
mock_dur.assert_called_once_with(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("run-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_workflow_spans.assert_called_once()
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_proc.assert_called_once_with(trace_info, 123)
mock_dur.assert_called_once_with(trace_info)
def test_workflow_trace_exception(self, tencent_data_trace):
def test_workflow_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.workflow_run_id = "run-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
tencent_data_trace.workflow_trace(trace_info)
mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow trace")
tencent_data_trace.workflow_trace(trace_info)
assert "[Tencent APM] Failed to process workflow trace" in caplog.text
def test_message_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=MessageTraceInfo)
@@ -214,29 +218,33 @@ class TestTencentDataTrace:
mock_trace_utils.convert_to_trace_id.return_value = 123
mock_trace_utils.create_link.return_value = "link"
with patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"):
with patch.object(tencent_data_trace, "_record_message_llm_metrics") as mock_metrics:
with patch.object(tencent_data_trace, "_record_message_trace_duration") as mock_dur:
mock_span_builder.build_message_span.return_value = MagicMock()
with (
patch.object(tencent_data_trace, "_get_user_id", return_value="user-1"),
patch.object(tencent_data_trace, "_record_message_llm_metrics") as mock_metrics,
patch.object(tencent_data_trace, "_record_message_trace_duration") as mock_dur,
):
mock_span_builder.build_message_span.return_value = MagicMock()
tencent_data_trace.message_trace(trace_info)
tencent_data_trace.message_trace(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("msg-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_message_span.assert_called_once()
tencent_data_trace.trace_client.add_span.assert_called_once()
mock_metrics.assert_called_once_with(trace_info)
mock_dur.assert_called_once_with(trace_info)
mock_trace_utils.convert_to_trace_id.assert_called_once_with("msg-id")
mock_trace_utils.create_link.assert_called_once_with("parent-trace-id")
mock_span_builder.build_message_span.assert_called_once()
tencent_data_trace.trace_client.add_span.assert_called_once()
mock_metrics.assert_called_once_with(trace_info)
mock_dur.assert_called_once_with(trace_info)
def test_message_trace_exception(self, tencent_data_trace):
def test_message_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_trace_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
tencent_data_trace.message_trace(trace_info)
mock_log.assert_called_once_with("[Tencent APM] Failed to process message trace")
tencent_data_trace.message_trace(trace_info)
assert "[Tencent APM] Failed to process message trace" in caplog.text
def test_tool_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=ToolTraceInfo)
@@ -259,16 +267,18 @@ class TestTencentDataTrace:
tencent_data_trace.tool_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
def test_tool_trace_exception(self, tencent_data_trace):
def test_tool_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=ToolTraceInfo)
trace_info.message_id = "msg-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
tencent_data_trace.tool_trace(trace_info)
mock_log.assert_called_once_with("[Tencent APM] Failed to process tool trace")
tencent_data_trace.tool_trace(trace_info)
assert "[Tencent APM] Failed to process tool trace" in caplog.text
def test_dataset_retrieval_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
@@ -291,29 +301,34 @@ class TestTencentDataTrace:
tencent_data_trace.dataset_retrieval_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
def test_dataset_retrieval_trace_exception(self, tencent_data_trace):
def test_dataset_retrieval_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info.message_id = "msg-id"
with patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
with (
patch(
"dify_trace_tencent.tencent_trace.TencentTraceUtils.convert_to_span_id", side_effect=Exception("error")
),
caplog.at_level(logging.ERROR),
):
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
tencent_data_trace.dataset_retrieval_trace(trace_info)
mock_log.assert_called_once_with("[Tencent APM] Failed to process dataset retrieval trace")
tencent_data_trace.dataset_retrieval_trace(trace_info)
assert "[Tencent APM] Failed to process dataset retrieval trace" in caplog.text
def test_suggested_question_trace(self, tencent_data_trace):
def test_suggested_question_trace(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
with patch("dify_trace_tencent.tencent_trace.logger.info") as mock_log:
with caplog.at_level(logging.INFO):
tencent_data_trace.suggested_question_trace(trace_info)
mock_log.assert_called_once_with("[Tencent APM] Processing suggested question trace")
assert "[Tencent APM] Processing suggested question trace" in caplog.text
def test_suggested_question_trace_exception(self, tencent_data_trace):
def test_suggested_question_trace_exception(
self, tencent_data_trace, monkeypatch, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
with patch("dify_trace_tencent.tencent_trace.logger.info", side_effect=Exception("error")):
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
tencent_data_trace.suggested_question_trace(trace_info)
mock_log.assert_called_once_with("[Tencent APM] Failed to process suggested question trace")
target_logger = logging.getLogger("dify_trace_tencent.tencent_trace")
monkeypatch.setattr(target_logger, "info", MagicMock(side_effect=Exception("error")))
with caplog.at_level(logging.ERROR):
tencent_data_trace.suggested_question_trace(trace_info)
assert "[Tencent APM] Failed to process suggested question trace" in caplog.text
def test_process_workflow_nodes(self, tencent_data_trace, mock_trace_utils):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -327,35 +342,42 @@ class TestTencentDataTrace:
node2.id = "n2"
node2.node_type = BuiltinNodeTypes.TOOL
with patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node1, node2]):
with patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=["span1", "span2"]):
with patch.object(tencent_data_trace, "_record_llm_metrics") as mock_metrics:
tencent_data_trace._process_workflow_nodes(trace_info, 123)
with (
patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node1, node2]),
patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=["span1", "span2"]),
patch.object(tencent_data_trace, "_record_llm_metrics") as mock_metrics,
):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_metrics.assert_called_once_with(node1)
assert tencent_data_trace.trace_client.add_span.call_count == 2
mock_metrics.assert_called_once_with(node1)
def test_process_workflow_nodes_node_exception(self, tencent_data_trace, mock_trace_utils):
def test_process_workflow_nodes_node_exception(
self, tencent_data_trace, mock_trace_utils, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=WorkflowTraceInfo)
mock_trace_utils.convert_to_span_id.return_value = 111
node = MagicMock(spec=WorkflowNodeExecution)
node.id = "n1"
with patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]):
with patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")):
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
tencent_data_trace._process_workflow_nodes(trace_info, 123)
# The exception should be caught by the outer handler since convert_to_span_id is called first
mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes")
with (
patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]),
patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")),
caplog.at_level(logging.ERROR),
):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
def test_process_workflow_nodes_exception(self, tencent_data_trace, mock_trace_utils):
def test_process_workflow_nodes_exception(
self, tencent_data_trace, mock_trace_utils, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=WorkflowTraceInfo)
mock_trace_utils.convert_to_span_id.side_effect = Exception("outer error")
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
with caplog.at_level(logging.ERROR):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
mock_log.assert_called_once_with("[Tencent APM] Failed to process workflow nodes")
assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
def test_build_workflow_node_span(self, tencent_data_trace, mock_span_builder):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -377,16 +399,18 @@ class TestTencentDataTrace:
assert result == "span"
builder_method.assert_called_once_with(123, 456, trace_info, node)
def test_build_workflow_node_span_exception(self, tencent_data_trace, mock_span_builder):
def test_build_workflow_node_span_exception(
self, tencent_data_trace, mock_span_builder, caplog: pytest.LogCaptureFixture
):
node = MagicMock(spec=WorkflowNodeExecution)
node.node_type = BuiltinNodeTypes.LLM
node.id = "n1"
mock_span_builder.build_workflow_llm_span.side_effect = Exception("error")
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
with caplog.at_level(logging.DEBUG):
result = tencent_data_trace._build_workflow_node_span(node, 123, MagicMock(), 456)
assert result is None
mock_log.assert_called_once()
assert result is None
assert len([r for r in caplog.records if r.levelno == logging.DEBUG]) >= 1
def test_get_workflow_node_executions(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -419,16 +443,16 @@ class TestTencentDataTrace:
assert results == mock_executions
account.set_tenant_id.assert_called_once_with("tenant-1")
def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace):
def test_get_workflow_node_executions_no_app_id(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {}
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
with caplog.at_level(logging.ERROR):
results = tencent_data_trace._get_workflow_node_executions(trace_info)
assert results == []
mock_log.assert_called_once()
assert results == []
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace):
def test_get_workflow_node_executions_app_not_found(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.metadata = {"app_id": "app-1"}
@@ -439,23 +463,25 @@ class TestTencentDataTrace:
session = mock_session_ctx.return_value.__enter__.return_value
session.scalar.return_value = None
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
with caplog.at_level(logging.ERROR):
results = tencent_data_trace._get_workflow_node_executions(trace_info)
assert results == []
mock_log.assert_called_once()
assert results == []
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
def test_get_user_id_workflow(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.tenant_id = "tenant-1"
trace_info.metadata = {"user_id": "user-1"}
with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("Database error")):
with patch("dify_trace_tencent.tencent_trace.db") as mock_db:
mock_db.init_app = MagicMock()
mock_db.engine = MagicMock()
with (
patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("Database error")),
patch("dify_trace_tencent.tencent_trace.db") as mock_db,
):
mock_db.init_app = MagicMock()
mock_db.engine = MagicMock()
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "unknown"
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "unknown"
def test_get_user_id_only_user_id(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
@@ -471,16 +497,18 @@ class TestTencentDataTrace:
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "anonymous"
def test_get_user_id_exception(self, tencent_data_trace):
def test_get_user_id_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.tenant_id = "t"
trace_info.metadata = {"user_id": "u"}
with patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("error")):
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "unknown"
mock_log.assert_called_once_with("[Tencent APM] Failed to get user ID")
with (
patch("dify_trace_tencent.tencent_trace.sessionmaker", side_effect=Exception("error")),
caplog.at_level(logging.ERROR),
):
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "unknown"
assert "[Tencent APM] Failed to get user ID" in caplog.text
def test_record_llm_metrics_usage_in_process_data(self, tencent_data_trace):
node = MagicMock(spec=WorkflowNodeExecution)
@@ -514,14 +542,14 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
tencent_data_trace.trace_client.record_token_usage.assert_called_once()
def test_record_llm_metrics_exception(self, tencent_data_trace):
def test_record_llm_metrics_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
node = MagicMock(spec=WorkflowNodeExecution)
node.process_data = None
node.outputs = None
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_llm_metrics(node)
# Should not crash
# Should not crash
def test_record_message_llm_metrics(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
@@ -553,13 +581,13 @@ class TestTencentDataTrace:
tencent_data_trace._record_message_llm_metrics(trace_info)
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
def test_record_message_llm_metrics_exception(self, tencent_data_trace):
def test_record_message_llm_metrics_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info.metadata = None
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_message_llm_metrics(trace_info)
# Should not crash
# Should not crash
def test_record_workflow_trace_duration(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
@@ -605,11 +633,11 @@ class TestTencentDataTrace:
attributes = kwargs["attributes"] if "attributes" in kwargs else args[1] if len(args) > 1 else {}
assert attributes["has_conversation"] == "false"
def test_record_workflow_trace_duration_exception(self, tencent_data_trace):
def test_record_workflow_trace_duration_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.start_time = MagicMock() # This might cause total_seconds() to fail if not mocked right
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_workflow_trace_duration(trace_info)
def test_record_message_trace_duration(self, tencent_data_trace):
@@ -627,11 +655,11 @@ class TestTencentDataTrace:
2.0, {"conversation_mode": "chat", "stream": "true"}
)
def test_record_message_trace_duration_exception(self, tencent_data_trace):
def test_record_message_trace_duration_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info.start_time = None
with patch("dify_trace_tencent.tencent_trace.logger.debug") as mock_log:
with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_message_trace_duration(trace_info)
def test_close(self, tencent_data_trace):
@@ -647,11 +675,11 @@ class TestTencentDataTrace:
client.shutdown.assert_called_once()
def test_close_exception(self, tencent_data_trace):
def test_close_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
tencent_data_trace.trace_client.shutdown.side_effect = Exception("error")
with patch("dify_trace_tencent.tencent_trace.logger.exception") as mock_log:
with caplog.at_level(logging.ERROR):
tencent_data_trace.close()
mock_log.assert_called_once_with("[Tencent APM] Failed to shutdown trace client during cleanup")
assert "[Tencent APM] Failed to shutdown trace client during cleanup" in caplog.text
def test_close_handles_async_shutdown_mock(self, tencent_data_trace):
shutdown = AsyncMock()
@@ -113,7 +113,7 @@ class LindormVectorStore(BaseVector):
)
def _bulk_with_retry(actions):
try:
response = self._client.bulk(actions, timeout=timeout)
response = self._client.bulk(body=actions, timeout=timeout)
if response["errors"]:
error_items = [item for item in response["items"] if "error" in item["index"]]
error_msg = f"Bulk indexing had {len(error_items)} errors"
@@ -231,7 +231,7 @@ class LindormVectorStore(BaseVector):
routing_filter_query = {
"query": {"bool": {"must": [{"term": {f"{ROUTING_FIELD}.keyword": self._routing}}]}}
}
self._client.delete_by_query(self._collection_name, body=routing_filter_query)
self._client.delete_by_query(index=self._collection_name, body=routing_filter_query)
self.refresh()
else:
if self._client.indices.exists(index=self._collection_name):
@@ -127,7 +127,7 @@ def test_create_refresh_and_add_texts_success(lindorm_module, monkeypatch: pytes
vector.add_texts(docs, embeddings, batch_size=2, timeout=9)
assert vector._client.bulk.call_count == 2
actions = vector._client.bulk.call_args_list[0].args[0]
actions = vector._client.bulk.call_args_list[0].kwargs["body"]
assert actions[0]["index"]["routing"] == "route"
assert actions[1][lindorm_module.ROUTING_FIELD] == "route"
vector.refresh()
@@ -268,9 +268,11 @@ class TestWeaviateVector(unittest.TestCase):
wv._client = MagicMock()
wv._client.collections.exists.side_effect = RuntimeError("create failed")
with patch.object(weaviate_vector_module.logger, "exception") as mock_exception:
with pytest.raises(RuntimeError, match="create failed"):
wv._create_collection()
with (
patch.object(weaviate_vector_module.logger, "exception") as mock_exception,
pytest.raises(RuntimeError, match="create failed"),
):
wv._create_collection()
mock_exception.assert_called_once()
@@ -835,9 +837,11 @@ class TestWeaviateVector(unittest.TestCase):
wv._client.collections.use.return_value = mock_col
mock_col.data.delete_by_id.side_effect = FakeUnexpectedStatusCodeError(500)
with patch.object(weaviate_vector_module, "UnexpectedStatusCodeError", FakeUnexpectedStatusCodeError):
with pytest.raises(FakeUnexpectedStatusCodeError, match="status=500"):
wv.delete_by_ids(["bad-id"])
with (
patch.object(weaviate_vector_module, "UnexpectedStatusCodeError", FakeUnexpectedStatusCodeError),
pytest.raises(FakeUnexpectedStatusCodeError, match="status=500"),
):
wv.delete_by_ids(["bad-id"])
def test_json_serializable_converts_datetime(self):
wv = WeaviateVector.__new__(WeaviateVector)
+2 -2
View File
@@ -1,11 +1,11 @@
[project]
name = "dify-api"
version = "1.14.2"
version = "1.15.0"
requires-python = "~=3.12.0"
dependencies = [
# Legacy: mature and widely deployed
"bleach>=6.3.0,<7.0.0",
"bleach>=6.4.0,<7.0.0",
"boto3>=1.43.24,<2.0.0",
"celery>=5.6.3,<6.0.0",
"croniter>=6.2.2,<7.0.0",
+3
View File
@@ -1788,6 +1788,9 @@ class TenantService:
account_id,
)
if dify_config.RBAC_ENABLED:
RBACService.MemberRoles.delete_rbac_bindings(tenant_id=tenant.id, account_id=account_id)
@staticmethod
def update_member_role(
tenant: Tenant, member: Account, new_role: str, operator: Account, *, session: scoped_session | Session
+27 -10
View File
@@ -309,7 +309,8 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"customization.manage",
"plugin.install",
"plugin.plugin_preferences",
"plugin.manage",
"plugin.model_config",
"plugin.delete",
"plugin.debug",
"credential.use",
"credential.create",
@@ -330,8 +331,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"snippets.management",
"tool.manage",
"mcp.manage",
"snippets.create_and_modify",
"snippets.management",
]
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
@@ -342,7 +341,8 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"customization.manage",
"plugin.install",
"plugin.plugin_preferences",
"plugin.manage",
"plugin.model_config",
"plugin.delete",
"plugin.debug",
"credential.use",
"credential.create",
@@ -361,8 +361,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"snippets.management",
"tool.manage",
"mcp.manage",
"snippets.create_and_modify",
"snippets.management",
]
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
@@ -378,7 +376,9 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"dataset.external.connect",
"snippets.create_and_modify",
"tool.manage",
"snippets.create_and_modify",
"billing.view",
"billing.subscription.manage",
"billing.manage",
]
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
@@ -386,6 +386,9 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
"plugin.install",
"credential.use",
"app_library.access",
"billing.view",
"billing.subscription.manage",
"billing.manage",
]
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
@@ -404,6 +407,8 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
"app.acl.release_and_version",
"app.acl.monitor",
"app.acl.access_config",
"app.acl.tracing_config",
"app.acl.log_and_annotation",
]
_LEGACY_APP_ADMIN_KEYS: list[str] = [
@@ -416,6 +421,9 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
"app.acl.release_and_version",
"app.acl.monitor",
"app.acl.access_config",
"app.acl.access_config",
"app.acl.tracing_config",
"app.acl.log_and_annotation",
]
_LEGACY_APP_EDITOR_KEYS: list[str] = [
@@ -431,9 +439,6 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
]
_LEGACY_APP_NORMAL_KEYS: list[str] = [
"app.acl.preview",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.monitor",
]
@@ -834,6 +839,7 @@ class RBACService:
options: ListOption | None = None,
) -> Paginated[RBACRole]:
params = (options or ListOption()).to_params({"include_owner": include_owner})
params["dataset_operator_enabled"] = dify_config.DATASET_OPERATOR_ENABLED
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/roles",
@@ -1678,6 +1684,17 @@ class RBACService:
)
return MemberRolesResponse.model_validate(data or {})
@staticmethod
def delete_rbac_bindings(tenant_id: str, account_id: str):
data = _inner_call(
"DELETE",
f"{_INNER_PREFIX}/members/rbac-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"account_id": account_id},
)
return data
class CheckAccess:
"""Call the ``/inner/api/rbac/check-access`` endpoint."""
+217 -20
View File
@@ -19,11 +19,16 @@ from core.app.entities.app_invoke_entities import (
InvokeFrom,
WorkflowAppGenerateEntity,
)
from core.app.entities.task_entities import WorkflowFinishStreamResponse, WorkflowStartStreamResponse
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, WorkflowResumptionContext
from core.repositories import DifyCoreRepositoryFactory
from extensions.ext_database import db
from graphon.entities import WorkflowStartReason
from graphon.enums import WorkflowExecutionStatus
from graphon.runtime import GraphRuntimeState
from libs.datetime_utils import naive_utc_now
from libs.flask_utils import set_login_user
from libs.helper import to_timestamp
from models.account import Account
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.model import App, AppMode, Conversation, EndUser, Message
@@ -173,14 +178,24 @@ class _AppRunner:
)
except Exception as exc:
if exec_params.streaming:
_publish_error_event(exc, exec_params.workflow_run_id, exec_params.app_mode)
_publish_failed_workflow_terminal_events(
exc=exc,
exec_params=exec_params,
)
raise
if not exec_params.streaming:
return response
assert isinstance(response, Generator)
_publish_streaming_response(response, exec_params.workflow_run_id, exec_params.app_mode)
_publish_streaming_response(
response,
exec_params.workflow_run_id,
exec_params.app_mode,
exec_params.workflow_id,
exec_params.args.get("inputs", {}),
WorkflowStartReason.INITIAL,
)
def _run_app(
self,
@@ -246,29 +261,197 @@ def _resolve_user_for_run(session: Session, workflow_run: WorkflowRun) -> Accoun
return session.get(EndUser, workflow_run.created_by)
def _publish_error_event(exc: Exception, workflow_run_id: str, app_mode: AppMode) -> None:
topic = MessageBasedAppGenerator.get_response_topic(app_mode, workflow_run_id)
payload = json.dumps({"event": "error", "message": str(exc), "status": 500})
topic.publish(payload.encode())
def _publish_failed_workflow_terminal_events(exc: Exception, exec_params: AppExecutionParams) -> None:
"""Publish synthetic workflow lifecycle events for pre-runtime failures.
Early failures can happen before the app generator creates a task entity or
emits any workflow queue events. In that window SSE consumers still need a
normal terminal event to close their state machines, so we synthesize a
minimal `workflow_started -> workflow_finished(failed)` sequence here.
`workflow_run_id` is reused as a synthetic `task_id` because no application
task id exists yet on this failure path.
"""
timestamp = to_timestamp(naive_utc_now())
assert timestamp is not None
topic = MessageBasedAppGenerator.get_response_topic(exec_params.app_mode, exec_params.workflow_run_id)
started_payload = WorkflowStartStreamResponse(
task_id=exec_params.workflow_run_id,
workflow_run_id=exec_params.workflow_run_id,
data=WorkflowStartStreamResponse.Data(
id=exec_params.workflow_run_id,
workflow_id=exec_params.workflow_id,
inputs=exec_params.args.get("inputs", {}),
created_at=timestamp,
reason=WorkflowStartReason.INITIAL,
),
)
topic.publish(json.dumps(started_payload.model_dump(mode="json"), ensure_ascii=False).encode())
finished_payload = WorkflowFinishStreamResponse(
task_id=exec_params.workflow_run_id,
workflow_run_id=exec_params.workflow_run_id,
data=WorkflowFinishStreamResponse.Data(
id=exec_params.workflow_run_id,
workflow_id=exec_params.workflow_id,
status=WorkflowExecutionStatus.FAILED,
outputs=None,
error=str(exc),
elapsed_time=0.0,
total_tokens=0,
total_steps=0,
created_by={},
created_at=timestamp,
finished_at=timestamp,
exceptions_count=1,
files=[],
),
)
topic.publish(json.dumps(finished_payload.model_dump(mode="json"), ensure_ascii=False).encode())
def _get_event_name(event: str | Mapping[str, Any] | BaseModel) -> str | None:
if isinstance(event, BaseModel):
# Temporary compatibility for legacy BaseModel stream events; remove after confirming generators always emit
# str / Mapping responses.
event_name = getattr(event, "event", None)
elif isinstance(event, Mapping):
event_name = event.get("event")
else:
return None
if event_name is None:
return None
return str(event_name)
def _get_task_id(event: str | Mapping[str, Any] | BaseModel) -> str | None:
if isinstance(event, BaseModel):
# Temporary compatibility for legacy BaseModel stream events; remove after confirming generators always emit
# str / Mapping responses.
task_id = getattr(event, "task_id", None)
elif isinstance(event, Mapping):
task_id = event.get("task_id")
else:
return None
return task_id if isinstance(task_id, str) and task_id else None
def _publish_streaming_response(
response_stream: Generator[str | Mapping[str, Any] | BaseModel, None, None],
workflow_run_id: str,
workflow_run_id: str | uuid.UUID,
app_mode: AppMode,
workflow_id: str,
inputs: Mapping[str, Any],
started_reason: WorkflowStartReason,
) -> None:
topic = MessageBasedAppGenerator.get_response_topic(app_mode, workflow_run_id)
for event in response_stream:
try:
if isinstance(event, BaseModel):
payload = json.dumps(event.model_dump(mode="json"), ensure_ascii=False)
else:
payload = json.dumps(event, ensure_ascii=False, default=str)
except (TypeError, ValueError):
logger.exception("error while encoding event")
continue
"""Publish workflow stream events and close broken streams with a failed terminal event.
topic.publish(payload.encode())
`_AppRunner.run()` only handles failures before the generator is returned.
Once we start iterating the runtime stream, this helper becomes the last
place that can guarantee SSE consumers eventually see a terminal workflow
lifecycle event.
"""
normalized_workflow_run_id = str(workflow_run_id)
def _publish_failed_terminal_event(error_message: str, task_id: str, publish_started: bool) -> None:
timestamp = to_timestamp(naive_utc_now())
assert timestamp is not None
if publish_started:
started_payload = WorkflowStartStreamResponse(
task_id=task_id,
workflow_run_id=normalized_workflow_run_id,
data=WorkflowStartStreamResponse.Data(
id=normalized_workflow_run_id,
workflow_id=workflow_id,
inputs=inputs,
created_at=timestamp,
reason=started_reason,
),
)
topic.publish(
json.dumps(
started_payload.model_dump(mode="json", fallback=str),
ensure_ascii=False,
).encode()
)
finished_payload = WorkflowFinishStreamResponse(
task_id=task_id,
workflow_run_id=normalized_workflow_run_id,
data=WorkflowFinishStreamResponse.Data(
id=normalized_workflow_run_id,
workflow_id=workflow_id,
status=WorkflowExecutionStatus.FAILED,
outputs=None,
error=error_message,
elapsed_time=0.0,
total_tokens=0,
total_steps=0,
created_by={},
created_at=timestamp,
finished_at=timestamp,
exceptions_count=1,
files=[],
),
)
topic.publish(json.dumps(finished_payload.model_dump(mode="json"), ensure_ascii=False).encode())
terminal_events = {"workflow_finished", "workflow_paused"}
unexpected_stream_end_message = "Workflow stream ended without a terminal event"
topic = MessageBasedAppGenerator.get_response_topic(app_mode, normalized_workflow_run_id)
started_published = False
terminal_published = False
last_task_id = normalized_workflow_run_id
try:
for event in response_stream:
event_name = _get_event_name(event)
task_id = _get_task_id(event)
if task_id is not None:
last_task_id = task_id
try:
if isinstance(event, BaseModel):
payload = json.dumps(event.model_dump(mode="json"), ensure_ascii=False)
else:
payload = json.dumps(event, ensure_ascii=False, default=str)
except (TypeError, ValueError):
logger.exception("error while encoding event")
continue
topic.publish(payload.encode())
if event_name == "workflow_started":
started_published = True
elif event_name in terminal_events:
terminal_published = True
except Exception as exc:
if not terminal_published:
logger.exception(
"Workflow stream for run %s failed before terminal event; publishing fallback terminal event",
normalized_workflow_run_id,
)
_publish_failed_terminal_event(
error_message=str(exc) or exc.__class__.__name__,
task_id=last_task_id,
publish_started=not started_published,
)
raise
if not terminal_published:
logger.warning(
"Workflow stream for run %s ended without a terminal event; publishing fallback terminal event",
normalized_workflow_run_id,
)
_publish_failed_terminal_event(
error_message=unexpected_stream_end_message,
task_id=last_task_id,
publish_started=not started_published,
)
@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE)
@@ -454,7 +637,14 @@ def _resume_advanced_chat(
raise
assert isinstance(response, Generator)
_publish_streaming_response(response, workflow_run_id, AppMode.ADVANCED_CHAT)
_publish_streaming_response(
response,
workflow_run_id,
AppMode.ADVANCED_CHAT,
workflow.id,
generate_entity.inputs,
WorkflowStartReason.RESUMPTION,
)
def _resume_workflow(
@@ -509,7 +699,14 @@ def _resume_workflow(
raise
assert isinstance(response, Generator)
_publish_streaming_response(response, workflow_run_id, AppMode.WORKFLOW)
_publish_streaming_response(
response,
workflow_run_id,
AppMode.WORKFLOW,
workflow.id,
generate_entity.inputs,
WorkflowStartReason.RESUMPTION,
)
try:
workflow_run_repo.delete_workflow_pause(pause_entity)
@@ -1,5 +1,4 @@
import builtins
from types import SimpleNamespace
from unittest.mock import patch
from flask.views import MethodView as FlaskMethodView
@@ -22,7 +21,7 @@ def test_parameters_model_round_trip():
def test_site_icon_url_uses_signed_url_for_image_icon():
site = SimpleNamespace(
site = Site(
title="Example",
chat_color_theme=None,
chat_color_theme_inverted=False,
@@ -46,7 +45,7 @@ def test_site_icon_url_uses_signed_url_for_image_icon():
def test_site_icon_url_is_none_for_non_image_icon():
site = SimpleNamespace(
site = Site(
title="Example",
chat_color_theme=None,
chat_color_theme_inverted=False,
@@ -2,21 +2,14 @@
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
import pytest
from flask import Flask
from controllers.console.app import workflow as workflow_module
def _unwrap(func):
bound_self = getattr(func, "__self__", None)
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
if bound_self is not None:
return func.__get__(bound_self, bound_self.__class__)
return func
from controllers.console.app.workflow import ConvertToWorkflowApi
class TestConvertToWorkflowApi:
@@ -25,9 +18,9 @@ class TestConvertToWorkflowApi:
return workflow_module.ConvertToWorkflowApi()
def test_convert_to_workflow_attaches_permission_keys_when_rbac_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self, api: ConvertToWorkflowApi, app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
method = _unwrap(api.post)
method = unwrap(api.post)
monkeypatch.setattr(
workflow_module,
@@ -46,6 +39,7 @@ class TestConvertToWorkflowApi:
json={},
):
response = method(
api,
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id="u1"),
app_model=SimpleNamespace(id="app-1"),
@@ -9,6 +9,7 @@ This module tests the core authentication endpoints including:
"""
import base64
import logging
from unittest.mock import ANY, MagicMock, Mock, patch
import pytest
@@ -191,7 +192,9 @@ class TestLoginApi:
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask):
def test_login_fails_when_rate_limited(
self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog: pytest.LogCaptureFixture
):
"""
Test login rejection when rate limit is exceeded.
@@ -204,22 +207,26 @@ class TestLoginApi:
mock_get_invitation.return_value = None
# Act & Assert
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
with app.test_request_context(
"/login", method="POST", json={"email": "[email protected]", "password": encode_password("password")}
):
login_api = LoginApi()
with pytest.raises(EmailPasswordLoginLimitError):
login_api.post()
with app.test_request_context(
"/login", method="POST", json={"email": "[email protected]", "password": encode_password("password")}
):
login_api = LoginApi()
with pytest.raises(EmailPasswordLoginLimitError):
login_api.post()
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.LOGIN_RATE_LIMITED
warn_records = [
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
]
assert len(warn_records) == 1
assert warn_records[0].args[0] == "[email protected]"
assert warn_records[0].args[1] == LoginFailureReason.LOGIN_RATE_LIMITED
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", True)
@patch("controllers.console.auth.login.BillingService.is_email_in_freeze")
def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app: Flask):
def test_login_fails_when_account_frozen(
self, mock_is_frozen, mock_db, app: Flask, caplog: pytest.LogCaptureFixture
):
"""
Test login rejection for frozen accounts.
@@ -231,17 +238,19 @@ class TestLoginApi:
mock_is_frozen.return_value = True
# Act & Assert
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
with app.test_request_context(
"/login", method="POST", json={"email": "[email protected]", "password": encode_password("password")}
):
login_api = LoginApi()
with pytest.raises(AccountInFreezeError):
login_api.post()
with app.test_request_context(
"/login", method="POST", json={"email": "[email protected]", "password": encode_password("password")}
):
login_api = LoginApi()
with pytest.raises(AccountInFreezeError):
login_api.post()
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_IN_FREEZE
warn_records = [
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
]
assert len(warn_records) == 1
assert warn_records[0].args[0] == "[email protected]"
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_IN_FREEZE
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@@ -257,6 +266,7 @@ class TestLoginApi:
mock_is_rate_limit,
mock_db,
app: Flask,
caplog: pytest.LogCaptureFixture,
):
"""
Test login failure with invalid credentials.
@@ -272,20 +282,22 @@ class TestLoginApi:
mock_authenticate.side_effect = AccountPasswordError("Invalid password")
# Act & Assert
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
with app.test_request_context(
"/login",
method="POST",
json={"email": "[email protected]", "password": encode_password("WrongPass123!")},
):
login_api = LoginApi()
with pytest.raises(AuthenticationFailedError):
login_api.post()
with app.test_request_context(
"/login",
method="POST",
json={"email": "[email protected]", "password": encode_password("WrongPass123!")},
):
login_api = LoginApi()
with pytest.raises(AuthenticationFailedError):
login_api.post()
mock_add_rate_limit.assert_called_once_with("[email protected]")
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS
warn_records = [
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
]
assert len(warn_records) == 1
assert warn_records[0].args[0] == "[email protected]"
assert warn_records[0].args[1] == LoginFailureReason.INVALID_CREDENTIALS
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@@ -293,7 +305,7 @@ class TestLoginApi:
@patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.authenticate")
def test_login_fails_for_banned_account(
self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask
self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask, caplog
):
"""
Test login rejection for banned accounts.
@@ -308,19 +320,21 @@ class TestLoginApi:
mock_authenticate.side_effect = AccountLoginError("Account is banned")
# Act & Assert
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
with app.test_request_context(
"/login",
method="POST",
json={"email": "[email protected]", "password": encode_password("ValidPass123!")},
):
login_api = LoginApi()
with pytest.raises(AccountBannedError):
login_api.post()
with app.test_request_context(
"/login",
method="POST",
json={"email": "[email protected]", "password": encode_password("ValidPass123!")},
):
login_api = LoginApi()
with pytest.raises(AccountBannedError):
login_api.post()
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
warn_records = [
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
]
assert len(warn_records) == 1
assert warn_records[0].args[0] == "[email protected]"
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False)
@@ -452,23 +466,26 @@ class TestLoginApi:
mock_get_token_data: MagicMock,
mock_db: MagicMock,
app: Flask,
caplog: pytest.LogCaptureFixture,
):
mock_get_token_data.return_value = {"email": "[email protected]", "code": "123456"}
mock_get_account.side_effect = Unauthorized("Account is banned.")
with patch("controllers.console.auth.login.logger.warning") as mock_log_warning:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "[email protected]", "code": encode_code("123456"), "token": "token-123"},
):
with pytest.raises(AccountBannedError):
EmailCodeLoginApi().post()
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "[email protected]", "code": encode_code("123456"), "token": "token-123"},
):
with pytest.raises(AccountBannedError):
EmailCodeLoginApi().post()
mock_revoke_token.assert_called_once_with("token-123")
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
warn_records = [
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
]
assert len(warn_records) == 1
assert warn_records[0].args[0] == "[email protected]"
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED
class TestLogoutApi:
@@ -1,3 +1,4 @@
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import Mock
@@ -10,12 +11,6 @@ from models.account import Account, AccountStatus
from services.workflow_draft_variable_service import WorkflowDraftVariableList
def _unwrap(func):
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
return func
def _make_account() -> Account:
account = Account(
name="tester",
@@ -66,7 +61,7 @@ def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable(
def test_conversation_variables_returns_empty_list(app: Flask):
api = module.SnippetConversationVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
@@ -76,7 +71,7 @@ def test_conversation_variables_returns_empty_list(app: Flask):
def test_system_variables_returns_empty_list(app: Flask):
api = module.SnippetSystemVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
@@ -91,7 +86,7 @@ def test_delete_variable_collection_deletes_current_user_variables(app: Flask, m
db_session.return_value = SimpleNamespace()
monkeypatch.setattr(module.db, "session", db_session)
api = module.SnippetWorkflowVariableCollectionApi()
handler = _unwrap(api.delete)
handler = unwrap(api.delete)
with app.test_request_context("/", method="DELETE"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
@@ -109,7 +104,7 @@ def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask,
)
api = module.SnippetWorkflowVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/?page=1&limit=20"):
with pytest.raises(module.DraftWorkflowNotExist):
@@ -140,7 +135,7 @@ def test_node_variable_collection_get_lists_node_variables(app: Flask, monkeypat
)
api = module.SnippetNodeVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1")
@@ -158,7 +153,7 @@ def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monk
monkeypatch.setattr(module.db, "session", db_session)
api = module.SnippetNodeVariableCollectionApi()
handler = _unwrap(api.delete)
handler = unwrap(api.delete)
with app.test_request_context("/", method="DELETE"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1")
@@ -177,7 +172,7 @@ def test_variable_patch_returns_variable_when_no_changes(app: Flask, monkeypatch
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
api = module.SnippetVariableApi()
handler = _unwrap(api.patch)
handler = unwrap(api.patch)
with app.test_request_context("/", method="PATCH", json={}):
result = handler(
@@ -202,7 +197,7 @@ def test_variable_delete_deletes_variable(app: Flask, monkeypatch: pytest.Monkey
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
api = module.SnippetVariableApi()
handler = _unwrap(api.delete)
handler = unwrap(api.delete)
with app.test_request_context("/", method="DELETE"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1")
@@ -230,7 +225,7 @@ def test_variable_reset_returns_no_content_when_reset_result_is_none(app: Flask,
)
api = module.SnippetVariableResetApi()
handler = _unwrap(api.put)
handler = unwrap(api.put)
with app.test_request_context("/", method="PUT"):
response = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1")
@@ -260,7 +255,7 @@ def test_environment_variables_returns_workflow_environment_variables(app: Flask
)
api = module.SnippetEnvironmentVariableCollectionApi()
handler = _unwrap(api.get)
handler = unwrap(api.get)
with app.test_request_context("/"):
result = handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1"))
@@ -201,10 +201,10 @@ class TestPaginationMapping:
},
]
assert response["pagination"] == {
"total_count": 5,
"total_count": 4,
"per_page": 2,
"current_page": 1,
"total_pages": 3,
"total_pages": 2,
}
mock_list.assert_not_called()
@@ -1,4 +1,5 @@
import inspect
import logging
from io import BytesIO
from unittest.mock import MagicMock, patch
@@ -151,7 +152,9 @@ class TestTenantListApi:
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_called_once_with("t2", exclude_vector_space=True)
def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(self, app: Flask):
def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(
self, app: Flask, caplog: pytest.LogCaptureFixture
):
"""Test fallback to FeatureService when bulk billing returns empty result.
BillingService.get_plan_bulk catches exceptions internally and returns empty dict,
@@ -170,6 +173,7 @@ class TestTenantListApi:
with (
app.test_request_context("/workspaces"),
caplog.at_level(logging.WARNING, logger="controllers.console.workspace.workspace"),
patch(
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
@@ -185,7 +189,6 @@ class TestTenantListApi:
"controllers.console.workspace.workspace.FeatureService.get_features",
return_value=features,
) as get_features_mock,
patch("controllers.console.workspace.workspace.logger.warning") as logger_warning_mock,
):
result, status = method(api, "t2", user)
@@ -194,7 +197,7 @@ class TestTenantListApi:
assert result["workspaces"][1]["plan"] == CloudPlan.TEAM
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
assert get_features_mock.call_count == 2
logger_warning_mock.assert_called_once()
assert "get_plan_bulk returned empty result, falling back to legacy feature path" in caplog.messages
def test_get_billing_disabled_community_path(self, app: Flask):
api = TenantListApi()
@@ -365,7 +368,7 @@ class TestTenantApi:
with pytest.raises(Unauthorized):
method(api, user)
def test_post_info_path(self, app: Flask):
def test_post_info_path(self, app: Flask, caplog: pytest.LogCaptureFixture):
api = TenantApi()
method = inspect.unwrap(api.post)
@@ -374,15 +377,15 @@ class TestTenantApi:
with (
app.test_request_context("/info"),
caplog.at_level(logging.WARNING, logger="controllers.console.workspace.workspace"),
patch(
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info",
return_value={"id": "t1"},
),
patch("controllers.console.workspace.workspace.logger.warning") as warn_mock,
):
result, status = method(api, user)
warn_mock.assert_called_once()
assert "Deprecated URL /info was used." in caplog.messages
assert status == 200
@@ -1,4 +1,5 @@
import base64
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -16,6 +17,13 @@ def encode_code(code: str) -> str:
return base64.b64encode(code.encode("utf-8")).decode()
def assert_login_failure_logged(caplog: pytest.LogCaptureFixture, email: str, reason: LoginFailureReason) -> None:
records = [record for record in caplog.records if record.name == "controllers.web.login"]
assert len(records) == 1
assert records[0].args[0] == email
assert records[0].args[1] == reason
@pytest.fixture
def app():
flask_app = Flask(__name__)
@@ -114,10 +122,10 @@ class TestLoginApi:
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountLoginError(),
)
def test_login_banned_account(self, mock_auth: MagicMock, app: Flask) -> None:
def test_login_banned_account(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
from controllers.console.error import AccountBannedError
with patch("controllers.web.login.logger.warning") as mock_log_warning:
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/login",
method="POST",
@@ -126,18 +134,16 @@ class TestLoginApi:
with pytest.raises(AccountBannedError):
LoginApi().post()
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
assert_login_failure_logged(caplog, "[email protected]", LoginFailureReason.ACCOUNT_BANNED)
@patch(
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountPasswordError(),
)
def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask) -> None:
def test_login_wrong_password(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
from controllers.console.auth.error import AuthenticationFailedError
with patch("controllers.web.login.logger.warning") as mock_log_warning:
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/login",
method="POST",
@@ -146,18 +152,16 @@ class TestLoginApi:
with pytest.raises(AuthenticationFailedError):
LoginApi().post()
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_CREDENTIALS
assert_login_failure_logged(caplog, "[email protected]", LoginFailureReason.INVALID_CREDENTIALS)
@patch(
"controllers.web.login.WebAppAuthService.authenticate",
side_effect=services.errors.account.AccountNotFoundError(),
)
def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask) -> None:
def test_login_account_not_found(self, mock_auth: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture) -> None:
from controllers.console.auth.error import AuthenticationFailedError
with patch("controllers.web.login.logger.warning") as mock_log_warning:
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/login",
method="POST",
@@ -166,13 +170,13 @@ class TestLoginApi:
with pytest.raises(AuthenticationFailedError):
LoginApi().post()
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_NOT_FOUND
assert_login_failure_logged(caplog, "[email protected]", LoginFailureReason.ACCOUNT_NOT_FOUND)
@patch("controllers.web.login.WebAppAuthService.get_email_code_login_data", return_value=None)
def test_email_code_login_logs_invalid_token(self, mock_get_token_data: MagicMock, app: Flask) -> None:
with patch("controllers.web.login.logger.warning") as mock_log_warning:
def test_email_code_login_logs_invalid_token(
self, mock_get_token_data: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/email-code-login/validity",
method="POST",
@@ -182,9 +186,7 @@ class TestLoginApi:
EmailCodeLoginApi().post()
mock_get_token_data.assert_called_once_with("token-123")
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.INVALID_EMAIL_CODE_TOKEN
assert_login_failure_logged(caplog, "[email protected]", LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
@patch("controllers.web.login.WebAppAuthService.revoke_email_code_login_token")
@patch(
@@ -201,10 +203,11 @@ class TestLoginApi:
mock_get_user: MagicMock,
mock_revoke_token: MagicMock,
app: Flask,
caplog: pytest.LogCaptureFixture,
) -> None:
from controllers.console.error import AccountBannedError
with patch("controllers.web.login.logger.warning") as mock_log_warning:
with caplog.at_level(logging.WARNING, logger="controllers.web.login"):
with app.test_request_context(
"/web/email-code-login/validity",
method="POST",
@@ -215,9 +218,7 @@ class TestLoginApi:
mock_get_token_data.assert_called_once_with("token-123")
mock_revoke_token.assert_called_once_with("token-123")
assert mock_log_warning.call_count == 1
assert mock_log_warning.call_args.args[1] == "[email protected]"
assert mock_log_warning.call_args.args[2] == LoginFailureReason.ACCOUNT_BANNED
assert_login_failure_logged(caplog, "[email protected]", LoginFailureReason.ACCOUNT_BANNED)
class TestLoginStatusApi:
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -961,7 +962,9 @@ class TestAdvancedChatAppGeneratorInternals:
stream=False,
)
def test_handle_response_re_raises_value_error(self, monkeypatch: pytest.MonkeyPatch):
def test_handle_response_re_raises_value_error(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
generator = AdvancedChatAppGenerator()
generator._dialogue_count = 1
app_config = self._build_app_config()
@@ -986,29 +989,28 @@ class TestAdvancedChatAppGeneratorInternals:
def process(self):
raise ValueError("other error")
logger_exception = MagicMock()
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.logger.exception", logger_exception)
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppGenerateTaskPipeline", _Pipeline)
with pytest.raises(ValueError, match="other error"):
generator._handle_advanced_chat_response(
application_generate_entity=application_generate_entity,
workflow=WorkflowSnapshot(id="wf", tenant_id="tenant", features_dict={}),
queue_manager=SimpleNamespace(),
conversation=ConversationSnapshot(id="conv", mode=AppMode.ADVANCED_CHAT),
message=MessageSnapshot(
id="msg",
query="hello",
created_at=naive_utc_now(),
status=MessageStatus.NORMAL,
answer="",
),
user=SimpleNamespace(),
draft_var_saver_factory=lambda **kwargs: None,
stream=False,
)
with caplog.at_level(logging.ERROR, logger="core.app.apps.advanced_chat.app_generator"):
with pytest.raises(ValueError, match="other error"):
generator._handle_advanced_chat_response(
application_generate_entity=application_generate_entity,
workflow=WorkflowSnapshot(id="wf", tenant_id="tenant", features_dict={}),
queue_manager=SimpleNamespace(),
conversation=ConversationSnapshot(id="conv", mode=AppMode.ADVANCED_CHAT),
message=MessageSnapshot(
id="msg",
query="hello",
created_at=naive_utc_now(),
status=MessageStatus.NORMAL,
answer="",
),
user=SimpleNamespace(),
draft_var_saver_factory=lambda **kwargs: None,
stream=False,
)
logger_exception.assert_called_once()
assert "Failed to process generate task pipeline, conversation_id: conv" in caplog.messages
def test_generate_worker_handles_invoke_auth_error(self, monkeypatch: pytest.MonkeyPatch):
generator = AdvancedChatAppGenerator()
@@ -1,4 +1,5 @@
import contextlib
import logging
import pytest
from pydantic import ValidationError
@@ -274,7 +275,9 @@ class TestAgentChatAppGeneratorWorker:
assert queue_manager.publish_error.called
def test_generate_worker_logs_value_error_when_debug(self, generator, mocker: MockerFixture):
def test_generate_worker_logs_value_error_when_debug(
self, generator, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
):
queue_manager = mocker.MagicMock()
generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock())
generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock())
@@ -285,15 +288,15 @@ class TestAgentChatAppGeneratorWorker:
mocker.patch("core.app.apps.agent_chat.app_generator.db.session.close")
mocker.patch("core.app.apps.agent_chat.app_generator.dify_config", new=mocker.MagicMock(DEBUG=True))
logger = mocker.patch("core.app.apps.agent_chat.app_generator.logger")
generator._generate_worker(
flask_app=mocker.MagicMock(),
context=mocker.MagicMock(),
application_generate_entity=mocker.MagicMock(),
queue_manager=queue_manager,
conversation_id="conv",
message_id="msg",
)
with caplog.at_level(logging.ERROR, logger="core.app.apps.agent_chat.app_generator"):
generator._generate_worker(
flask_app=mocker.MagicMock(),
context=mocker.MagicMock(),
application_generate_entity=mocker.MagicMock(),
queue_manager=queue_manager,
conversation_id="conv",
message_id="msg",
)
logger.exception.assert_called_once()
assert "Error when generating" in caplog.messages
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -263,11 +264,11 @@ class TestAppRunner:
files=[],
)
def test_handle_invoke_result_stream_routes_chunks_and_builds_message(self, monkeypatch: pytest.MonkeyPatch):
def test_handle_invoke_result_stream_routes_chunks_and_builds_message(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
runner = AppRunner()
queue = _QueueRecorder()
warning_logger = MagicMock()
monkeypatch.setattr("core.app.apps.base_app_runner._logger.warning", warning_logger)
image_content = ImagePromptMessageContent(
url="https://example.com/image.png", format="png", mime_type="image/png"
@@ -290,23 +291,24 @@ class TestAppRunner:
),
)
runner._handle_invoke_result(
invoke_result=_stream(),
queue_manager=queue,
stream=True,
agent=False,
)
with caplog.at_level(logging.WARNING, logger="core.app.apps.base_app_runner"):
runner._handle_invoke_result(
invoke_result=_stream(),
queue_manager=queue,
stream=True,
agent=False,
)
assert isinstance(queue.events[0], QueueLLMChunkEvent)
assert isinstance(queue.events[-1], QueueMessageEndEvent)
assert queue.events[-1].llm_result.message.content == "abc"
warning_logger.assert_called_once()
assert "Received multimodal output but missing required parameters" in caplog.messages
def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors(self, monkeypatch: pytest.MonkeyPatch):
def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
runner = AppRunner()
queue = _QueueRecorder()
exception_logger = MagicMock()
monkeypatch.setattr("core.app.apps.base_app_runner._logger.exception", exception_logger)
monkeypatch.setattr(
runner,
@@ -335,19 +337,20 @@ class TestAppRunner:
),
)
runner._handle_invoke_result_stream(
invoke_result=_stream(),
queue_manager=queue,
agent=True,
message_id="message-id",
user_id="user-id",
tenant_id="tenant-id",
)
with caplog.at_level(logging.ERROR, logger="core.app.apps.base_app_runner"):
runner._handle_invoke_result_stream(
invoke_result=_stream(),
queue_manager=queue,
agent=True,
message_id="message-id",
user_id="user-id",
tenant_id="tenant-id",
)
assert isinstance(queue.events[0], QueueAgentMessageEvent)
assert isinstance(queue.events[-1], QueueMessageEndEvent)
assert queue.events[-1].llm_result.usage == usage
exception_logger.assert_called_once()
assert "Failed to handle multimodal image output" in caplog.messages
def test_handle_invoke_result_stream_closes_generator_when_stopped(self):
runner = AppRunner()
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -639,7 +640,9 @@ class TestWorkflowGenerateTaskPipeline:
assert sleep_spy
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
def test_wrapper_process_stream_response_handles_audio_exception(self, monkeypatch: pytest.MonkeyPatch):
def test_wrapper_process_stream_response_handles_audio_exception(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
pipeline = _make_pipeline()
pipeline._workflow_features_dict = {
"text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"}
@@ -659,20 +662,16 @@ class TestWorkflowGenerateTaskPipeline:
def publish(self, message):
_ = message
logger_exception = []
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.time.time", lambda: 0.0)
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.logger.exception",
lambda *args, **kwargs: logger_exception.append((args, kwargs)),
)
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher",
_Publisher,
)
responses = list(pipeline._wrapper_process_stream_response())
with caplog.at_level(logging.ERROR, logger="core.app.apps.workflow.generate_task_pipeline"):
responses = list(pipeline._wrapper_process_stream_response())
assert logger_exception
assert "Fails to get audio trunk, task_id: task" in caplog.messages
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
@@ -2042,7 +2042,9 @@ def test_get_custom_provider_models_skips_schema_models_with_mismatched_type() -
assert all(model.model != "embed-model" for model in models)
def test_get_custom_provider_models_skips_custom_models_on_schema_error_or_none() -> None:
def test_get_custom_provider_models_skips_custom_models_on_schema_error_or_none(
caplog: pytest.LogCaptureFixture,
) -> None:
configuration = _build_provider_configuration()
configuration.custom_configuration.models = [
CustomModelConfiguration(model="error-custom", model_type=ModelType.LLM, credentials={"k": "v"}),
@@ -2064,7 +2066,7 @@ def test_get_custom_provider_models_skips_custom_models_on_schema_error_or_none(
return None
return _build_ai_model(model)
with patch("core.entities.provider_configuration.logger.warning") as mock_warning:
with caplog.at_level(logging.WARNING, logger="core.entities.provider_configuration"):
with patch.object(ProviderConfiguration, "get_model_schema", side_effect=_schema):
models = configuration._get_custom_provider_models(
model_types=[ModelType.LLM],
@@ -2072,6 +2074,6 @@ def test_get_custom_provider_models_skips_custom_models_on_schema_error_or_none(
model_setting_map={},
)
assert mock_warning.call_count == 1
assert "get custom model schema failed, boom" in caplog.messages
assert any(model.model == "ok-custom" for model in models)
assert all(model.model != "none-custom" for model in models)
@@ -21,6 +21,7 @@ from core.ops.entities.trace_entity import (
WorkflowNodeTraceInfo,
WorkflowTraceInfo,
)
from enterprise.telemetry.enterprise_trace import EnterpriseOtelTrace
from enterprise.telemetry.entities import (
EnterpriseTelemetryCounter,
EnterpriseTelemetryEvent,
@@ -297,43 +298,43 @@ def test_init_succeeds_with_valid_exporter(mock_exporter):
class TestSafePayloadValue:
def test_string_passthrough(self, trace_handler):
def test_string_passthrough(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value("hello") == "hello"
def test_dict_passthrough(self, trace_handler):
def test_dict_passthrough(self, trace_handler: EnterpriseOtelTrace):
d = {"key": "val"}
assert trace_handler._safe_payload_value(d) == d
def test_list_passthrough(self, trace_handler):
def test_list_passthrough(self, trace_handler: EnterpriseOtelTrace):
lst = [1, 2, 3]
assert trace_handler._safe_payload_value(lst) == lst
def test_none_returns_none(self, trace_handler):
def test_none_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value(None) is None
def test_int_returns_none(self, trace_handler):
def test_int_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value(42) is None
def test_bool_returns_none(self, trace_handler):
def test_bool_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._safe_payload_value(True) is None
class TestMaybeJson:
def test_none_returns_none(self, trace_handler):
def test_none_returns_none(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._maybe_json(None) is None
def test_string_passthrough(self, trace_handler):
def test_string_passthrough(self, trace_handler: EnterpriseOtelTrace):
assert trace_handler._maybe_json("hello") == "hello"
def test_dict_serialised(self, trace_handler):
def test_dict_serialised(self, trace_handler: EnterpriseOtelTrace):
result = trace_handler._maybe_json({"a": 1})
assert result == json.dumps({"a": 1})
def test_list_serialised(self, trace_handler):
def test_list_serialised(self, trace_handler: EnterpriseOtelTrace):
result = trace_handler._maybe_json([1, 2])
assert result == "[1, 2]"
def test_non_serialisable_falls_back_to_str(self, trace_handler):
def test_non_serialisable_falls_back_to_str(self, trace_handler: EnterpriseOtelTrace):
class Unserializable:
def __repr__(self):
return "Unserializable()"
@@ -344,22 +345,22 @@ class TestMaybeJson:
class TestContentOrRef:
def test_returns_content_when_include_content_true(self, trace_handler, mock_exporter):
def test_returns_content_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
result = trace_handler._content_or_ref("actual content", "ref:x=1")
assert result == "actual content"
def test_returns_ref_when_include_content_false(self, trace_handler, mock_exporter):
def test_returns_ref_when_include_content_false(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
result = trace_handler._content_or_ref("actual content", "ref:x=1")
assert result == "ref:x=1"
def test_dict_serialised_when_include_content_true(self, trace_handler, mock_exporter):
def test_dict_serialised_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
result = trace_handler._content_or_ref({"key": "val"}, "ref:x=1")
assert result == json.dumps({"key": "val"})
def test_none_returns_none_when_include_content_true(self, trace_handler, mock_exporter):
def test_none_returns_none_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
result = trace_handler._content_or_ref(None, "ref:x=1")
assert result is None
@@ -371,67 +372,67 @@ class TestContentOrRef:
class TestTraceDispatcher:
def test_dispatches_workflow_trace(self, trace_handler):
def test_dispatches_workflow_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_workflow_trace") as mock_method:
info = make_workflow_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_message_trace(self, trace_handler):
def test_dispatches_message_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_message_trace") as mock_method:
info = make_message_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_tool_trace(self, trace_handler):
def test_dispatches_tool_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_tool_trace") as mock_method:
info = make_tool_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_draft_node_execution_trace(self, trace_handler):
def test_dispatches_draft_node_execution_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_draft_node_execution_trace") as mock_method:
info = make_draft_node_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_node_execution_trace(self, trace_handler):
def test_dispatches_node_execution_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_node_execution_trace") as mock_method:
info = make_node_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_moderation_trace(self, trace_handler):
def test_dispatches_moderation_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_moderation_trace") as mock_method:
info = make_moderation_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_suggested_question_trace(self, trace_handler):
def test_dispatches_suggested_question_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_suggested_question_trace") as mock_method:
info = make_suggested_question_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_dataset_retrieval_trace(self, trace_handler):
def test_dispatches_dataset_retrieval_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_dataset_retrieval_trace") as mock_method:
info = make_dataset_retrieval_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_generate_name_trace(self, trace_handler):
def test_dispatches_generate_name_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_generate_name_trace") as mock_method:
info = make_generate_name_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_dispatches_prompt_generation_trace(self, trace_handler):
def test_dispatches_prompt_generation_trace(self, trace_handler: EnterpriseOtelTrace):
with patch.object(trace_handler, "_prompt_generation_trace") as mock_method:
info = make_prompt_generation_info()
trace_handler.trace(info)
mock_method.assert_called_once_with(info)
def test_draft_node_dispatched_before_node(self, trace_handler):
def test_draft_node_dispatched_before_node(self, trace_handler: EnterpriseOtelTrace):
"""DraftNodeExecutionTrace is a subclass of WorkflowNodeTraceInfo;
it must be dispatched to _draft_node_execution_trace, not _node_execution_trace."""
with (
@@ -450,7 +451,7 @@ class TestTraceDispatcher:
class TestWorkflowTrace:
def test_emits_correct_span_attributes(self, trace_handler, mock_exporter):
def test_emits_correct_span_attributes(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
info = make_workflow_info()
trace_handler._workflow_trace(info)
@@ -465,7 +466,7 @@ class TestWorkflowTrace:
assert attrs["dify.workflow.status"] == "succeeded"
assert attrs["gen_ai.usage.total_tokens"] == 100
def test_span_timing_passed_correctly(self, trace_handler, mock_exporter):
def test_span_timing_passed_correctly(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info()
trace_handler._workflow_trace(info)
@@ -474,7 +475,7 @@ class TestWorkflowTrace:
assert span_call[1]["start_time"] == _T0
assert span_call[1]["end_time"] == _T1
def test_emits_companion_log_with_event_name(self, trace_handler, mock_exporter):
def test_emits_companion_log_with_event_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._workflow_trace(make_workflow_info())
@@ -482,7 +483,7 @@ class TestWorkflowTrace:
assert mock_log.call_args[1]["event_name"] == EnterpriseTelemetryEvent.WORKFLOW_RUN
assert mock_log.call_args[1]["tenant_id"] == "tenant-abc"
def test_companion_log_includes_content_when_enabled(self, trace_handler, mock_exporter):
def test_companion_log_includes_content_when_enabled(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._workflow_trace(make_workflow_info())
@@ -491,7 +492,7 @@ class TestWorkflowTrace:
assert log_attrs["dify.workflow.inputs"] == json.dumps({"query": "hello"})
assert log_attrs["dify.workflow.outputs"] == json.dumps({"answer": "world"})
def test_companion_log_uses_ref_when_content_disabled(self, trace_handler, mock_exporter):
def test_companion_log_uses_ref_when_content_disabled(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._workflow_trace(make_workflow_info())
@@ -500,7 +501,7 @@ class TestWorkflowTrace:
assert log_attrs["dify.workflow.inputs"].startswith("ref:workflow_run_id=")
assert log_attrs["dify.workflow.outputs"].startswith("ref:workflow_run_id=")
def test_increments_token_counter(self, trace_handler, mock_exporter):
def test_increments_token_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@@ -510,7 +511,7 @@ class TestWorkflowTrace:
assert len(token_calls) == 1
assert token_calls[0][0][1] == 100
def test_increments_input_and_output_token_counters(self, trace_handler, mock_exporter):
def test_increments_input_and_output_token_counters(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@@ -519,7 +520,7 @@ class TestWorkflowTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_no_input_token_counter_when_prompt_tokens_zero(self, trace_handler, mock_exporter):
def test_no_input_token_counter_when_prompt_tokens_zero(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(prompt_tokens=0)
trace_handler._workflow_trace(info)
@@ -528,7 +529,7 @@ class TestWorkflowTrace:
counter_names = [c[0][0] for c in all_calls]
assert EnterpriseTelemetryCounter.INPUT_TOKENS not in counter_names
def test_records_workflow_duration_histogram(self, trace_handler, mock_exporter):
def test_records_workflow_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@@ -537,7 +538,9 @@ class TestWorkflowTrace:
assert hist_call[0][0] == EnterpriseTelemetryHistogram.WORKFLOW_DURATION
assert hist_call[0][1] == pytest.approx(5.0)
def test_duration_falls_back_to_elapsed_time_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_duration_falls_back_to_elapsed_time_when_timestamps_missing(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(start_time=None, end_time=None, workflow_run_elapsed_time=7.3)
trace_handler._workflow_trace(info)
@@ -545,7 +548,7 @@ class TestWorkflowTrace:
hist_call = mock_exporter.record_histogram.call_args
assert hist_call[0][1] == pytest.approx(7.3)
def test_duration_defaults_to_zero_when_no_timing(self, trace_handler, mock_exporter):
def test_duration_defaults_to_zero_when_no_timing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(start_time=None, end_time=None, workflow_run_elapsed_time=0)
trace_handler._workflow_trace(info)
@@ -553,7 +556,7 @@ class TestWorkflowTrace:
hist_call = mock_exporter.record_histogram.call_args
assert hist_call[0][1] == pytest.approx(0.0)
def test_error_path_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_path_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(error="Something went wrong", workflow_run_status="failed")
trace_handler._workflow_trace(info)
@@ -563,7 +566,7 @@ class TestWorkflowTrace:
]
assert len(error_calls) == 1
def test_no_error_counter_on_success(self, trace_handler, mock_exporter):
def test_no_error_counter_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._workflow_trace(make_workflow_info())
@@ -572,7 +575,7 @@ class TestWorkflowTrace:
]
assert len(error_calls) == 0
def test_parent_trace_context_injected_into_span_attrs(self, trace_handler, mock_exporter):
def test_parent_trace_context_injected_into_span_attrs(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_workflow_info(
metadata={
@@ -601,14 +604,14 @@ class TestWorkflowTrace:
class TestNodeExecutionTrace:
def test_emits_span_with_node_execution_span_name(self, trace_handler, mock_exporter):
def test_emits_span_with_node_execution_span_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
span_call = mock_exporter.export_span.call_args
assert span_call[0][0] == EnterpriseTelemetrySpan.NODE_EXECUTION
def test_span_contains_core_node_attributes(self, trace_handler, mock_exporter):
def test_span_contains_core_node_attributes(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
@@ -620,7 +623,7 @@ class TestNodeExecutionTrace:
assert attrs["gen_ai.request.model"] == "gpt-4"
assert attrs["gen_ai.provider.name"] == "openai"
def test_increments_token_counters_when_tokens_present(self, trace_handler, mock_exporter):
def test_increments_token_counters_when_tokens_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
@@ -629,7 +632,7 @@ class TestNodeExecutionTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_no_token_counters_when_total_tokens_zero(self, trace_handler, mock_exporter):
def test_no_token_counters_when_total_tokens_zero(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info(total_tokens=0))
@@ -637,7 +640,7 @@ class TestNodeExecutionTrace:
assert EnterpriseTelemetryCounter.TOKENS not in counter_names
assert EnterpriseTelemetryCounter.INPUT_TOKENS not in counter_names
def test_records_node_duration_histogram(self, trace_handler, mock_exporter):
def test_records_node_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info())
@@ -645,7 +648,7 @@ class TestNodeExecutionTrace:
assert hist_call[0][0] == EnterpriseTelemetryHistogram.NODE_DURATION
assert hist_call[0][1] == pytest.approx(2.5)
def test_error_path_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_path_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._node_execution_trace(make_node_info(error="Node failed", status="failed"))
@@ -654,14 +657,16 @@ class TestNodeExecutionTrace:
]
assert len(error_calls) == 1
def test_emits_companion_log_with_span_name_as_event(self, trace_handler, mock_exporter):
def test_emits_companion_log_with_span_name_as_event(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._node_execution_trace(make_node_info())
mock_log.assert_called_once()
assert mock_log.call_args[1]["event_name"] == EnterpriseTelemetrySpan.NODE_EXECUTION.value
def test_plugin_name_added_to_duration_labels_for_tool_node(self, trace_handler, mock_exporter):
def test_plugin_name_added_to_duration_labels_for_tool_node(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_node_info(
node_type="tool",
@@ -677,7 +682,7 @@ class TestNodeExecutionTrace:
duration_labels = hist_call[0][2]
assert duration_labels.get("plugin_name") == "my-plugin"
def test_plugin_name_not_added_for_non_tool_node(self, trace_handler, mock_exporter):
def test_plugin_name_not_added_for_non_tool_node(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_node_info(
node_type="llm",
@@ -693,7 +698,9 @@ class TestNodeExecutionTrace:
duration_labels = hist_call[0][2]
assert "plugin_name" not in duration_labels
def test_companion_log_inputs_use_ref_when_content_disabled(self, trace_handler, mock_exporter):
def test_companion_log_inputs_use_ref_when_content_disabled(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._node_execution_trace(
@@ -711,14 +718,14 @@ class TestNodeExecutionTrace:
class TestDraftNodeExecutionTrace:
def test_uses_draft_span_name(self, trace_handler, mock_exporter):
def test_uses_draft_span_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
trace_handler._draft_node_execution_trace(make_draft_node_info())
span_call = mock_exporter.export_span.call_args
assert span_call[0][0] == EnterpriseTelemetrySpan.DRAFT_NODE_EXECUTION
def test_correlation_id_is_node_execution_id(self, trace_handler, mock_exporter):
def test_correlation_id_is_node_execution_id(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_draft_node_info()
trace_handler._draft_node_execution_trace(info)
@@ -726,7 +733,7 @@ class TestDraftNodeExecutionTrace:
span_call = mock_exporter.export_span.call_args
assert span_call[1]["correlation_id"] == "ne-draft-001"
def test_trace_correlation_override_is_workflow_run_id(self, trace_handler, mock_exporter):
def test_trace_correlation_override_is_workflow_run_id(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log"):
info = make_draft_node_info()
trace_handler._draft_node_execution_trace(info)
@@ -734,7 +741,7 @@ class TestDraftNodeExecutionTrace:
span_call = mock_exporter.export_span.call_args
assert span_call[1]["trace_correlation_override"] == "run-draft-001"
def test_companion_log_uses_draft_span_name(self, trace_handler, mock_exporter):
def test_companion_log_uses_draft_span_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
trace_handler._draft_node_execution_trace(make_draft_node_info())
@@ -747,34 +754,36 @@ class TestDraftNodeExecutionTrace:
class TestMessageTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
mock_emit.assert_called_once()
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.MESSAGE_RUN
def test_emits_correct_tenant_and_user(self, trace_handler, mock_exporter):
def test_emits_correct_tenant_and_user(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
assert mock_emit.call_args[1]["tenant_id"] == "tenant-abc"
def test_duration_computed_from_timestamps(self, trace_handler, mock_exporter):
def test_duration_computed_from_timestamps(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.message.duration"] == pytest.approx(5.0)
def test_no_duration_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_no_duration_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info(start_time=None, end_time=None))
attrs = mock_emit.call_args[1]["attributes"]
assert "dify.message.duration" not in attrs
def test_records_duration_histogram_when_timestamps_present(self, trace_handler, mock_exporter):
def test_records_duration_histogram_when_timestamps_present(
self, trace_handler: EnterpriseOtelTrace, mock_exporter
):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info())
@@ -786,14 +795,14 @@ class TestMessageTrace:
assert len(hist_calls) == 1
assert hist_calls[0][0][1] == pytest.approx(5.0)
def test_no_duration_histogram_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_no_duration_histogram_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(start_time=None, end_time=None))
hist_names = [c[0][0] for c in mock_exporter.record_histogram.call_args_list]
assert EnterpriseTelemetryHistogram.MESSAGE_DURATION not in hist_names
def test_records_ttft_histogram_when_present(self, trace_handler, mock_exporter):
def test_records_ttft_histogram_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(gen_ai_server_time_to_first_token=0.42))
@@ -805,14 +814,14 @@ class TestMessageTrace:
assert len(ttft_calls) == 1
assert ttft_calls[0][0][1] == pytest.approx(0.42)
def test_no_ttft_histogram_when_not_present(self, trace_handler, mock_exporter):
def test_no_ttft_histogram_when_not_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(gen_ai_server_time_to_first_token=None))
hist_names = [c[0][0] for c in mock_exporter.record_histogram.call_args_list]
assert EnterpriseTelemetryHistogram.MESSAGE_TTFT not in hist_names
def test_increments_token_counters(self, trace_handler, mock_exporter):
def test_increments_token_counters(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info())
@@ -821,7 +830,7 @@ class TestMessageTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_error_path_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_path_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._message_trace(make_message_info(error="LLM failed"))
@@ -830,7 +839,7 @@ class TestMessageTrace:
]
assert len(error_calls) == 1
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler, mock_exporter):
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._message_trace(make_message_info())
@@ -846,27 +855,27 @@ class TestMessageTrace:
class TestToolTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.TOOL_EXECUTION
def test_status_is_succeeded_on_success(self, trace_handler, mock_exporter):
def test_status_is_succeeded_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.tool.status"] == "succeeded"
def test_status_is_failed_on_error(self, trace_handler, mock_exporter):
def test_status_is_failed_on_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info(error="Tool error"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.tool.status"] == "failed"
def test_records_tool_duration_histogram(self, trace_handler, mock_exporter):
def test_records_tool_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._tool_trace(make_tool_info())
@@ -874,7 +883,7 @@ class TestToolTrace:
assert hist_call[0][0] == EnterpriseTelemetryHistogram.TOOL_DURATION
assert hist_call[0][1] == pytest.approx(1.5)
def test_error_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._tool_trace(make_tool_info(error="Tool crashed"))
@@ -883,7 +892,7 @@ class TestToolTrace:
]
assert len(error_calls) == 1
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler, mock_exporter):
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
@@ -892,7 +901,7 @@ class TestToolTrace:
assert attrs["dify.tool.inputs"].startswith("ref:message_id=")
assert attrs["dify.tool.outputs"].startswith("ref:message_id=")
def test_inputs_present_when_include_content_true(self, trace_handler, mock_exporter):
def test_inputs_present_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._tool_trace(make_tool_info())
@@ -901,7 +910,7 @@ class TestToolTrace:
assert attrs["dify.tool.inputs"] == json.dumps({"query": "test"})
assert attrs["dify.tool.outputs"] == "search results"
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._tool_trace(make_tool_info())
@@ -918,27 +927,27 @@ class TestToolTrace:
class TestModerationTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.MODERATION_CHECK
def test_flagged_true_sets_attribute(self, trace_handler, mock_exporter):
def test_flagged_true_sets_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info(flagged=True))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.flagged"] is True
def test_flagged_false_sets_attribute(self, trace_handler, mock_exporter):
def test_flagged_false_sets_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info(flagged=False))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.flagged"] is False
def test_query_gated_by_include_content(self, trace_handler, mock_exporter):
def test_query_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info())
@@ -946,7 +955,7 @@ class TestModerationTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.query"].startswith("ref:message_id=")
def test_query_present_when_include_content_true(self, trace_handler, mock_exporter):
def test_query_present_when_include_content_true(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._moderation_trace(make_moderation_info())
@@ -954,7 +963,7 @@ class TestModerationTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.moderation.query"] == "is this ok?"
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._moderation_trace(make_moderation_info())
@@ -971,48 +980,48 @@ class TestModerationTrace:
class TestSuggestedQuestionTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.SUGGESTED_QUESTION_GENERATION
def test_duration_computed_from_timestamps(self, trace_handler, mock_exporter):
def test_duration_computed_from_timestamps(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.duration"] == pytest.approx(5.0)
def test_duration_is_none_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_duration_is_none_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info(start_time=None, end_time=None))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.duration"] is None
def test_status_is_failed_when_error_present(self, trace_handler, mock_exporter):
def test_status_is_failed_when_error_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info(error="Generation failed"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.status"] == "failed"
def test_status_falls_back_to_succeeded_when_no_error(self, trace_handler, mock_exporter):
def test_status_falls_back_to_succeeded_when_no_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info(status=None, error=None))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.status"] == "succeeded"
def test_question_count_attribute(self, trace_handler, mock_exporter):
def test_question_count_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.count"] == 2
def test_questions_gated_by_include_content(self, trace_handler, mock_exporter):
def test_questions_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._suggested_question_trace(make_suggested_question_info())
@@ -1020,7 +1029,7 @@ class TestSuggestedQuestionTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.suggested_question.questions"].startswith("ref:message_id=")
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._suggested_question_trace(make_suggested_question_info())
@@ -1037,48 +1046,48 @@ class TestSuggestedQuestionTrace:
class TestDatasetRetrievalTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.DATASET_RETRIEVAL
def test_document_count_attribute(self, trace_handler, mock_exporter):
def test_document_count_attribute(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.document_count"] == 1
def test_dataset_ids_extracted(self, trace_handler, mock_exporter):
def test_dataset_ids_extracted(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
attrs = mock_emit.call_args[1]["attributes"]
assert "ds-001" in attrs["dify.dataset.id"]
def test_empty_documents_has_zero_count(self, trace_handler, mock_exporter):
def test_empty_documents_has_zero_count(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info(documents=[]))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.document_count"] == 0
def test_status_succeeded_when_no_error(self, trace_handler, mock_exporter):
def test_status_succeeded_when_no_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.status"] == "succeeded"
def test_status_failed_when_error_present(self, trace_handler, mock_exporter):
def test_status_failed_when_error_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info(error="DB error"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.retrieval.status"] == "failed"
def test_embedding_model_attributes_set_when_present(self, trace_handler, mock_exporter):
def test_embedding_model_attributes_set_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
@@ -1086,7 +1095,7 @@ class TestDatasetRetrievalTrace:
assert "dify.dataset.embedding_providers" in attrs
assert "dify.dataset.embedding_models" in attrs
def test_no_embedding_model_attributes_when_not_provided(self, trace_handler, mock_exporter):
def test_no_embedding_model_attributes_when_not_provided(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(
make_dataset_retrieval_info(metadata={"app_id": "app-001", "tenant_id": "tenant-abc"})
@@ -1096,7 +1105,7 @@ class TestDatasetRetrievalTrace:
assert "dify.dataset.embedding_providers" not in attrs
assert "dify.dataset.embedding_models" not in attrs
def test_rerank_attributes_set_when_present(self, trace_handler, mock_exporter):
def test_rerank_attributes_set_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(
make_dataset_retrieval_info(
@@ -1113,7 +1122,7 @@ class TestDatasetRetrievalTrace:
assert attrs["dify.retrieval.rerank_provider"] == "cohere"
assert attrs["dify.retrieval.rerank_model"] == "rerank-english"
def test_no_rerank_attributes_when_not_present(self, trace_handler, mock_exporter):
def test_no_rerank_attributes_when_not_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(
make_dataset_retrieval_info(metadata={"app_id": "app-001", "tenant_id": "tenant-abc"})
@@ -1123,7 +1132,7 @@ class TestDatasetRetrievalTrace:
assert "dify.retrieval.rerank_provider" not in attrs
assert "dify.retrieval.rerank_model" not in attrs
def test_dataset_retrieval_counter_incremented_per_dataset(self, trace_handler, mock_exporter):
def test_dataset_retrieval_counter_incremented_per_dataset(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
@@ -1135,7 +1144,7 @@ class TestDatasetRetrievalTrace:
assert len(ds_calls) == 1
assert ds_calls[0][0][2]["dataset_id"] == "ds-001"
def test_no_dataset_retrieval_counter_when_no_documents(self, trace_handler, mock_exporter):
def test_no_dataset_retrieval_counter_when_no_documents(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info(documents=[]))
@@ -1146,7 +1155,7 @@ class TestDatasetRetrievalTrace:
]
assert len(ds_calls) == 0
def test_query_gated_by_include_content(self, trace_handler, mock_exporter):
def test_query_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._dataset_retrieval_trace(make_dataset_retrieval_info())
@@ -1161,34 +1170,34 @@ class TestDatasetRetrievalTrace:
class TestGenerateNameTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.GENERATE_NAME_EXECUTION
def test_duration_computed_from_timestamps(self, trace_handler, mock_exporter):
def test_duration_computed_from_timestamps(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.duration"] == pytest.approx(5.0)
def test_no_duration_when_timestamps_missing(self, trace_handler, mock_exporter):
def test_no_duration_when_timestamps_missing(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info(start_time=None, end_time=None))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.duration"] is None
def test_status_succeeded_on_success(self, trace_handler, mock_exporter):
def test_status_succeeded_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.status"] == "succeeded"
def test_status_failed_when_metadata_has_error(self, trace_handler, mock_exporter):
def test_status_failed_when_metadata_has_error(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(
make_generate_name_info(
@@ -1203,7 +1212,7 @@ class TestGenerateNameTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.generate_name.status"] == "failed"
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler, mock_exporter):
def test_inputs_and_outputs_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._generate_name_trace(make_generate_name_info())
@@ -1212,7 +1221,7 @@ class TestGenerateNameTrace:
assert attrs["dify.generate_name.inputs"].startswith("ref:conversation_id=")
assert attrs["dify.generate_name.outputs"].startswith("ref:conversation_id=")
def test_increments_requests_counter(self, trace_handler, mock_exporter):
def test_increments_requests_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._generate_name_trace(make_generate_name_info())
@@ -1229,27 +1238,27 @@ class TestGenerateNameTrace:
class TestPromptGenerationTrace:
def test_emits_event_with_correct_name(self, trace_handler, mock_exporter):
def test_emits_event_with_correct_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())
assert mock_emit.call_args[1]["event_name"] == EnterpriseTelemetryEvent.PROMPT_GENERATION_EXECUTION
def test_status_succeeded_on_success(self, trace_handler, mock_exporter):
def test_status_succeeded_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.prompt_generation.status"] == "succeeded"
def test_status_failed_when_error_present(self, trace_handler, mock_exporter):
def test_status_failed_when_error_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info(error="Generation error"))
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.prompt_generation.status"] == "failed"
def test_token_counters_incremented(self, trace_handler, mock_exporter):
def test_token_counters_incremented(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@@ -1258,7 +1267,7 @@ class TestPromptGenerationTrace:
assert EnterpriseTelemetryCounter.INPUT_TOKENS in counter_names
assert EnterpriseTelemetryCounter.OUTPUT_TOKENS in counter_names
def test_records_duration_histogram(self, trace_handler, mock_exporter):
def test_records_duration_histogram(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@@ -1270,7 +1279,7 @@ class TestPromptGenerationTrace:
assert len(hist_calls) == 1
assert hist_calls[0][0][1] == pytest.approx(3.2)
def test_total_price_attribute_set_when_present(self, trace_handler, mock_exporter):
def test_total_price_attribute_set_when_present(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info(total_price=0.05, currency="USD"))
@@ -1278,14 +1287,14 @@ class TestPromptGenerationTrace:
assert attrs["dify.prompt_generation.total_price"] == pytest.approx(0.05)
assert attrs["dify.prompt_generation.currency"] == "USD"
def test_no_total_price_attribute_when_none(self, trace_handler, mock_exporter):
def test_no_total_price_attribute_when_none(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info(total_price=None))
attrs = mock_emit.call_args[1]["attributes"]
assert "dify.prompt_generation.total_price" not in attrs
def test_error_increments_error_counter(self, trace_handler, mock_exporter):
def test_error_increments_error_counter(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info(error="Prompt failed"))
@@ -1294,7 +1303,7 @@ class TestPromptGenerationTrace:
]
assert len(error_calls) == 1
def test_no_error_counter_on_success(self, trace_handler, mock_exporter):
def test_no_error_counter_on_success(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@@ -1303,7 +1312,7 @@ class TestPromptGenerationTrace:
]
assert len(error_calls) == 0
def test_instruction_gated_by_include_content(self, trace_handler, mock_exporter):
def test_instruction_gated_by_include_content(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = False
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@@ -1311,7 +1320,7 @@ class TestPromptGenerationTrace:
attrs = mock_emit.call_args[1]["attributes"]
assert attrs["dify.prompt_generation.instruction"].startswith("ref:trace_id=")
def test_operation_type_label_used_in_token_counters(self, trace_handler, mock_exporter):
def test_operation_type_label_used_in_token_counters(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event"):
trace_handler._prompt_generation_trace(make_prompt_generation_info(operation_type="code_generate"))
@@ -1321,7 +1330,7 @@ class TestPromptGenerationTrace:
assert len(token_calls) == 1
assert token_calls[0][0][2]["operation_type"] == "code_generate"
def test_emits_correct_tenant_id(self, trace_handler, mock_exporter):
def test_emits_correct_tenant_id(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_metric_only_event") as mock_emit:
trace_handler._prompt_generation_trace(make_prompt_generation_info())
@@ -12,7 +12,6 @@ This test suite covers:
import json
import pickle
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock, patch
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
@@ -20,6 +19,7 @@ from uuid import uuid4
import pytest
from core.rag.index_processor.constant.index_type import IndexTechniqueType
from extensions.storage.storage_type import StorageType
from models.dataset import (
AppDatasetJoin,
ChildChunk,
@@ -32,12 +32,14 @@ from models.dataset import (
ExternalKnowledgeBindings,
)
from models.enums import (
CreatorUserRole,
DataSourceType,
DocumentCreatedFrom,
IndexingStatus,
ProcessRuleMode,
SegmentStatus,
)
from models.model import UploadFile
class TestDatasetModelValidation:
@@ -719,13 +721,20 @@ class TestDocumentSegmentIndexing:
created_by="user-1",
)
segment.id = "segment-1"
attachment = SimpleNamespace(
id="upload-1",
attachment = UploadFile(
tenant_id="tenant-1",
storage_type=StorageType.LOCAL,
key="upload-1-key",
name="image.png",
size=128,
extension="png",
mime_type="image/png",
created_by_role=CreatorUserRole.ACCOUNT,
created_by="user-1",
created_at=datetime(2023, 11, 14, tzinfo=UTC),
used=False,
)
attachment.id = "upload-1"
monkeypatch.setattr("models.dataset.time.time", lambda: 1700000000)
monkeypatch.setattr("models.dataset.os.urandom", lambda _: b"\x01" * 16)
@@ -86,21 +86,26 @@ class TestRoles:
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/roles"
assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"}
assert call.params == {
"dataset_operator_enabled": False,
"page_number": 2,
"results_per_page": 50,
"reverse": "true",
}
assert out.pagination
assert out.pagination.total_count == 1
def test_list_omits_params_when_default(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.Roles.list("tenant-1")
assert _call_args(mock_send).params is None
assert _call_args(mock_send).params is not None
def test_list_forwards_include_owner(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.Roles.list("tenant-1", include_owner=1)
assert _call_args(mock_send).params == {"include_owner": 1}
assert _call_args(mock_send).params == {"dataset_operator_enabled": False, "include_owner": 1}
def test_list_coerces_null_permission_keys(self, mock_send: MagicMock):
mock_send.return_value = {
@@ -616,6 +621,7 @@ class TestMyPermissions:
mock_send.assert_not_called()
assert out.workspace.permission_keys == workspace_keys
assert len(out.workspace.permission_keys) == len(set(out.workspace.permission_keys))
assert out.app.default_permission_keys == app_keys
assert out.dataset.default_permission_keys == dataset_keys
assert out.app.overrides == []
@@ -1,6 +1,9 @@
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from models.account import TenantPluginAutoUpgradeStrategy
MODULE = "services.plugin.plugin_auto_upgrade_service"
@@ -227,7 +230,7 @@ class TestBackfillStrategyCategories:
assert default_time % (15 * 60) == 0
assert 0 <= default_time < 24 * 60 * 60
def test_creates_missing_categories_and_splits_known_plugins(self):
def test_creates_missing_categories_and_splits_known_plugins(self, caplog: pytest.LogCaptureFixture):
p1, session = _patched_session()
tool_strategy = SimpleNamespace(
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
@@ -260,7 +263,11 @@ class TestBackfillStrategyCategories:
installer = MagicMock()
installer.list_plugins.return_value = installed_plugins
with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer), patch(f"{MODULE}.logger") as logger:
with (
p1,
patch(f"{MODULE}.PluginInstaller", return_value=installer),
caplog.at_level(logging.WARNING, logger=MODULE),
):
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
result = PluginAutoUpgradeService.backfill_strategy_categories("t1")
@@ -272,10 +279,7 @@ class TestBackfillStrategyCategories:
assert tool_strategy.include_plugins == ["tool-plugin"]
assert model_strategy.exclude_plugins == ["model-plugin"]
assert model_strategy.include_plugins == ["model-plugin"]
logger.warning.assert_called_once_with(
assert (
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
"tenant_id=%s, field=%s, plugin_ids=%s",
"t1",
"exclude_plugins",
["unknown-plugin"],
"tenant_id=t1, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages
)
@@ -1,4 +1,5 @@
import datetime
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
@@ -347,7 +348,9 @@ def test_serialize_record_falls_back_to_table_columns() -> None:
}
def test_process_with_tenant_ids_filters_by_plan_and_logs_errors(monkeypatch: pytest.MonkeyPatch) -> None:
def test_process_with_tenant_ids_filters_by_plan_and_logs_errors(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object()))
# Total tenant count query
@@ -381,14 +384,13 @@ def test_process_with_tenant_ids_filters_by_plan_and_logs_errors(monkeypatch: py
process_tenant_mock = MagicMock(side_effect=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("err")))
monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant_mock)
logger_exc = MagicMock()
monkeypatch.setattr(service_module.logger, "exception", logger_exc)
ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=["t_sandbox", "t_paid", "t_fail"])
with caplog.at_level(logging.ERROR, logger=service_module.logger.name):
ClearFreePlanTenantExpiredLogs.process(days=7, batch=10, tenant_ids=["t_sandbox", "t_paid", "t_fail"])
# Only sandbox tenant should attempt processing, and its failure should be swallowed + logged.
assert process_tenant_mock.call_count == 1
assert logger_exc.call_count >= 1
assert "Failed to process tenant t_sandbox" in caplog.messages
assert "Failed to process tenant t_fail" in caplog.messages
def test_process_without_tenant_ids_batches_and_scales_interval(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -9,6 +9,7 @@ This module tests the document indexing task functionality including:
- Task cancellation and cleanup
"""
import logging
import uuid
from contextlib import nullcontext
from types import SimpleNamespace
@@ -758,7 +759,15 @@ class TestErrorHandling:
assert mock_db_session.close.called
def test_tenant_queue_error_handling_still_processes_next_task(
self, tenant_id, dataset_id, document_ids, mock_redis, mock_db_session, mock_dataset, mock_indexing_runner
self,
tenant_id,
dataset_id,
document_ids,
mock_redis,
mock_db_session,
mock_dataset,
mock_indexing_runner,
caplog: pytest.LogCaptureFixture,
):
"""
Test that errors don't prevent processing next task in tenant queue.
@@ -778,14 +787,17 @@ class TestErrorHandling:
with patch("tasks.document_indexing_task._document_indexing") as mock_indexing:
mock_indexing.side_effect = Exception("Processing failed")
# Patch logger to avoid format string issue in actual code
with patch("tasks.document_indexing_task.logger"):
with caplog.at_level(logging.ERROR, logger="tasks.document_indexing_task"):
with patch("tasks.document_indexing_task.normal_document_indexing_task") as mock_task:
# Act
_document_indexing_with_tenant_queue(tenant_id, dataset_id, document_ids, mock_task)
# Assert - Next task should still be enqueued despite error
mock_task.apply_async.assert_called()
assert (
f"Error processing document indexing {dataset_id} for tenant {tenant_id}: {document_ids}"
in caplog.messages
)
def test_concurrent_task_limit_respected(
self, tenant_id, dataset_id, document_ids, mock_redis, mock_db_session, mock_dataset
@@ -1,18 +1,26 @@
from __future__ import annotations
import json
import logging
import uuid
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from pydantic import BaseModel
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
from graphon.entities import WorkflowStartReason
from graphon.enums import WorkflowExecutionStatus
from models.enums import CreatorUserRole
from models.model import App, AppMode, Conversation
from models.workflow import Workflow, WorkflowRun
from repositories.sqlalchemy_api_workflow_run_repository import _WorkflowRunError
from tasks.app_generate import workflow_execute_task as workflow_execute_task_module
from tasks.app_generate.workflow_execute_task import (
AppExecutionParams,
_AppRunner,
_publish_streaming_response,
_resume_advanced_chat,
_resume_app_execution,
@@ -31,6 +39,11 @@ class _FakeSessionContext:
return False
class _StreamEventModel(BaseModel):
event: object | None = None
task_id: object | None = None
def _build_advanced_chat_generate_entity(conversation_id: str | None) -> AdvancedChatAppGenerateEntity:
return AdvancedChatAppGenerateEntity(
task_id="task-id",
@@ -60,6 +73,46 @@ def _single_event_generator(payload):
yield payload
def _decode_published_payload(payload: bytes) -> dict[str, object] | str:
return json.loads(payload.decode())
def _published_payloads(topic: MagicMock) -> list[dict[str, object] | str]:
return [_decode_published_payload(call.args[0]) for call in topic.publish.call_args_list]
@pytest.mark.parametrize(
("event", "expected"),
[
({"event": "workflow_started"}, "workflow_started"),
({"event": 123}, "123"),
(_StreamEventModel(event="workflow_started"), "workflow_started"),
(_StreamEventModel(event=123), "123"),
({}, None),
(_StreamEventModel(), None),
("workflow_started", None),
],
)
def test_get_event_name(event: object, expected: str | None):
assert workflow_execute_task_module._get_event_name(event) == expected
@pytest.mark.parametrize(
("event", "expected"),
[
({"task_id": "task-id"}, "task-id"),
(_StreamEventModel(task_id="task-id"), "task-id"),
({"task_id": 123}, None),
(_StreamEventModel(task_id=123), None),
({"task_id": ""}, None),
(_StreamEventModel(), None),
("task-id", None),
],
)
def test_get_task_id(event: object, expected: str | None):
assert workflow_execute_task_module._get_task_id(event) == expected
@pytest.fixture
def mock_topic(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
topic = MagicMock()
@@ -72,21 +125,413 @@ def mock_topic(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
def test_publish_streaming_response_with_uuid(mock_topic: MagicMock):
workflow_run_id = uuid.uuid4()
response_stream = iter([{"event": "foo"}, "ping"])
response_stream = iter(
[
{"event": "workflow_started", "task_id": "task-id"},
{"event": "workflow_finished", "task_id": "task-id", "data": {"status": "succeeded"}},
]
)
_publish_streaming_response(response_stream, workflow_run_id, app_mode=AppMode.ADVANCED_CHAT)
_publish_streaming_response(
response_stream,
workflow_run_id,
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={},
started_reason=WorkflowStartReason.INITIAL,
)
payloads = [call.args[0] for call in mock_topic.publish.call_args_list]
assert payloads == [json.dumps({"event": "foo"}).encode(), json.dumps("ping").encode()]
payloads = _published_payloads(mock_topic)
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
def test_publish_streaming_response_coerces_string_uuid(mock_topic: MagicMock):
workflow_run_id = uuid.uuid4()
response_stream = iter([{"event": "bar"}])
response_stream = iter([{"event": "workflow_paused", "task_id": "task-id"}])
_publish_streaming_response(response_stream, str(workflow_run_id), app_mode=AppMode.ADVANCED_CHAT)
_publish_streaming_response(
response_stream,
str(workflow_run_id),
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={},
started_reason=WorkflowStartReason.INITIAL,
)
mock_topic.publish.assert_called_once_with(json.dumps({"event": "bar"}).encode())
payloads = _published_payloads(mock_topic)
assert [payload["event"] for payload in payloads] == ["workflow_paused"]
def test_publish_streaming_response_publishes_started_then_failed_terminal_when_iteration_raises(
mock_topic: MagicMock,
):
def _response_stream():
if False:
yield None
raise RuntimeError("stream exploded")
with pytest.raises(RuntimeError, match="stream exploded"):
_publish_streaming_response(
_response_stream(),
"workflow-run-id",
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={"foo": "bar"},
started_reason=WorkflowStartReason.INITIAL,
)
payloads = _published_payloads(mock_topic)
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
assert payloads[0]["data"]["workflow_id"] == "workflow-id"
assert payloads[0]["data"]["inputs"] == {"foo": "bar"}
assert payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
assert payloads[1]["data"]["error"] == "stream exploded"
def test_publish_streaming_response_recovers_when_workflow_started_publish_fails_first(
mock_topic: MagicMock,
caplog: pytest.LogCaptureFixture,
):
caplog.set_level(logging.ERROR, logger="tasks.app_generate.workflow_execute_task")
response_stream = iter([{"event": "workflow_started", "task_id": "task-id"}])
successful_payloads: list[dict[str, object] | str] = []
started_publish_attempts = 0
def _publish(payload: bytes) -> None:
nonlocal started_publish_attempts
decoded = _decode_published_payload(payload)
if isinstance(decoded, dict) and decoded.get("event") == "workflow_started":
started_publish_attempts += 1
if started_publish_attempts == 1:
raise RuntimeError("started publish failed")
successful_payloads.append(decoded)
mock_topic.publish.side_effect = _publish
with pytest.raises(RuntimeError, match="started publish failed"):
_publish_streaming_response(
response_stream,
"workflow-run-id",
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={"file": object()},
started_reason=WorkflowStartReason.INITIAL,
)
assert [payload["event"] for payload in successful_payloads] == ["workflow_started", "workflow_finished"]
assert successful_payloads[0]["task_id"] == "task-id"
assert isinstance(successful_payloads[0]["data"]["inputs"]["file"], str)
assert successful_payloads[1]["task_id"] == "task-id"
assert successful_payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
assert successful_payloads[1]["data"]["error"] == "started publish failed"
assert "workflow-run-id" in caplog.text
assert "publishing fallback terminal event" in caplog.text
def test_publish_streaming_response_publishes_failed_terminal_without_duplicate_started_on_publish_error(
mock_topic: MagicMock,
caplog: pytest.LogCaptureFixture,
):
caplog.set_level(logging.ERROR, logger="tasks.app_generate.workflow_execute_task")
response_stream = iter(
[
{
"event": "workflow_started",
"task_id": "task-id",
"workflow_run_id": "workflow-run-id",
"data": {"id": "workflow-run-id", "workflow_id": "workflow-id", "inputs": {}, "created_at": 1},
},
{"event": "node_started", "task_id": "task-id"},
]
)
successful_payloads: list[dict[str, object] | str] = []
def _publish(payload: bytes) -> None:
decoded = _decode_published_payload(payload)
if isinstance(decoded, dict) and decoded.get("event") == "node_started":
raise RuntimeError("broker write failed")
successful_payloads.append(decoded)
mock_topic.publish.side_effect = _publish
with pytest.raises(RuntimeError, match="broker write failed"):
_publish_streaming_response(
response_stream,
"workflow-run-id",
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={},
started_reason=WorkflowStartReason.INITIAL,
)
assert [payload["event"] for payload in successful_payloads] == ["workflow_started", "workflow_finished"]
assert successful_payloads[1]["task_id"] == "task-id"
assert successful_payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
assert successful_payloads[1]["data"]["error"] == "broker write failed"
assert "workflow-run-id" in caplog.text
assert "publishing fallback terminal event" in caplog.text
def test_publish_streaming_response_recovers_when_workflow_finished_publish_fails_first(
mock_topic: MagicMock,
caplog: pytest.LogCaptureFixture,
):
caplog.set_level(logging.ERROR, logger="tasks.app_generate.workflow_execute_task")
response_stream = iter(
[
{"event": "workflow_started", "task_id": "task-id"},
{"event": "workflow_finished", "task_id": "task-id", "data": {"status": "succeeded"}},
]
)
successful_payloads: list[dict[str, object] | str] = []
finished_publish_attempts = 0
def _publish(payload: bytes) -> None:
nonlocal finished_publish_attempts
decoded = _decode_published_payload(payload)
if isinstance(decoded, dict) and decoded.get("event") == "workflow_finished":
finished_publish_attempts += 1
if finished_publish_attempts == 1:
raise RuntimeError("finished publish failed")
successful_payloads.append(decoded)
mock_topic.publish.side_effect = _publish
with pytest.raises(RuntimeError, match="finished publish failed"):
_publish_streaming_response(
response_stream,
"workflow-run-id",
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={},
started_reason=WorkflowStartReason.INITIAL,
)
assert [payload["event"] for payload in successful_payloads] == ["workflow_started", "workflow_finished"]
assert successful_payloads[1]["task_id"] == "task-id"
assert successful_payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
assert successful_payloads[1]["data"]["error"] == "finished publish failed"
assert "workflow-run-id" in caplog.text
assert "publishing fallback terminal event" in caplog.text
def test_publish_streaming_response_publishes_failed_terminal_on_exhaustion_without_terminal_event(
mock_topic: MagicMock,
caplog: pytest.LogCaptureFixture,
):
caplog.set_level(logging.WARNING, logger="tasks.app_generate.workflow_execute_task")
response_stream = iter(
[
{
"event": "workflow_started",
"task_id": "task-id",
"workflow_run_id": "workflow-run-id",
"data": {"id": "workflow-run-id", "workflow_id": "workflow-id", "inputs": {}, "created_at": 1},
}
]
)
_publish_streaming_response(
response_stream,
"workflow-run-id",
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={},
started_reason=WorkflowStartReason.INITIAL,
)
payloads = _published_payloads(mock_topic)
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
assert payloads[1]["task_id"] == "task-id"
assert payloads[1]["data"]["status"] == WorkflowExecutionStatus.FAILED
assert payloads[1]["data"]["error"] == "Workflow stream ended without a terminal event"
assert "workflow-run-id" in caplog.text
assert "ended without a terminal event" in caplog.text
def test_publish_streaming_response_does_not_publish_synthetic_failure_after_terminal_event(mock_topic: MagicMock):
response_stream = iter(
[
{
"event": "workflow_started",
"task_id": "task-id",
"workflow_run_id": "workflow-run-id",
"data": {"id": "workflow-run-id", "workflow_id": "workflow-id", "inputs": {}, "created_at": 1},
},
{
"event": "workflow_finished",
"task_id": "task-id",
"workflow_run_id": "workflow-run-id",
"data": {
"id": "workflow-run-id",
"workflow_id": "workflow-id",
"status": WorkflowExecutionStatus.SUCCEEDED,
"outputs": {},
"error": None,
"elapsed_time": 0.1,
"total_tokens": 1,
"total_steps": 1,
"created_by": {},
"created_at": 1,
"finished_at": 2,
"exceptions_count": 0,
"files": [],
},
},
]
)
_publish_streaming_response(
response_stream,
"workflow-run-id",
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-id",
inputs={},
started_reason=WorkflowStartReason.INITIAL,
)
payloads = _published_payloads(mock_topic)
assert [payload["event"] for payload in payloads] == ["workflow_started", "workflow_finished"]
def test_app_runner_streaming_failure_publishes_started_then_failed_workflow_finished(
mock_topic: MagicMock, monkeypatch
):
exec_params = AppExecutionParams(
app_id="app-id",
workflow_id="workflow-id",
tenant_id="tenant-id",
app_mode=AppMode.ADVANCED_CHAT,
user={"TYPE": "account", "user_id": "user-id"},
args={"inputs": {}, "query": "test"},
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
workflow_run_id="workflow-run-id",
)
runner = _AppRunner(session_factory=MagicMock(), exec_params=exec_params)
workflow = SimpleNamespace(id="workflow-id", app_id="app-id", created_by="workflow-owner")
app = SimpleNamespace(id="app-id")
fake_session = MagicMock()
fake_session.get.side_effect = [workflow, app]
monkeypatch.setattr(runner, "_session", lambda: nullcontext(fake_session))
monkeypatch.setattr(runner, "_resolve_user", lambda: MagicMock())
monkeypatch.setattr(runner, "_setup_flask_context", lambda _user: nullcontext())
monkeypatch.setattr(runner, "_run_app", lambda **_kwargs: (_ for _ in ()).throw(ValueError("Invalid upload file")))
with pytest.raises(ValueError, match="Invalid upload file"):
runner.run()
assert mock_topic.publish.call_count == 2
started_payload = json.loads(mock_topic.publish.call_args_list[0].args[0].decode())
assert started_payload["event"] == "workflow_started"
assert started_payload["workflow_run_id"] == "workflow-run-id"
assert started_payload["task_id"] == "workflow-run-id"
assert started_payload["data"]["id"] == "workflow-run-id"
assert started_payload["data"]["workflow_id"] == "workflow-id"
assert started_payload["data"]["reason"] == "initial"
finished_payload = json.loads(mock_topic.publish.call_args_list[1].args[0].decode())
assert finished_payload["event"] == "workflow_finished"
assert finished_payload["workflow_run_id"] == "workflow-run-id"
assert finished_payload["task_id"] == "workflow-run-id"
assert finished_payload["data"]["id"] == "workflow-run-id"
assert finished_payload["data"]["workflow_id"] == "workflow-id"
assert finished_payload["data"]["status"] == WorkflowExecutionStatus.FAILED
assert finished_payload["data"]["error"] == "Invalid upload file"
assert finished_payload["data"]["outputs"] is None
assert finished_payload["data"]["total_tokens"] == 0
assert finished_payload["data"]["total_steps"] == 0
assert finished_payload["data"]["exceptions_count"] == 1
assert finished_payload["data"]["created_by"] == {}
assert finished_payload["data"]["created_at"] == finished_payload["data"]["finished_at"]
assert finished_payload["data"]["files"] == []
def test_app_runner_streaming_failure_keeps_existing_pre_runtime_helper_behavior(
mock_topic: MagicMock,
monkeypatch: pytest.MonkeyPatch,
):
exec_params = AppExecutionParams(
app_id="app-id",
workflow_id="workflow-id",
tenant_id="tenant-id",
app_mode=AppMode.ADVANCED_CHAT,
user={"TYPE": "account", "user_id": "user-id"},
args={"inputs": {}, "query": "test"},
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
workflow_run_id="workflow-run-id",
)
runner = _AppRunner(session_factory=MagicMock(), exec_params=exec_params)
workflow = SimpleNamespace(id="workflow-id", app_id="app-id", created_by="workflow-owner")
app = SimpleNamespace(id="app-id")
fake_session = MagicMock()
fake_session.get.side_effect = [workflow, app]
monkeypatch.setattr(runner, "_session", lambda: nullcontext(fake_session))
monkeypatch.setattr(runner, "_resolve_user", lambda: MagicMock())
monkeypatch.setattr(runner, "_setup_flask_context", lambda _user: nullcontext())
monkeypatch.setattr(runner, "_run_app", lambda **_kwargs: (_ for _ in ()).throw(ValueError("Invalid upload file")))
monkeypatch.setattr(
"core.workflow.workflow_entry.WorkflowEntry.handle_special_values",
lambda value: (_ for _ in ()).throw(AssertionError("pre-runtime helper should not normalize inputs")),
)
with pytest.raises(ValueError, match="Invalid upload file"):
runner.run()
payloads = _published_payloads(mock_topic)
assert payloads[0]["data"]["inputs"] == {}
assert payloads[0]["data"]["reason"] == WorkflowStartReason.INITIAL
def test_app_runner_streaming_success_calls_publish_streaming_response_with_full_signature(
monkeypatch: pytest.MonkeyPatch,
):
exec_params = AppExecutionParams(
app_id="app-id",
workflow_id="workflow-id",
tenant_id="tenant-id",
app_mode=AppMode.ADVANCED_CHAT,
user={"TYPE": "account", "user_id": "user-id"},
args={"inputs": {"foo": "bar"}, "query": "test"},
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
workflow_run_id="workflow-run-id",
)
runner = _AppRunner(session_factory=MagicMock(), exec_params=exec_params)
workflow = SimpleNamespace(id="workflow-id", app_id="app-id", created_by="workflow-owner")
app = SimpleNamespace(id="app-id")
fake_session = MagicMock()
fake_session.get.side_effect = [workflow, app]
response_stream = _single_event_generator({"event": "message"})
publish_streaming_response = MagicMock()
monkeypatch.setattr(runner, "_session", lambda: nullcontext(fake_session))
monkeypatch.setattr(runner, "_resolve_user", lambda: MagicMock())
monkeypatch.setattr(runner, "_setup_flask_context", lambda _user: nullcontext())
monkeypatch.setattr(runner, "_run_app", lambda **_kwargs: response_stream)
monkeypatch.setattr(
"tasks.app_generate.workflow_execute_task._publish_streaming_response",
publish_streaming_response,
)
runner.run()
publish_streaming_response.assert_called_once_with(
response_stream,
exec_params.workflow_run_id,
exec_params.app_mode,
exec_params.workflow_id,
exec_params.args.get("inputs", {}),
WorkflowStartReason.INITIAL,
)
def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(monkeypatch: pytest.MonkeyPatch):
@@ -247,6 +692,7 @@ def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversat
def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch):
generate_entity = _build_advanced_chat_generate_entity(conversation_id="conversation-id")
generate_entity.stream = False
workflow = SimpleNamespace(id="workflow-id", created_by="workflow-owner")
generator_instance = MagicMock()
response_stream = _single_event_generator({"event": "message"})
@@ -271,7 +717,7 @@ def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monk
_resume_advanced_chat(
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(created_by="workflow-owner"),
workflow=workflow,
user=MagicMock(),
conversation=SimpleNamespace(id="conversation-id"),
message=MagicMock(),
@@ -285,11 +731,19 @@ def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monk
resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"]
assert resumed_entity.stream is True
publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.ADVANCED_CHAT)
publish_streaming_response.assert_called_once_with(
response_stream,
"workflow-run-id",
AppMode.ADVANCED_CHAT,
workflow.id,
generate_entity.inputs,
WorkflowStartReason.RESUMPTION,
)
def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch):
generate_entity = _build_workflow_generate_entity(stream=False)
workflow = SimpleNamespace(id="workflow-id", created_by="workflow-owner")
generator_instance = MagicMock()
response_stream = _single_event_generator({"event": "workflow_finished"})
@@ -316,7 +770,7 @@ def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypat
_resume_workflow(
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(created_by="workflow-owner"),
workflow=workflow,
user=MagicMock(),
generate_entity=generate_entity,
graph_runtime_state=MagicMock(),
@@ -330,12 +784,20 @@ def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypat
resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"]
assert resumed_entity.stream is True
publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW)
publish_streaming_response.assert_called_once_with(
response_stream,
"workflow-run-id",
AppMode.WORKFLOW,
workflow.id,
generate_entity.inputs,
WorkflowStartReason.RESUMPTION,
)
workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity)
def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: pytest.MonkeyPatch):
generate_entity = _build_workflow_generate_entity(stream=False)
workflow = SimpleNamespace(id="workflow-id", created_by="workflow-owner")
generator_instance = MagicMock()
response_stream = _single_event_generator({"event": "workflow_paused"})
@@ -363,7 +825,7 @@ def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: py
_resume_workflow(
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(created_by="workflow-owner"),
workflow=workflow,
user=MagicMock(),
generate_entity=generate_entity,
graph_runtime_state=MagicMock(),
@@ -375,5 +837,12 @@ def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: py
pause_entity=pause_entity,
)
publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW)
publish_streaming_response.assert_called_once_with(
response_stream,
"workflow-run-id",
AppMode.WORKFLOW,
workflow.id,
generate_entity.inputs,
WorkflowStartReason.RESUMPTION,
)
workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity)
Generated
+2 -2
View File
@@ -1331,7 +1331,7 @@ docs = [
[[package]]
name = "dify-api"
version = "1.14.2"
version = "1.15.0"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
@@ -1619,7 +1619,7 @@ vdb-xinference = [
requires-dist = [
{ name = "aliyun-log-python-sdk", specifier = "==0.9.44" },
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
{ name = "bleach", specifier = ">=6.3.0,<7.0.0" },
{ name = "bleach", specifier = ">=6.4.0,<7.0.0" },
{ name = "boto3", specifier = ">=1.43.24,<2.0.0" },
{ name = "celery", specifier = ">=5.6.3,<6.0.0" },
{ name = "croniter", specifier = ">=6.2.2,<7.0.0" },
+6 -6
View File
@@ -220,7 +220,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -264,7 +264,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
profiles:
- collaboration
environment:
@@ -290,7 +290,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -333,7 +333,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
environment:
MODE: beat
depends_on:
@@ -366,7 +366,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.14.2
image: langgenius/dify-web:1.15.0
restart: always
env_file:
- path: ./envs/core-services/web.env
@@ -518,7 +518,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.6.1-local
image: langgenius/dify-plugin-daemon:0.6.3-local
restart: always
env_file:
- path: ./envs/core-services/shared.env
+1 -1
View File
@@ -129,7 +129,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.6.1-local
image: langgenius/dify-plugin-daemon:0.6.3-local
restart: always
env_file:
- ./middleware.env
+6 -6
View File
@@ -226,7 +226,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -270,7 +270,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
profiles:
- collaboration
environment:
@@ -296,7 +296,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -339,7 +339,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.14.2
image: langgenius/dify-api:1.15.0
environment:
MODE: beat
depends_on:
@@ -372,7 +372,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.14.2
image: langgenius/dify-web:1.15.0
restart: always
env_file:
- path: ./envs/core-services/web.env
@@ -524,7 +524,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.6.1-local
image: langgenius/dify-plugin-daemon:0.6.3-local
restart: always
env_file:
- path: ./envs/core-services/shared.env
+2 -10
View File
@@ -3274,7 +3274,7 @@
},
"web/app/components/develop/code.tsx": {
"ts/no-explicit-any": {
"count": 7
"count": 6
}
},
"web/app/components/develop/doc.tsx": {
@@ -3286,9 +3286,6 @@
"jsx-a11y/no-redundant-roles": {
"count": 1
},
"ts/no-empty-object-type": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@@ -4476,11 +4473,6 @@
"count": 1
}
},
"web/app/components/tools/provider/detail.tsx": {
"jsx-a11y/anchor-has-content": {
"count": 1
}
},
"web/app/components/tools/provider/tool-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -7227,7 +7219,7 @@
"count": 1
},
"ts/no-explicit-any": {
"count": 13
"count": 9
}
},
"web/service/datasets.ts": {
@@ -5,7 +5,7 @@ import { AppPublisher } from '@/app/components/app/app-publisher'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
const mockFetchAppDetailDirect = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockSetAppDetail = vi.fn()
const mockRefetch = vi.fn()
@@ -69,7 +69,7 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
@@ -120,7 +120,7 @@ describe('App Access Control Flow', () => {
access_token: 'token-1',
},
}
mockFetchAppDetailDirect.mockResolvedValue({
mockFetchAppDetail.mockResolvedValue({
...mockAppDetail,
access_mode: AccessMode.PUBLIC,
})
@@ -128,7 +128,7 @@ describe('App Access Control Flow', () => {
it('refreshes app detail after confirming access control updates', async () => {
const { queryClient } = renderWithQueryClient(<AppPublisher publishedAt={1700000000} />)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue()
const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData')
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' }))
fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific'))
@@ -138,8 +138,14 @@ describe('App Access Control Flow', () => {
fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' }))
await waitFor(() => {
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
await waitFor(() => {
expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument()
@@ -39,7 +39,7 @@ vi.mock('@/context/app-context', () => ({
},
workspacePermissionKeys: [
'plugin.install',
'plugin.manage',
'plugin.delete',
'plugin.plugin_preferences',
],
}),
@@ -23,12 +23,12 @@ vi.mock('@/app/components/plugins/hooks', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { id: 'user-1', timezone: 'UTC' },
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'],
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.delete', 'plugin.plugin_preferences'],
langGeniusVersionInfo: { current_version: '1.0.0' },
}),
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) =>
selector({
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'],
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.delete', 'plugin.plugin_preferences'],
}),
}))
@@ -46,12 +46,12 @@ vi.mock('@/app/components/plugins/hooks', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { id: 'user-1', timezone: 'UTC' },
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'],
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.delete', 'plugin.plugin_preferences'],
langGeniusVersionInfo: { current_version: '1.0.0' },
}),
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) =>
selector({
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'],
workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.delete', 'plugin.plugin_preferences'],
}),
}))
@@ -128,7 +128,7 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should allow users with monitor access to open logs directly', async () => {
it('should redirect logs pages when log and annotation access is missing', async () => {
mockPathname = '/app/app-1/logs'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.Monitor] }))
@@ -138,9 +138,26 @@ describe('AppDetailLayout', () => {
</AppDetailLayout>,
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/overview')
})
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should allow users with log and annotation access to open logs directly', async () => {
mockPathname = '/app/app-1/logs'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.LogAndAnnotation] }))
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
expect(mockReplace).not.toHaveBeenCalledWith('/app/app-1/overview')
expect(mockReplace).not.toHaveBeenCalled()
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
@@ -289,7 +306,7 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should redirect annotation pages when edit access is missing', async () => {
it('should redirect annotation pages when log and annotation access is missing', async () => {
mockPathname = '/app/app-1/annotations'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({
mode: AppModeEnum.CHAT,
@@ -309,11 +326,11 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should allow users with edit access to open annotations directly', async () => {
it('should allow users with log and annotation access to open annotations directly', async () => {
mockPathname = '/app/app-1/annotations'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({
mode: AppModeEnum.CHAT,
permission_keys: [AppACLPermission.Edit],
permission_keys: [AppACLPermission.LogAndAnnotation],
}))
render(
@@ -108,8 +108,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const isAccessConfigPath = pathname.endsWith('access-config')
if (
(isLayoutPath && !appACLCapabilities.canAccessLayout)
|| (isLogsPath && !appACLCapabilities.canMonitor)
|| (isAnnotationsPath && !appACLCapabilities.canEdit)
|| (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation)
|| (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation)
|| (isOverviewPath && !appACLCapabilities.canMonitor)
|| (isAccessConfigPath && !appACLCapabilities.canAccessConfig)
) {
@@ -15,7 +15,8 @@ const mockAppState = vi.hoisted(() => ({
const mockUpdateAppSiteStatus = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteConfig = vi.hoisted(() => vi.fn())
const mockUpdateAppSiteAccessToken = vi.hoisted(() => vi.fn())
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
const mockFetchAppDetail = vi.hoisted(() => vi.fn())
const mockSetQueryData = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/app/store', () => ({
useStore: <T,>(selector: (state: typeof mockAppState) => T): T => selector(mockAppState),
@@ -26,6 +27,7 @@ vi.mock('@/service/use-workflow', () => ({
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args),
updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args),
updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args),
@@ -33,7 +35,7 @@ vi.mock('@/service/apps', () => ({
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
setQueryData: mockSetQueryData,
}),
}))
@@ -104,7 +106,14 @@ describe('CardView ACL edit guards', () => {
mockUpdateAppSiteStatus.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteConfig.mockResolvedValue(mockAppState.appDetail as App)
mockUpdateAppSiteAccessToken.mockResolvedValue({ code: 'token' })
mockInvalidateQueries.mockResolvedValue(undefined)
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
mode: 'chat',
permission_keys: ['app.acl.edit'],
site: {
title: 'Saved site title',
},
} as unknown as App)
})
// User-facing card actions should not mutate app settings without app ACL edit permission.
@@ -122,6 +131,7 @@ describe('CardView ACL edit guards', () => {
expect(mockUpdateAppSiteStatus).not.toHaveBeenCalled()
expect(mockUpdateAppSiteConfig).not.toHaveBeenCalled()
expect(mockUpdateAppSiteAccessToken).not.toHaveBeenCalled()
expect(mockFetchAppDetail).not.toHaveBeenCalled()
})
it('should call write APIs when app ACL edit permission is present', async () => {
@@ -153,7 +163,35 @@ describe('CardView ACL edit guards', () => {
expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({
url: '/apps/app-1/site/access-token-reset',
})
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalled()
})
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
expect(mockAppState.setAppDetail).toHaveBeenCalledWith(expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
})
it('should refresh the Zustand app detail after saving webapp settings', async () => {
const user = userEvent.setup()
mockAppState.appDetail.permission_keys = ['app.acl.edit']
render(<CardView appId="app-1" />)
await user.click(screen.getByRole('button', { name: /save webapp/ }))
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(mockSetQueryData).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
expect(mockAppState.setAppDetail).toHaveBeenCalledWith(expect.objectContaining({
site: expect.objectContaining({ title: 'Saved site title' }),
}))
})
})
})
@@ -69,14 +69,34 @@ describe('OverviewView monitor permission', () => {
expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument()
})
it('should render overview page content when app monitor permission is granted', () => {
it('should render overview page content without tracing entry when only app monitor permission is granted', () => {
testState.appDetail.permission_keys = [AppACLPermission.Monitor]
render(<OverviewView appId="app-1" />)
expect(screen.getByText('api key info panel')).toBeInTheDocument()
expect(screen.getByText(/chart view app-1/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument()
})
it('should render tracing entry when app tracing config permission is granted with monitor access', () => {
testState.appDetail.permission_keys = [AppACLPermission.Monitor, AppACLPermission.TracingConfig]
render(<OverviewView appId="app-1" />)
expect(screen.getByText('api key info panel')).toBeInTheDocument()
expect(screen.getByText(/chart view app-1/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'tracing' })).toBeInTheDocument()
})
it('should not render overview page content when only app tracing config permission is granted', () => {
testState.appDetail.permission_keys = [AppACLPermission.TracingConfig]
render(<OverviewView appId="app-1" />)
expect(screen.queryByText('api key info panel')).not.toBeInTheDocument()
expect(screen.queryByText(/chart view app-1/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument()
})
})
})
@@ -20,6 +20,7 @@ import { webSocketClient } from '@/app/components/workflow/collaboration/core/we
import { isTriggerNode } from '@/app/components/workflow/types'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
@@ -40,6 +41,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const queryClient = useQueryClient()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canEditApp = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, {
@@ -88,12 +90,14 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const updateAppDetail = useCallback(async () => {
try {
await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appId] })
const res = await fetchAppDetail({ url: '/apps', id: appId })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appId], res)
setAppDetail({ ...res })
}
catch (error) {
console.error(error)
}
}, [appId, queryClient])
}, [appId, queryClient, setAppDetail])
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = err ? 'error' : 'success'
@@ -122,11 +122,24 @@ describe('Tracing overview panel permissions', () => {
})
})
it('allows tracing config when app ACL includes monitor permission', async () => {
it('marks tracing config as read-only with app monitor permission only', async () => {
testState.appPermissionKeys = [AppACLPermission.Monitor]
await renderPanel()
await waitFor(() => {
expect(testState.configButtonProps[0]).toMatchObject({
readOnly: true,
hasConfigured: false,
})
})
})
it('allows tracing config when app ACL includes tracing config permission', async () => {
testState.appPermissionKeys = [AppACLPermission.TracingConfig]
await renderPanel()
await waitFor(() => {
expect(testState.configButtonProps[0]).toMatchObject({
readOnly: false,
@@ -47,7 +47,7 @@ const Panel: FC = () => {
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
}), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys])
const canConfigTracing = appACLCapabilities.canMonitor
const canConfigTracing = appACLCapabilities.canConfigureTracing
const readOnly = !canConfigTracing
const [isLoaded, {
@@ -16,13 +16,13 @@ const OverviewView = ({ appId }: OverviewViewProps) => {
const appDetail = useAppStore(state => state.appDetail)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canMonitor = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, {
const appACLCapabilities = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
}).canMonitor, [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys])
}), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys])
if (!appDetail || !canMonitor)
if (!appDetail || !appACLCapabilities.canMonitor)
return null
return (
@@ -31,7 +31,7 @@ const OverviewView = ({ appId }: OverviewViewProps) => {
<div className="min-h-0 flex-1">
<ChartView
appId={appId}
headerRight={<TracingPanel />}
headerRight={appACLCapabilities.canConfigureTracing ? <TracingPanel /> : null}
/>
</div>
</div>
@@ -69,28 +69,30 @@ describe('AppDetailSection', () => {
// Rendering behavior for app detail navigation entries.
describe('Rendering', () => {
it('should render logs and overview for chat apps with app monitor permission', () => {
it('should render only overview for chat apps with app monitor permission', () => {
// Arrange
mockAppMode = 'chat'
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toHaveAttribute('href', '/app/app-1/overview')
expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
expect(screen.queryAllByRole('separator')).toHaveLength(0)
})
it('should render logs and annotations for chat apps with app log and annotation permission', () => {
// Arrange
mockAppMode = 'chat'
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs')
expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toHaveAttribute('href', '/app/app-1/overview')
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
})
it('should render annotations for chat apps with app edit permission', () => {
// Arrange
mockAppMode = 'chat'
mockAppPermissionKeys = [AppACLPermission.Edit]
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations')
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('data-icon', 'Annotations')
expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument()
@@ -99,7 +101,7 @@ describe('AppDetailSection', () => {
it('should render dividers before logs and after annotations for chat apps', () => {
// Arrange
mockAppMode = 'chat'
mockAppPermissionKeys = [AppACLPermission.Monitor, AppACLPermission.Edit]
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
// Act
render(<AppDetailSection />)
@@ -111,6 +113,7 @@ describe('AppDetailSection', () => {
it('should only render logs navigation for workflow apps', () => {
// Arrange
mockAppMode = 'workflow'
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
// Act
render(<AppDetailSection />)
@@ -123,6 +126,7 @@ describe('AppDetailSection', () => {
it('should render dividers before and after logs for workflow apps', () => {
// Arrange
mockAppMode = 'workflow'
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
// Act
render(<AppDetailSection />)
@@ -134,6 +138,7 @@ describe('AppDetailSection', () => {
it('should only render logs navigation for completion apps', () => {
// Arrange
mockAppMode = 'completion'
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
// Act
render(<AppDetailSection />)
@@ -143,9 +148,9 @@ describe('AppDetailSection', () => {
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
})
it('should not render monitor group dividers without monitor or edit permission', () => {
it('should not render log and annotation group dividers without log and annotation permission', () => {
// Arrange
mockAppPermissionKeys = []
mockAppPermissionKeys = [AppACLPermission.Monitor]
// Act
render(<AppDetailSection />)
@@ -154,19 +159,20 @@ describe('AppDetailSection', () => {
expect(screen.queryAllByRole('separator')).toHaveLength(0)
expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toBeInTheDocument()
})
it('should render logs for users with app monitor permission', () => {
it('should render logs for users with app log and annotation permission', () => {
// Arrange
mockAppPermissionKeys = [AppACLPermission.Monitor]
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs')
expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations')
expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(2)
})
@@ -225,6 +231,9 @@ describe('AppDetailSection', () => {
})
it('should pass collapsed mode to app info and navigation links when collapsed', () => {
// Arrange
mockAppPermissionKeys = [AppACLPermission.LogAndAnnotation]
// Act
render(<AppDetailSection expand={false} />)
@@ -111,7 +111,7 @@ const AppDetailSection = ({
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(appACLCapabilities.canMonitor
...(appACLCapabilities.canAccessLogAndAnnotation
? [{
name: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
@@ -120,7 +120,7 @@ const AppDetailSection = ({
}]
: []
),
...(appACLCapabilities.canEdit && supportsAnnotations
...(appACLCapabilities.canAccessLogAndAnnotation && supportsAnnotations
? [{
name: t('appMenus.annotations', { ns: 'common' }),
href: `/app/${appId}/annotations`,
@@ -19,7 +19,7 @@ const mockRefetch = vi.fn()
const mockUseGetUserCanAccessApp = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockToastError = vi.fn()
const mockWindowOpen = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
@@ -90,7 +90,7 @@ vi.mock('@/service/explore', () => ({
const mockPublishToCreatorsPlatform = vi.fn()
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args),
}))
@@ -211,7 +211,7 @@ describe('AppPublisher', () => {
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [{ id: 'installed-1' }],
})
mockFetchAppDetailDirect.mockResolvedValue({
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
@@ -416,7 +416,7 @@ describe('AppPublisher', () => {
publishedAt={Date.now()}
/>,
)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue()
const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData')
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
@@ -426,8 +426,14 @@ describe('AppPublisher', () => {
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
})
it('should open the installed explore page through the async window helper', async () => {
@@ -667,7 +673,7 @@ describe('AppPublisher', () => {
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
expect(mockFetchAppDetail).not.toHaveBeenCalled()
})
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
})
@@ -39,7 +39,7 @@ import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { publishToCreatorsPlatform } from '@/service/apps'
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
@@ -130,6 +130,7 @@ export function AppPublisher({
const workflowStore = use(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const canManageTools = useCanManageTools()
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@@ -242,7 +243,9 @@ export function AppPublisher({
if (!appDetail)
return
try {
await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appDetail.id] })
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res)
setAppDetail({ ...res })
}
finally {
setShowAppAccessControl(false)
@@ -17,6 +17,7 @@ const mockPush = vi.fn()
const mockSetAppDetail = vi.fn()
const mockOnChangeStatus = vi.fn()
const mockOnGenerateCode = vi.fn()
const mockFetchAppDetail = vi.fn()
let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array<Record<string, unknown>> } }> } } | null = null
let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] }
@@ -59,6 +60,10 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
}),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
vi.mock('@/app/components/develop/secret-key/secret-key-button', () => ({
default: ({ appId }: { appId: string }) => <div data-testid="secret-key-button">{appId}</div>,
}))
@@ -125,6 +130,14 @@ describe('AppCard', () => {
groups: [],
members: [],
}
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
site: {
app_base_url: 'https://example.com',
access_token: 'access-token',
},
} as AppDetailResponse)
})
it('should open the published webapp when launch is clicked', () => {
@@ -405,7 +418,7 @@ describe('AppCard', () => {
onGenerateCode={mockOnGenerateCode}
/>,
)
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue()
const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData')
fireEvent.click(screen.getByText('publishApp.notSet'))
expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
@@ -413,8 +426,14 @@ describe('AppCard', () => {
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
})
expect(setQueryDataSpy).toHaveBeenCalledWith(['apps', 'detail', 'app-1'], expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({
access_mode: AccessMode.PUBLIC,
}))
})
it('should surface the learn-more tooltip action for workflows without a start node', () => {
@@ -467,14 +486,14 @@ describe('AppCard', () => {
it('should report refresh failures from access control updates', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
mockFetchAppDetail.mockRejectedValueOnce(new Error('refresh failed'))
const { queryClient } = render(
render(
<AppCard
appInfo={appInfo}
onChangeStatus={mockOnChangeStatus}
/>,
)
vi.spyOn(queryClient, 'invalidateQueries').mockRejectedValueOnce(new Error('refresh failed'))
fireEvent.click(screen.getByText('publishApp.notSet'))
fireEvent.click(screen.getByText('confirm-access-control'))
+6 -2
View File
@@ -19,6 +19,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { fetchAppDetail } from '@/service/apps'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
@@ -83,6 +84,7 @@ function AppCard({
const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '')
const docLink = useDocLink()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
@@ -157,13 +159,15 @@ function AppCard({
return
try {
await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appDetail.id] })
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appDetail.id], res)
setAppDetail({ ...res })
setShowAccessControl(false)
}
catch (error) {
console.error('Failed to fetch app detail:', error)
}
}, [appDetail, queryClient])
}, [appDetail, queryClient, setAppDetail])
const operationKeys = useMemo(() => getAppCardOperationKeys({
cardType,
@@ -687,6 +687,29 @@ describe('useChat', () => {
expect(lastResponse!.workflowProcess?.status).toBe('failed')
})
it('should store workflow finished error on workflow process state', async () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'failed workflow' }, {})
})
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-err', task_id: 't-err' })
callbacks.onWorkflowFinished({ data: { status: 'failed', error: 'Invalid upload file' } })
})
const lastResponse = result.current.chatList[1]
expect(lastResponse!.workflowProcess?.status).toBe('failed')
expect(lastResponse!.workflowProcess?.error).toBe('Invalid upload file')
})
it('should insert and then replace child QA when sending with parent_message_id', () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
@@ -24,6 +24,21 @@ describe('WorkflowProcessItem', () => {
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
})
it('should render workflow error message as collapsed title when failed without tracing', () => {
render(
<WorkflowProcessItem
data={{
status: WorkflowRunningStatus.Failed,
tracing: [],
error: 'Invalid upload file',
} as WorkflowProcess}
expand={false}
/>,
)
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('Invalid upload file')
})
it('should render "Workflow Process" title and TracingPanel when expanded', () => {
// We expect t('common.workflowProcess', { ns: 'workflow' }) to be called
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={true} />)
@@ -31,6 +46,21 @@ describe('WorkflowProcessItem', () => {
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
})
it('should render workflow error message when failed without node tracing details', () => {
render(
<WorkflowProcessItem
data={{
status: WorkflowRunningStatus.Failed,
tracing: [],
error: 'Invalid upload file',
} as WorkflowProcess}
expand={true}
/>,
)
expect(screen.getByText('Invalid upload file')).toBeInTheDocument()
})
it('should toggle collapse state on header click', async () => {
const user = userEvent.setup()
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
@@ -89,7 +119,7 @@ describe('WorkflowProcessItem', () => {
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-paused-bg')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-failed-bg')
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-[var(--color-workflow-process-failed-bg)]')
})
it('should apply correct background when expanded for different statuses', () => {
@@ -31,6 +31,10 @@ const WorkflowProcessItem = ({
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
const paused = data.status === WorkflowRunningStatus.Paused
const latestNode = data.tracing[data.tracing.length - 1]
const fallbackTitle = t('common.workflowProcess', { ns: 'workflow' })
const collapsedTitle = failed
? data.error || latestNode?.error || latestNode?.title || fallbackTitle
: latestNode?.title || fallbackTitle
useEffect(() => {
setCollapse(!expand)
@@ -50,7 +54,7 @@ const WorkflowProcessItem = ({
paused && !collapse && 'bg-state-warning-hover',
collapse && !failed && !paused && 'bg-workflow-process-bg',
collapse && paused && 'bg-workflow-process-paused-bg',
collapse && failed && 'bg-workflow-process-failed-bg',
collapse && failed && 'bg-[var(--color-workflow-process-failed-bg)]',
)}
data-testid="workflow-process-item"
>
@@ -92,21 +96,38 @@ const WorkflowProcessItem = ({
)
}
<div
className="min-w-0 grow truncate system-xs-medium text-text-secondary"
className={cn(
'min-w-0 grow truncate system-xs-medium',
collapse && failed && data.error ? 'text-text-destructive' : 'text-text-secondary',
)}
data-testid="workflow-process-title"
>
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
{!collapse ? fallbackTitle : collapsedTitle}
</div>
<div className={cn('ml-1 i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary', !collapse && 'rotate-90')} />
</div>
{
!collapse && (
<div className="mt-1.5">
<TracingPanel
list={data.tracing}
hideNodeInfo={hideInfo}
hideNodeProcessDetail={hideProcessDetail}
/>
{
failed && data.error && (
<div
className="mb-1.5 rounded-lg border-[0.5px] border-state-destructive-border bg-state-destructive-hover px-2 py-1.5 system-xs-regular text-text-destructive"
data-testid="workflow-process-error"
>
{data.error}
</div>
)
}
{
data.tracing.length > 0 && (
<TracingPanel
list={data.tracing}
hideNodeInfo={hideInfo}
hideNodeProcessDetail={hideProcessDetail}
/>
)
}
</div>
)
}
+22 -5
View File
@@ -405,7 +405,11 @@ export const useChat = (
hasStopRespondedRef.current = false
updateChatTreeNode(messageId, (responseItem) => {
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
responseItem.workflowProcess = {
...responseItem.workflowProcess,
status: WorkflowRunningStatus.Running,
error: undefined,
}
}
else {
taskIdRef.current = task_id
@@ -419,8 +423,13 @@ export const useChat = (
},
onWorkflowFinished: ({ data: workflowFinishedData }) => {
updateChatTreeNode(messageId, (responseItem) => {
if (responseItem.workflowProcess)
responseItem.workflowProcess.status = workflowFinishedData.status as WorkflowRunningStatus
if (responseItem.workflowProcess) {
responseItem.workflowProcess = {
...responseItem.workflowProcess,
status: workflowFinishedData.status as WorkflowRunningStatus,
error: workflowFinishedData.error,
}
}
})
},
onIterationStart: ({ data: iterationStartedData }) => {
@@ -971,7 +980,11 @@ export const useChat = (
}
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
responseItem.workflowProcess = {
...responseItem.workflowProcess,
status: WorkflowRunningStatus.Running,
error: undefined,
}
}
else {
taskIdRef.current = task_id
@@ -991,7 +1004,11 @@ export const useChat = (
onWorkflowFinished: ({ data: workflowFinishedData }) => {
if (pausedStateRef.current)
pausedStateRef.current = false
responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus
responseItem.workflowProcess = {
...responseItem.workflowProcess!,
status: workflowFinishedData.status as WorkflowRunningStatus,
error: workflowFinishedData.error,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
+1
View File
@@ -38,6 +38,7 @@ export type ChatConfig = Omit<ModelConfig, 'model'> & {
export type WorkflowProcess = {
status: WorkflowRunningStatus
tracing: NodeTracing[]
error?: string
expand?: boolean // for UI
resultText?: string
files?: FileEntity[]
@@ -1,6 +1,6 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Code, CodeGroup, Embed, Pre } from '../code'
import { CodeGroup, Embed } from '../code'
vi.mock('@/utils/clipboard', () => ({
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
@@ -21,31 +21,6 @@ describe('code.tsx components', () => {
vi.restoreAllMocks()
})
describe('Code', () => {
it('should render children as a code element', () => {
render(<Code>const x = 1</Code>)
const codeElement = screen.getByText('const x = 1')
expect(codeElement.tagName).toBe('CODE')
})
it('should pass through additional props', () => {
render(<Code data-testid="custom-code" className="custom-class">snippet</Code>)
const codeElement = screen.getByTestId('custom-code')
expect(codeElement).toHaveClass('custom-class')
})
it('should render with complex children', () => {
render(
<Code>
<span>part1</span>
<span>part2</span>
</Code>,
)
expect(screen.getByText('part1')).toBeInTheDocument()
expect(screen.getByText('part2')).toBeInTheDocument()
})
})
describe('Embed', () => {
it('should render value prop as a span element', () => {
render(<Embed value="embedded content">ignored children</Embed>)
@@ -277,28 +252,6 @@ describe('code.tsx components', () => {
})
})
describe('Pre', () => {
it('should wrap children in CodeGroup when outside CodeGroup context', () => {
render(
<Pre title="Pre Title">
<pre><code>code</code></pre>
</Pre>,
)
expect(screen.getByText('Pre Title')).toBeInTheDocument()
})
it('should return children directly when inside CodeGroup context', () => {
render(
<CodeGroup targetCode="outer code">
<Pre>
<code>inner code</code>
</Pre>
</CodeGroup>,
)
expect(screen.getByText('outer code')).toBeInTheDocument()
})
})
describe('CodePanelHeader (via CodeGroup)', () => {
it('should render when tag is provided', () => {
render(
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import { Col, Heading, Properties, Property, PropertyInstruction, Row, SubProperty } from '../md'
import { Col, Heading, Properties, Property, Row, SubProperty } from '../md'
describe('md.tsx components', () => {
describe('Heading', () => {
@@ -540,67 +540,6 @@ describe('md.tsx components', () => {
})
})
describe('PropertyInstruction', () => {
it('should render children', () => {
render(
<PropertyInstruction>
This is an instruction
</PropertyInstruction>,
)
expect(screen.getByText('This is an instruction')).toBeInTheDocument()
})
it('should render as li element', () => {
const { container } = render(
<PropertyInstruction>
Instruction text
</PropertyInstruction>,
)
expect(container.querySelector('li')).toBeInTheDocument()
})
it('should have m-0 class', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('m-0')
})
it('should have padding classes', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('px-0')
expect(li.className).toContain('py-4')
})
it('should have italic class', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('italic')
})
it('should have first:pt-0 class', () => {
const { container } = render(
<PropertyInstruction>
Instruction
</PropertyInstruction>,
)
const li = container.querySelector('li')!
expect(li.className).toContain('first:pt-0')
})
})
describe('integration tests', () => {
it('should render Property inside Properties', () => {
render(
@@ -635,21 +574,5 @@ describe('md.tsx components', () => {
expect(screen.getByText('Left column')).toBeInTheDocument()
expect(screen.getByText('Right column')).toBeInTheDocument()
})
it('should render PropertyInstruction inside Properties', () => {
render(
<Properties anchor={false}>
<PropertyInstruction>
Note: All fields are required
</PropertyInstruction>
<Property name="required_field" type="string" anchor={false}>
A required field
</Property>
</Properties>,
)
expect(screen.getByText('Note: All fields are required')).toBeInTheDocument()
expect(screen.getByText('required_field')).toBeInTheDocument()
})
})
})
+15 -41
View File
@@ -1,5 +1,5 @@
'use client'
import type { PropsWithChildren, ReactElement, ReactNode } from 'react'
import type { PropsWithChildren, ReactElement } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Tabs,
@@ -9,8 +9,6 @@ import {
} from '@langgenius/dify-ui/tabs'
import {
Children,
createContext,
use,
useEffect,
useRef,
useState,
@@ -269,8 +267,6 @@ function useTabGroupProps(tabValues: string[]) {
}
}
const CodeGroupContext = createContext(false)
type CodeGroupProps = PropsWithChildren<{
/** Code example(s) to display */
targetCode?: string | CodeExample[]
@@ -297,42 +293,20 @@ export function CodeGroup({ children, title, targetCode, ...props }: CodeGroupPr
</>
)
return (
<CodeGroupContext.Provider value={true}>
{hasTabs
? (
<Tabs
{...tabGroupProps}
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
>
{content}
</Tabs>
)
: (
<div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10">
{content}
</div>
)}
</CodeGroupContext.Provider>
)
}
type IChildProps = {
children: ReactNode
[key: string]: any
}
export function Code({ children, ...props }: IChildProps) {
return <code {...props}>{children}</code>
}
export function Pre({ children, ...props }: IChildrenProps) {
const isGrouped = use(CodeGroupContext)
if (isGrouped)
return children
return <CodeGroup {...props}>{children}</CodeGroup>
return hasTabs
? (
<Tabs
{...tabGroupProps}
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
>
{content}
</Tabs>
)
: (
<div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10">
{content}
</div>
)
}
export function Embed({ value, ...props }: IChildrenProps) {
-7
View File
@@ -1,5 +1,4 @@
'use client'
import type { PropsWithChildren } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type IChildrenProps = {
@@ -140,9 +139,3 @@ export function SubProperty({ name, type, children }: ISubProperty) {
</li>
)
}
export function PropertyInstruction({ children }: PropsWithChildren<{ }>) {
return (
<li className="m-0 px-0 py-4 italic first:pt-0">{children}</li>
)
}
@@ -346,7 +346,7 @@ describe('BannerItem', () => {
expect(wrapper).toHaveClass('rounded-2xl')
})
it('keeps a fixed height even when text content is empty', () => {
it('keeps the desktop height even when text content is empty', () => {
const banner = createMockBanner({
content: {
'category': '',
@@ -359,7 +359,7 @@ describe('BannerItem', () => {
const { container } = renderBannerItem(banner)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('h-[184px]')
expect(wrapper).toHaveClass('xl:h-[184px]')
})
})
})
@@ -130,7 +130,7 @@ export function BannerItem({
return (
<div
className="flex h-[184px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs"
className="flex h-[224px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs xl:h-[184px]"
onClick={handleBannerClick}
>
<div className="flex min-w-px flex-1 flex-col items-end self-stretch rounded-2xl py-6 pl-8">
@@ -140,7 +140,7 @@ export function BannerItem({
</p>
</div>
<div className="flex w-full flex-col gap-3 py-1">
<div className="flex w-full flex-col gap-3 py-1 max-xl:flex-1 max-xl:justify-between">
<div
ref={textAreaRef}
className="grid w-full grid-cols-[minmax(0,680px)_minmax(240px,600px)] gap-x-1 max-xl:grid-cols-1"
@@ -159,11 +159,11 @@ export function BannerItem({
</div>
<div
className="grid w-full grid-cols-[minmax(0,680px)_minmax(240px,600px)] gap-x-1 max-xl:grid-cols-1"
className="flex w-full items-center justify-between gap-4 pr-4 xl:grid xl:grid-cols-[minmax(0,680px)_minmax(240px,600px)] xl:gap-x-1 xl:pr-0"
style={responsiveStyle}
>
<div
className="flex min-w-0 items-center gap-[6px] py-1"
className="flex min-w-0 items-center gap-[6px] py-1 max-xl:flex-1"
style={viewMoreStyle}
>
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
@@ -174,7 +174,7 @@ export function BannerItem({
</span>
</div>
<div className="flex min-w-0 items-center gap-2 py-1 pr-10">
<div className="flex min-w-0 shrink-0 items-center gap-2 py-1 xl:pr-10">
{/* Slide navigation indicators */}
<div className="flex items-center gap-1">
{indicatorItems.map(({ id, index }) => (
@@ -196,13 +196,13 @@ export function BannerItem({
</div>
</div>
<div className="flex w-60 max-w-60 shrink-0 flex-col items-end self-stretch p-2 max-lg:hidden">
<div className="flex w-60 max-w-60 shrink-0 flex-col items-end self-stretch p-2 max-xl:w-[360px] max-xl:max-w-[360px] max-lg:hidden">
<img
src={imgSrc}
alt={title}
width={224}
height={168}
className="h-[168px] w-56 shrink-0 rounded-xl object-cover"
className="h-full w-full shrink-0 rounded-xl object-cover"
/>
</div>
</div>
@@ -13,8 +13,8 @@ describe('AccountSetting Constants', () => {
it('should have correct ACCOUNT_SETTING_TAB values', () => {
expect(ACCOUNT_SETTING_TAB.PROVIDER).toBe('provider')
expect(ACCOUNT_SETTING_TAB.MEMBERS).toBe('members')
expect(ACCOUNT_SETTING_TAB.PERMISSIONS).toBe('permissions')
expect(ACCOUNT_SETTING_TAB.ACCESS_RULES).toBe('access-rules')
expect(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS).toBe('roles-and-permissions')
expect(ACCOUNT_SETTING_TAB.PERMISSION_SET).toBe('permission-set')
expect(ACCOUNT_SETTING_TAB.BILLING).toBe('billing')
expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('custom-endpoint')
@@ -28,8 +28,8 @@ describe('AccountSetting Constants', () => {
})
it('isValidSettingsTab should include integrations tabs', () => {
expect(isValidSettingsTab('permissions')).toBe(true)
expect(isValidSettingsTab('access-rules')).toBe(true)
expect(isValidSettingsTab('roles-and-permissions')).toBe(true)
expect(isValidSettingsTab('permission-set')).toBe(true)
expect(isValidSettingsTab('billing')).toBe(true)
expect(isValidSettingsTab('preferences')).toBe(true)
expect(isValidSettingsTab('language')).toBe(true)
@@ -241,7 +241,7 @@ describe('AccountSetting', () => {
expect(screen.queryByText('common.settings.provider'))!.not.toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.resourceAccess' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.permissionSet' })).toBeInTheDocument()
expect(screen.getByText('common.settings.billing'))!.toBeInTheDocument()
expect(screen.queryByText('common.settings.dataSource'))!.not.toBeInTheDocument()
expect(screen.queryByText('common.settings.customEndpoint'))!.not.toBeInTheDocument()
@@ -357,7 +357,7 @@ describe('AccountSetting', () => {
// Assert
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.resourceAccess' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.permissionSet' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.preferences' })).toBeInTheDocument()
@@ -400,7 +400,7 @@ describe('AccountSetting', () => {
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
})
it('should hide role and resource access entries when role management permission is missing', () => {
it('should hide role and permission set entries when role management permission is missing', () => {
// Arrange
const contextWithoutRoleManagePermission = {
...baseAppContextValue,
@@ -415,22 +415,22 @@ describe('AccountSetting', () => {
// Assert
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.permissionSet' })).not.toBeInTheDocument()
})
it('should hide role and resource access entries when RBAC is disabled', () => {
it('should hide role and permission set entries when RBAC is disabled', () => {
// Act
renderAccountSetting({ rbacEnabled: false })
// Assert
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.permissionSet' })).not.toBeInTheDocument()
})
it('should not render direct role pages when RBAC is disabled', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.ACCESS_RULES, rbacEnabled: false })
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PERMISSION_SET, rbacEnabled: false })
// Assert
expect(screen.queryByTestId('access-rules-page')).not.toBeInTheDocument()
@@ -554,9 +554,9 @@ describe('AccountSetting', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.settings.rolesAndPermissions' }))
expect(screen.getByTestId('permissions-page')).toBeInTheDocument()
// Resource Access
fireEvent.click(screen.getByRole('button', { name: 'common.settings.resourceAccess' }))
expect(screen.getByText('common.settings.resourceAccessDescription')).toBeInTheDocument()
// Permission Set
fireEvent.click(screen.getByRole('button', { name: 'common.settings.permissionSet' }))
expect(screen.getByText('common.settings.permissionSetDescription')).toBeInTheDocument()
expect(screen.getByTestId('access-rules-page')).toBeInTheDocument()
// Language
@@ -73,4 +73,46 @@ describe('WorkspaceRoleCheckboxList', () => {
expect(screen.getByRole('radio', { name: /First role/i })).toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: /First role/i })).not.toBeInTheDocument()
})
it('should show legacy role descriptions when only one role is allowed', () => {
vi.mocked(useWorkspaceRoleList).mockReturnValue({
data: {
pages: [{
data: [
createRole({ id: 'admin', name: 'admin' }),
createRole({ id: 'editor', name: 'editor' }),
createRole({ id: 'normal', name: 'normal' }),
createRole({ id: 'dataset_operator', name: 'dataset_operator' }),
],
pagination: {
total_count: 4,
per_page: 20,
current_page: 1,
total_pages: 1,
},
}],
pageParams: [1],
},
isLoading: false,
error: null,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
} as unknown as ReturnType<typeof useWorkspaceRoleList>)
render(
<WorkspaceRoleCheckboxList
selectedRoleIds={['editor']}
selectedRoles={[createRole({ id: 'editor', name: 'editor' })]}
allowMultipleRoles={false}
onSelectedRolesChange={vi.fn()}
/>,
)
expect(screen.getByText('common.members.adminTip')).toBeInTheDocument()
expect(screen.getByText('common.members.editorTip')).toBeInTheDocument()
expect(screen.getByText('common.members.normalTip')).toBeInTheDocument()
expect(screen.getByText('common.members.datasetOperatorTip')).toBeInTheDocument()
expect(screen.queryByText('permission.role.noDescription')).not.toBeInTheDocument()
})
})
@@ -11,6 +11,8 @@ const expectedAppACLPermissionKeys = [
'app.acl.delete',
'app.acl.release_and_version',
'app.acl.monitor',
'app.acl.tracing_config',
'app.acl.log_and_annotation',
'app.acl.access_config',
]
@@ -6,8 +6,8 @@ export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
export const ACCOUNT_SETTING_TAB = {
PROVIDER: 'provider',
MEMBERS: 'members',
PERMISSIONS: 'permissions',
ACCESS_RULES: 'access-rules',
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
PERMISSION_SET: 'permission-set',
BILLING: 'billing',
DATA_SOURCE: 'data-source',
API_BASED_EXTENSION: 'custom-endpoint',
@@ -22,8 +22,8 @@ export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS
const WORKSPACE_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.MEMBERS,
ACCOUNT_SETTING_TAB.PERMISSIONS,
ACCOUNT_SETTING_TAB.ACCESS_RULES,
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
ACCOUNT_SETTING_TAB.PERMISSION_SET,
ACCOUNT_SETTING_TAB.BILLING,
ACCOUNT_SETTING_TAB.CUSTOM,
] as const
@@ -69,7 +69,6 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
usePluginSettingsAccess: () => ({
canSetPermissions: true,
canSetPluginPreferences: true,
canViewInstalledPlugins: true,
}),
default: () => ({
canSetPermissions: true,
@@ -96,7 +96,7 @@ vi.mock('@/utils/var', () => ({
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
usePluginSettingsAccess: () => ({
canManagePlugin: true,
canDeletePlugin: true,
canUpdatePlugin: true,
}),
}))
@@ -60,14 +60,13 @@ const DataSourcePage = ({
const [searchText, setSearchText] = useState('')
const {
canSetPluginPreferences,
canViewInstalledPlugins,
} = usePluginSettingsAccess()
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,
})
const { data, isLoading: isDataSourceListLoading } = useGetDataSourceListAuth()
const { data: installedPluginList } = useInstalledPluginList(!canViewInstalledPlugins)
const { data: installedPluginList } = useInstalledPluginList()
const pluginListWithLatestVersion = usePluginsWithLatestVersion(installedPluginList?.plugins)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const invalidateDataSourceListAuth = useInvalidDataSourceListAuth()
@@ -50,7 +50,7 @@ const DataSourcePluginActions = ({
const locale = useLocale()
const readmeTriggerId = useId()
const openReadmePanel = useReadmePanelStore(s => s.openReadmePanel)
const { canManagePlugin, canUpdatePlugin } = usePluginSettingsAccess()
const { canDeletePlugin, canUpdatePlugin } = usePluginSettingsAccess()
const detailHeaderState = usePluginDetailHeader(detail)
const {
modalStates,
@@ -69,7 +69,7 @@ const DataSourcePluginActions = ({
modalStates,
versionPicker,
isFromMarketplace,
canManagePlugin,
canDeletePlugin,
canUpdatePlugin,
onUpdate,
})
@@ -150,7 +150,7 @@ const DataSourcePluginActions = ({
detailUrl={getDetailUrl(detail, locale, theme || 'light')}
triggerSize="xs"
showCheckVersion={canUpdatePlugin}
showRemove={canManagePlugin}
showRemove={canDeletePlugin}
/>
<HeaderModals
detail={detail}
@@ -63,7 +63,7 @@ export default function AccountSetting({
const activeMenu = (() => {
if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.PREFERENCES
if ((normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSIONS || normalizedActiveTab === ACCOUNT_SETTING_TAB.ACCESS_RULES) && !canManageWorkspaceRoles)
if ((normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS || normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) && !canManageWorkspaceRoles)
return ACCOUNT_SETTING_TAB.MEMBERS
return normalizedActiveTab
})()
@@ -83,15 +83,15 @@ export default function AccountSetting({
activeIcon: <span className={cn('i-ri-group-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.PERMISSIONS,
key: ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
name: t('settings.rolesAndPermissions', { ns: 'common' }),
icon: <span className={cn('i-ri-shield-user-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-shield-user-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.ACCESS_RULES,
name: t('settings.resourceAccess', { ns: 'common' }),
description: t('settings.resourceAccessDescription', { ns: 'common' }),
key: ACCOUNT_SETTING_TAB.PERMISSION_SET,
name: t('settings.permissionSet', { ns: 'common' }),
description: t('settings.permissionSetDescription', { ns: 'common' }),
icon: <span className={cn('i-ri-lock-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-lock-2-fill', iconClassName)} />,
},
@@ -136,8 +136,8 @@ export default function AccountSetting({
visibleTabs.push(ACCOUNT_SETTING_TAB.MEMBERS)
if (canManageWorkspaceRoles) {
visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSIONS)
visibleTabs.push(ACCOUNT_SETTING_TAB.ACCESS_RULES)
visibleTabs.push(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS)
visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSION_SET)
}
if (canViewBilling)
@@ -262,8 +262,8 @@ export default function AccountSetting({
/>
)}
{activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && <MembersPage />}
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSIONS && <PermissionsPage containerRef={scrollContainerRef} />}
{activeMenu === ACCOUNT_SETTING_TAB.ACCESS_RULES && <AccessRulesPage />}
{activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && <PermissionsPage containerRef={scrollContainerRef} />}
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && <AccessRulesPage />}
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
@@ -224,6 +224,18 @@ describe('MembersPage', () => {
expect(screen.getByTestId('member-row-1').children[2])!.toHaveClass('min-w-0', 'grow')
})
it('should render plural roles column header when RBAC is enabled', () => {
renderWithSystemFeatures(<MembersPage />, {
systemFeatures: {
is_email_setup: true,
rbac_enabled: true,
},
})
expect(screen.getByText('common.members.roles', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('min-w-0', 'grow')
expect(screen.queryByText('common.members.role', { selector: '.system-xs-medium-uppercase' })).not.toBeInTheDocument()
})
it('should open and close invite modal', async () => {
const user = userEvent.setup()
@@ -2,7 +2,7 @@ import type { Role } from '@/models/access-control'
import type { Member } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
@@ -97,6 +97,32 @@ describe('MemberMenu', () => {
} as unknown as ReturnType<typeof useWorkspaceRoleList>)
})
it('should show edit role copy when multiple roles are disabled', async () => {
const user = userEvent.setup()
renderWithSystemFeatures(
<MemberMenu
member={member}
isCurrentUser={false}
allowMultipleRoles={false}
/>,
{
systemFeatures: {
rbac_enabled: false,
},
},
)
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
expect(screen.getByRole('menuitem', { name: /common\.members\.editRole/i })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: /common\.members\.assignRoles/i })).not.toBeInTheDocument()
await user.click(screen.getByRole('menuitem', { name: /common\.members\.editRole/i }))
expect(screen.getByRole('dialog', { name: /common\.members\.editRole/i })).toBeInTheDocument()
})
it('should submit only one selected role from the assign modal when RBAC is disabled', async () => {
const user = userEvent.setup()
@@ -114,7 +140,7 @@ describe('MemberMenu', () => {
)
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.assignRoles/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.editRole/i }))
await user.click(screen.getByRole('radio', { name: /Second role/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
@@ -124,7 +150,7 @@ describe('MemberMenu', () => {
}, expect.any(Object))
})
it('should refresh and invalidate members after removing a member', async () => {
it('should require confirmation before removing a member', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
const membersQueryKey = [...commonQueryKeys.members, 'en-US']
@@ -143,8 +169,18 @@ describe('MemberMenu', () => {
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i }))
expect(deleteMemberOrCancelInvitation).toHaveBeenCalledWith({
url: '/workspaces/current/members/member-1',
const dialog = screen.getByRole('alertdialog', {
name: /common\.members\.removeFromTeamConfirmTitle:\{"memberName":"Member User"\}/i,
})
expect(dialog).toHaveTextContent('common.members.removeFromTeamConfirmDescription')
expect(deleteMemberOrCancelInvitation).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: /common\.operation\.confirm/i }))
await waitFor(() => {
expect(deleteMemberOrCancelInvitation).toHaveBeenCalledWith({
url: '/workspaces/current/members/member-1',
})
})
expect(queryClient.getQueryState(membersQueryKey)?.isInvalidated).toBe(true)
expect(toast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
@@ -19,4 +19,14 @@ describe('RoleBadges', () => {
expect(screen.queryByTitle('Editor')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should keep the wrapper rendered when role names are empty', () => {
const { container } = render(<RoleBadges roleNames={[]} className="role-badges-empty" />)
const wrapper = container.querySelector('.role-badges-empty')
expect(wrapper).toBeInTheDocument()
expect(wrapper).toBeEmptyDOMElement()
})
})
})
@@ -49,6 +49,33 @@ describe('AssignRolesModal', () => {
})
describe('Role selection', () => {
it('should hide selected count when multiple roles are disabled', () => {
render(
<AssignRolesModal
selectedRoles={[roles[0]!]}
allowMultipleRoles={false}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(screen.queryByText(/common\.members\.assignRolesModal\.selectedCount/i)).not.toBeInTheDocument()
})
it('should show single-role description when multiple roles are disabled', () => {
render(
<AssignRolesModal
selectedRoles={[roles[0]!]}
allowMultipleRoles={false}
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByText(/common\.members\.assignRolesModal\.singleDescription/i)).toBeInTheDocument()
expect(screen.queryByText(/common\.members\.assignRolesModal\.description/i)).not.toBeInTheDocument()
})
it('should disable confirm when the last selected role is unchecked', async () => {
const user = userEvent.setup()
@@ -32,6 +32,19 @@ const AssignRolesModalBody = ({
const [selected, setSelected] = useState(selectedRoles)
const selectedRoleIds = selected.map(role => role.id)
const isConfirmDisabled = selected.length === 0
const title = allowMultipleRoles
? t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })
: t('members.editRole', { ns: 'common', defaultValue: 'Edit Role' })
const description = allowMultipleRoles
? t('members.assignRolesModal.description', {
ns: 'common',
defaultValue:
'Select roles to assign to this member. All permissions from selected roles will be combined.',
})
: t('members.assignRolesModal.singleDescription', {
ns: 'common',
defaultValue: 'Select one role to assign to this member.',
})
const handleConfirm = () => {
if (isConfirmDisabled)
@@ -50,14 +63,10 @@ const AssignRolesModalBody = ({
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })}
{title}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('members.assignRolesModal.description', {
ns: 'common',
defaultValue:
'Select roles to assign to this member. All permissions from selected roles will be combined.',
})}
{description}
</DialogDescription>
</div>
</div>
@@ -69,14 +78,16 @@ const AssignRolesModalBody = ({
onSelectedRolesChange={setSelected}
/>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('members.assignRolesModal.selectedCount', {
ns: 'common',
count: selected.length,
})}
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-3 border-t border-divider-subtle px-6 py-4">
{allowMultipleRoles && (
<div className="system-xs-regular text-text-tertiary">
{t('members.assignRolesModal.selectedCount', {
ns: 'common',
count: selected.length,
})}
</div>
)}
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
@@ -45,6 +45,9 @@ const MembersPage = () => {
const [detailsMember, setDetailsMember] = useState<Member | null>(null)
const canManageMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage')
const roleColumnLabel = systemFeatures.rbac_enabled
? t('members.roles', { ns: 'common' })
: t('members.role', { ns: 'common' })
const handleOpenDetails = useCallback((member: Member) => {
setDetailsMember(member)
@@ -151,7 +154,7 @@ const MembersPage = () => {
<div className="flex min-w-120 items-center border-b border-divider-regular py-1.75">
<div className="w-65 shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.name', { ns: 'common' })}</div>
<div className="w-30 shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('members.lastActive', { ns: 'common' })}</div>
<div className="min-w-0 grow px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
<div className="min-w-0 grow px-3 system-xs-medium-uppercase text-text-tertiary">{roleColumnLabel}</div>
</div>
<div className="relative min-w-120">
{accounts.map(account => (
@@ -114,6 +114,39 @@ describe('RoleSelector', () => {
expect(getRoleOption('Editor')).toHaveAttribute('aria-checked', 'true')
})
it('should show legacy descriptions for built-in roles without descriptions', async () => {
const user = userEvent.setup()
mockUseWorkspaceRoleList({
pages: [{
data: [
createRole({ id: 'admin', name: 'admin', description: '' }),
createRole({ id: 'editor', name: 'editor', description: '' }),
createRole({ id: 'normal', name: 'normal', description: '' }),
createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }),
],
pagination: {
total_count: 4,
per_page: 20,
current_page: 1,
total_pages: 1,
},
}],
})
render(<RoleSelectorWrapper initialRole="" />)
await user.click(getTrigger())
const roleMenu = getRoleMenu()
expect(within(roleMenu).getByText(/common\.members\.adminTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.editorTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.normalTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.datasetOperatorTip/i)).toBeInTheDocument()
expect(within(roleMenu).queryByText(/permission\.role\.noDescription/i)).not.toBeInTheDocument()
})
it('should update selected role name after user chooses a role', async () => {
const user = userEvent.setup()
@@ -1,3 +1,4 @@
import type { Role } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@@ -19,6 +20,28 @@ type RoleSelectorProps = {
}
const PAGE_SIZE = 20
const LEGACY_ROLE_DESCRIPTION_KEY_MAP = {
admin: 'members.adminTip',
editor: 'members.editorTip',
normal: 'members.normalTip',
dataset_operator: 'members.datasetOperatorTip',
} as const
type LegacyRoleKey = keyof typeof LEGACY_ROLE_DESCRIPTION_KEY_MAP
const normalizeLegacyRoleKey = (value: string) => value.trim().toLowerCase()
const isLegacyRoleKey = (value: string): value is LegacyRoleKey =>
Object.prototype.hasOwnProperty.call(LEGACY_ROLE_DESCRIPTION_KEY_MAP, value)
const getLegacyRoleDescriptionKey = (role: Role) => {
const candidateKeys = [
normalizeLegacyRoleKey(role.name),
normalizeLegacyRoleKey(role.id),
]
return candidateKeys.find(isLegacyRoleKey)
}
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
const { t } = useTranslation()
@@ -88,6 +111,26 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
setOpen(false)
}
const getRoleDescription = (role: Role) => {
if (role.description)
return role.description
const legacyRoleDescriptionKey = getLegacyRoleDescriptionKey(role)
switch (legacyRoleDescriptionKey) {
case 'admin':
return t('members.adminTip', { ns: 'common' })
case 'editor':
return t('members.editorTip', { ns: 'common' })
case 'normal':
return t('members.normalTip', { ns: 'common' })
case 'dataset_operator':
return t('members.datasetOperatorTip', { ns: 'common' })
}
return t('role.noDescription', { ns: 'permission' })
}
return (
<DropdownMenu
open={open}
@@ -141,7 +184,7 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
>
<div className="relative min-w-0 pl-5">
<div className="truncate text-sm leading-5 text-text-secondary">{role.name}</div>
<div className="line-clamp-2 text-xs leading-4.5 text-text-tertiary">{role.description || t('role.noDescription', { ns: 'permission' })}</div>
<div className="line-clamp-2 text-xs leading-4.5 text-text-tertiary">{getRoleDescription(role)}</div>
{value === role.id && (
<div
aria-hidden="true"
@@ -76,6 +76,39 @@ describe('MemberDetailsModal', () => {
})
describe('Rendering', () => {
it('should render edit role action when multiple roles are disabled', () => {
render(
<MemberDetailsModal
member={member}
canAssignRoles
allowMultipleRoles={false}
onClose={vi.fn()}
onAssignSubmit={vi.fn()}
/>,
)
const editButton = screen.getByRole('button', { name: /common\.operation\.edit/i })
expect(editButton).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /members\.memberDetails\.assign/i })).not.toBeInTheDocument()
expect(editButton.querySelector('.i-ri-edit-line')).toBeInTheDocument()
expect(editButton.querySelector('.i-ri-add-line')).not.toBeInTheDocument()
})
it('should render singular assigned role label when there is one role', () => {
render(
<MemberDetailsModal
member={member}
canAssignRoles
onClose={vi.fn()}
onAssignSubmit={vi.fn()}
/>,
)
expect(screen.getByText(/common\.members\.memberDetails\.assignedRole:/i)).toBeInTheDocument()
expect(screen.queryByText(/common\.members\.memberDetails\.assignedRoles/i)).not.toBeInTheDocument()
})
it('should render role loading state without assigned role chips or count', () => {
vi.mocked(useRolesOfMember).mockReturnValue({
data: undefined,
@@ -99,6 +132,26 @@ describe('MemberDetailsModal', () => {
})
describe('Role actions', () => {
it('should keep role chips readonly when multiple roles are disabled', async () => {
const user = userEvent.setup()
render(
<MemberDetailsModal
member={member}
canAssignRoles
allowMultipleRoles={false}
onClose={vi.fn()}
onAssignSubmit={vi.fn()}
/>,
)
expect(screen.queryByRole('button', { name: /Custom role/i })).not.toBeInTheDocument()
await user.click(screen.getByText('Custom role'))
expect(screen.queryByRole('menuitem', { name: /common\.operation\.remove/i })).not.toBeInTheDocument()
})
it('should not show role removal controls when role assignment is not allowed', () => {
render(
<MemberDetailsModal
@@ -190,7 +243,7 @@ describe('MemberDetailsModal', () => {
/>,
)
await user.click(screen.getByRole('button', { name: /members\.memberDetails\.assign/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.edit/i }))
await user.click(screen.getByRole('radio', { name: /Second role/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
@@ -45,6 +45,25 @@ const MemberDetailsModal = ({
const roles = useMemo(() => rolesOfMember?.roles ?? [], [rolesOfMember?.roles])
const selectedRoles = pendingRoles ?? roles
const selectedRoleIds = useMemo(() => selectedRoles.map(role => role.id), [selectedRoles])
const canRemoveRoles = canAssignRoles && allowMultipleRoles
const assignedRolesLabel = selectedRoleIds.length === 1
? t('members.memberDetails.assignedRole', {
ns: 'common',
defaultValue: 'Assigned Role',
})
: t('members.memberDetails.assignedRoles', {
ns: 'common',
defaultValue: 'Assigned Roles',
})
const assignActionIconClassName = allowMultipleRoles
? 'mr-0.5 i-ri-add-line h-3.5 w-3.5'
: 'mr-0.5 i-ri-edit-line h-3.5 w-3.5'
const assignActionLabel = allowMultipleRoles
? t('members.memberDetails.assign', {
ns: 'common',
defaultValue: 'Assign',
})
: t('operation.edit', { ns: 'common' })
const builtinRoles = useMemo(() => selectedRoles.filter(role => role.is_builtin), [selectedRoles])
const customRoles = useMemo(() => selectedRoles.filter(role => !role.is_builtin), [selectedRoles])
@@ -110,10 +129,7 @@ const MemberDetailsModal = ({
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 system-sm-semibold text-text-secondary">
<span>
{t('members.memberDetails.assignedRoles', {
ns: 'common',
defaultValue: 'Assigned Roles',
})}
{assignedRolesLabel}
</span>
{!isLoadingRolesOfMember && (
<span className="system-xs-medium text-text-tertiary">
@@ -129,12 +145,9 @@ const MemberDetailsModal = ({
>
<span
aria-hidden
className="mr-0.5 i-ri-add-line h-3.5 w-3.5"
className={assignActionIconClassName}
/>
{t('members.memberDetails.assign', {
ns: 'common',
defaultValue: 'Assign',
})}
{assignActionLabel}
</Button>
)}
</div>
@@ -162,7 +175,7 @@ const MemberDetailsModal = ({
label={role.name}
isOwner={role.role_tag === 'owner'}
permissionKeys={role.permission_keys}
onRemove={canAssignRoles ? handleRemove : undefined}
onRemove={canRemoveRoles ? handleRemove : undefined}
/>
))}
</div>
@@ -183,7 +196,7 @@ const MemberDetailsModal = ({
label={role.name}
isOwner={role.role_tag === 'owner'}
permissionKeys={role.permission_keys}
onRemove={canAssignRoles ? handleRemove : undefined}
onRemove={canRemoveRoles ? handleRemove : undefined}
/>
))}
</div>
@@ -1,6 +1,15 @@
'use client'
import type { Role } from '@/models/access-control'
import type { Member } from '@/models/common'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@@ -38,6 +47,8 @@ const MemberMenu = ({
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [assignModalOpen, setAssignModalOpen] = useState(false)
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false)
const [removing, setRemoving] = useState(false)
const isOwner = member.role === 'owner'
const canAssignRoles = !isOwner && !isCurrentUser
@@ -45,6 +56,10 @@ const MemberMenu = ({
const showTransferOwnership = isOwner && canTransferOwnership
const selectedRoles = member.roles || []
const memberName = member.name || member.email
const assignRolesLabel = allowMultipleRoles
? t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })
: t('members.editRole', { ns: 'common', defaultValue: 'Edit Role' })
const handleOpenAssignRoles = useCallback(() => {
setOpen(false)
@@ -68,15 +83,24 @@ const MemberMenu = ({
})
}, [allowMultipleRoles, member.id, t, updateRolesOfMember])
const handleRemove = useCallback(async () => {
const handleOpenRemoveConfirm = useCallback(() => {
setOpen(false)
setRemoveConfirmOpen(true)
}, [])
const handleRemove = useCallback(async () => {
setRemoving(true)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setRemoveConfirmOpen(false)
}
catch {
}
finally {
setRemoving(false)
}
}, [member.id, queryClient, t])
const handleTransferOwnership = useCallback(() => {
@@ -120,7 +144,7 @@ const MemberMenu = ({
className="system-sm-medium text-text-secondary"
onClick={handleOpenAssignRoles}
>
{t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })}
{assignRolesLabel}
</DropdownMenuItem>
)}
{showTransferOwnership && (
@@ -138,13 +162,34 @@ const MemberMenu = ({
<DropdownMenuItem
variant="destructive"
className="system-sm-medium"
onClick={handleRemove}
onClick={handleOpenRemoveConfirm}
>
{t('members.removeFromTeam', { ns: 'common' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={removeConfirmOpen} onOpenChange={open => !open && setRemoveConfirmOpen(false)}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('members.removeFromTeamConfirmTitle', { ns: 'common', memberName })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('members.removeFromTeamConfirmDescription', { ns: 'common' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={removing}
onClick={handleRemove}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{assignModalOpen && (
<AssignRolesModal
selectedRoles={selectedRoles}
@@ -29,9 +29,6 @@ type RoleBadgesProps = {
}
const RoleBadges = ({ roleNames, max = 2, className }: RoleBadgesProps) => {
if (!roleNames.length)
return null
const visible = roleNames.slice(0, max)
const overflow = roleNames.slice(max)
@@ -67,7 +67,6 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
usePluginSettingsAccess: () => ({
canSetPermissions: true,
canSetPluginPreferences: true,
canViewInstalledPlugins: true,
}),
default: () => ({
referenceSetting: {
@@ -151,7 +151,6 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
usePluginSettingsAccess: () => ({
canSetPermissions: true,
canSetPluginPreferences: true,
canViewInstalledPlugins: true,
}),
default: () => ({
referenceSetting: mockReferenceSetting,
@@ -50,7 +50,6 @@ const ModelProviderPage = ({
const { t } = useTranslation()
const {
canSetPluginPreferences,
canViewInstalledPlugins,
} = usePluginSettingsAccess()
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
@@ -65,7 +64,7 @@ const ModelProviderPage = ({
}, [providers])
const { data: installedPlugins } = useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
input: { body: { plugin_ids: allPluginIds } },
enabled: canViewInstalledPlugins && allPluginIds.length > 0,
enabled: allPluginIds.length > 0,
staleTime: 0,
}))
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
@@ -8,7 +8,7 @@ import { ConfigurationMethodEnum } from '../../declarations'
import ProviderAddedCard from '../index'
let mockIsCurrentWorkspaceManager = true
let mockWorkspacePermissionKeys: string[] = ['plugin.manage', 'credential.use', 'credential.create', 'credential.manage']
let mockWorkspacePermissionKeys: string[] = ['plugin.model_config', 'credential.use', 'credential.create', 'credential.manage']
const mockFetchModelProviderModels = vi.fn()
const mockQueryOptions = vi.fn(({ input, ...options }: { input: { params: { provider: string } }, enabled?: boolean }) => ({
queryKey: ['console', 'modelProviders', 'models', input.params.provider],
@@ -105,7 +105,7 @@ describe('ProviderAddedCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
mockWorkspacePermissionKeys = ['plugin.manage', 'credential.use', 'credential.create', 'credential.manage']
mockWorkspacePermissionKeys = ['plugin.model_config', 'credential.use', 'credential.create', 'credential.manage']
})
it('should render provider added card component', () => {
@@ -201,7 +201,7 @@ describe('ProviderAddedCard', () => {
expect(screen.getByText('common.modelProvider.configureTip')).toBeInTheDocument()
})
it('should render custom model actions when user can manage plugins', () => {
it('should render custom model actions when user can configure models', () => {
const customConfigProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
@@ -218,12 +218,12 @@ describe('ProviderAddedCard', () => {
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
})
it('should render custom model actions when user can manage plugins without credential permissions', () => {
it('should render custom model actions when user can configure models without credential permissions', () => {
const customConfigProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
mockWorkspacePermissionKeys = ['plugin.manage']
mockWorkspacePermissionKeys = ['plugin.model_config']
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
@@ -14,7 +14,7 @@ function createWrapper() {
let mockModelLoadBalancingEnabled = false
let mockPlanType: string = 'pro'
let mockWorkspacePermissionKeys: string[] = ['plugin.manage']
let mockWorkspacePermissionKeys: string[] = ['plugin.model_config']
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
@@ -71,7 +71,7 @@ describe('ModelListItem', () => {
vi.clearAllMocks()
mockModelLoadBalancingEnabled = false
mockPlanType = 'pro'
mockWorkspacePermissionKeys = ['plugin.manage']
mockWorkspacePermissionKeys = ['plugin.model_config']
})
it('should render model item with icon and name', () => {
@@ -144,8 +144,8 @@ describe('ModelListItem', () => {
expect(onModifyLoadBalancing).toHaveBeenCalledWith(mockModel)
})
it('should allow model status and load balancing controls with plugin.manage', () => {
mockWorkspacePermissionKeys = ['plugin.manage']
it('should allow model status and load balancing controls with plugin.model_config', () => {
mockWorkspacePermissionKeys = ['plugin.model_config']
mockModelLoadBalancingEnabled = true
render(
@@ -162,7 +162,7 @@ describe('ModelListItem', () => {
expect(screen.getByRole('button', { name: 'modify load balancing' })).toBeInTheDocument()
})
it('should hide model status and load balancing controls without plugin.manage', () => {
it('should hide model status and load balancing controls without plugin.model_config', () => {
mockWorkspacePermissionKeys = []
mockModelLoadBalancingEnabled = true

Some files were not shown because too many files have changed in this diff Show More