Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aa26fb637 | ||
|
|
1d2cc1e475 | ||
|
|
aa37c1d833 | ||
|
|
48dfbd60d6 | ||
|
|
e90c7ab8a7 | ||
|
|
40119fef44 | ||
|
|
72a03c2d6a | ||
|
|
affdc89f84 | ||
|
|
b33e8f0ddb | ||
|
|
8f74e176ca | ||
|
|
b9bcf31c72 | ||
|
|
abf2986299 | ||
|
|
599d92ef6b | ||
|
|
93dd955deb | ||
|
|
75909ce10e | ||
|
|
d93989bfc0 | ||
|
|
31a50a3b20 | ||
|
|
3d8316333f | ||
|
|
9fc2925b00 | ||
|
|
d349e892f4 | ||
|
|
2483c091aa | ||
|
|
a421362847 | ||
|
|
4964359961 | ||
|
|
1b81ac033f | ||
|
|
2eb564696e | ||
|
|
d87764b0f8 | ||
|
|
d135dab241 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+135
-107
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+11
-16
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
+22
-5
@@ -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)
|
||||
) {
|
||||
|
||||
+42
-4
@@ -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' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+21
-1
@@ -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'
|
||||
|
||||
+14
-1
@@ -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'))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+42
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
-1
@@ -69,7 +69,6 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
usePluginSettingsAccess: () => ({
|
||||
canSetPermissions: true,
|
||||
canSetPluginPreferences: true,
|
||||
canViewInstalledPlugins: true,
|
||||
}),
|
||||
default: () => ({
|
||||
canSetPermissions: true,
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
|
||||
+41
-5
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+27
@@ -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()
|
||||
|
||||
|
||||
+25
-14
@@ -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 => (
|
||||
|
||||
+33
@@ -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()
|
||||
|
||||
|
||||
+44
-1
@@ -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"
|
||||
|
||||
+54
-1
@@ -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 }))
|
||||
|
||||
+24
-11
@@ -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)
|
||||
|
||||
|
||||
-1
@@ -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)
|
||||
|
||||
+5
-5
@@ -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} />)
|
||||
|
||||
|
||||
+5
-5
@@ -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
Reference in New Issue
Block a user