Compare commits

..
Author SHA1 Message Date
yyhandGitHub b95d313128 refactor(web): improve workflow block selector (#39377) 2026-07-21 14:18:38 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
45ac70132c chore(i18n): sync translations with en-US (#39362)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-21 11:54:20 +00:00
JoelandGitHub 6e772259bb fix: update the agent roster documentation link (#39368) 2026-07-21 09:56:21 +00:00
QuantumGhostandGitHub af465fb527 fix(api): scope HITL snapshot message lookup to utilize db index (#39351) 2026-07-21 09:47:17 +00:00
Xiyuan ChenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
62bbc0dbeb feat(webapp-access): add WEBAPP_PUBLIC_ACCESS_ENABLED toggle (#38567)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 08:46:28 +00:00
zyssyz123andGitHub ed1289281d fix(agent): include workflow runs in monitoring stats (#39354) 2026-07-21 08:33:23 +00:00
Asuka MinatoandGitHub e37f32e4a0 test: add SQLite session fixture for providers (#38982) 2026-07-21 08:29:44 +00:00
Asuka MinatoandGitHub 150613cc05 test: use sqlite3 session in test_build_from_mapping (#38769) 2026-07-21 08:21:36 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a5194ff17b test: move human input delivery coverage to unit tests (#38926)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 08:18:32 +00:00
Asuka MinatoandGitHub cdaf5d4562 test: use sqlite3 session in test_tool_file_manager (#38771) 2026-07-21 08:10:23 +00:00
Asuka MinatoandGitHub 145b0e622e test: use sqlite3 session in test_workspace_account (#38757) 2026-07-21 08:07:57 +00:00
Asuka MinatoandGitHub 4bab62d458 test: use sqlite3 session in test_agent_app_parameters (#38759) 2026-07-21 07:59:00 +00:00
Asuka MinatoandGitHub b99f8941bb test: use sqlite3 session in test_database_retrieval (#38743) 2026-07-21 07:56:34 +00:00
Asuka MinatoandGitHub 32d2594877 test: move app import API coverage to unit tests (#38920) 2026-07-21 07:54:32 +00:00
Asuka MinatoandGitHub 7b188287da test: use sqlite3 session in test_accounts (#38752) 2026-07-21 07:50:05 +00:00
Asuka MinatoandGitHub f6db23444d test: move API token service coverage to unit tests (#38918) 2026-07-21 07:48:10 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a5c989d82b test: use sqlite3 session in test_conversation_service (#38774)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 07:38:53 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2622b1ce0a test: use sqlite3 session in test_based_generate_task_pipeline (#38738)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 07:37:20 +00:00
Asuka MinatoandGitHub 1e672530c4 test: use sqlite3 session in test_built_in_retrieval (#38735) 2026-07-21 07:36:42 +00:00
Asuka MinatoandGitHub 852169d99c test: use SQLite sessions in unit misc (#39118) 2026-07-21 07:27:13 +00:00
Asuka MinatoandGitHub d7860571aa test: use SQLite sessions in services workflow (#39115) 2026-07-21 07:24:52 +00:00
Asuka MinatoandGitHub 3683831738 test: use sqlite3 session in test_account_service (#38751) 2026-07-21 07:22:05 +00:00
非法操作andGitHub f5024f5440 fix(workflow): avoid false draft save error during Strict Mode initia… (#39353) 2026-07-21 07:11:19 +00:00
JoelGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4f8477427e fix: not support jump to detail in marketplace (#39347)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 07:01:00 +00:00
非法操作andGitHub 8d406aec31 fix(dev): use gevent WebSocket server for local API (#39349) 2026-07-21 06:42:55 +00:00
落尘andGitHub f8754286ec fix(watercrawl): handle non-json error responses (#37514) 2026-07-21 06:41:06 +00:00
yyhandGitHub 7da6b2ce36 refactor(web): remove permission catalog service hooks (#39342) 2026-07-21 06:01:00 +00:00
aa718add24 fix(api): return 400 instead of 500 when 'file' is missing from trial-app audio-to-text (#39305)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-07-21 05:59:48 +00:00
yyhandGitHub fcc1d39f5e fix(web): restore billing access boundary (#39343) 2026-07-21 05:35:36 +00:00
yyhandGitHub 861831f176 fix(api): keep workflow log archives owner-admin only (#39338) 2026-07-21 04:31:14 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d333aa31ea test: use SQLite sessions in services workflow (#39116)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 03:40:02 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
97e6a14c13 test: use SQLite sessions in unit misc (#39119)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-21 03:20:59 +00:00
317 changed files with 7943 additions and 5365 deletions
+6
View File
@@ -14,6 +14,12 @@ class EnterpriseFeatureConfig(BaseSettings):
default=False,
)
WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field(
description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, "
"no auth). Disable in security-sensitive on-prem deployments.",
default=True,
)
CAN_REPLACE_LOGO: bool = Field(
description="Allow customization of the enterprise logo.",
default=False,
+10 -27
View File
@@ -4,20 +4,16 @@ from http import HTTPStatus
from flask import redirect
from flask_restx import Resource
from pydantic import BaseModel, Field
from werkzeug.exceptions import Conflict, NotFound
from werkzeug.exceptions import Conflict, Forbidden, NotFound
from controllers.common.fields import RedirectResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
cloud_edition_billing_enabled,
cloud_edition_billing_paid_plan_required,
is_admin_or_owner_required,
only_edition_cloud,
rbac_permission_required,
setup_required,
)
from extensions.ext_database import db
@@ -25,6 +21,7 @@ from fields.base import ResponseModel
from libs.archive_storage import get_export_storage
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models import TenantAccountRole
from services.retention.workflow_run.archive_download_preparation import ARCHIVE_DOWNLOAD_MIME_TYPE
from services.retention.workflow_run.archive_download_task_cache import (
WorkflowRunArchiveDownloadStatus,
@@ -98,11 +95,13 @@ register_response_schema_models(
)
def _current_ids() -> tuple[str, str]:
"""Return current `(tenant_id, account_id)` or raise when no workspace is selected."""
def _current_owner_or_admin_ids() -> tuple[str, str]:
"""Return current Cloud workspace IDs for an owner or admin, independently of enterprise RBAC."""
current_user, current_tenant_id = current_account_with_tenant()
if not current_tenant_id:
raise NotFound("Current workspace not found")
if not TenantAccountRole.is_privileged_role(current_user.current_role):
raise Forbidden()
return current_tenant_id, current_user.id
@@ -124,12 +123,8 @@ class WorkflowRunArchivesApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self):
tenant_id, _ = _current_ids()
tenant_id, _ = _current_owner_or_admin_ids()
return dump_response(WorkflowRunArchiveListResponse, list_workflow_run_archives(db.session(), tenant_id))
@@ -149,12 +144,8 @@ class WorkflowRunArchiveDownloadsApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def post(self):
tenant_id, account_id = _current_ids()
tenant_id, account_id = _current_owner_or_admin_ids()
payload = WorkflowRunArchiveDownloadPayload.model_validate(console_ns.payload or {})
try:
task = create_workflow_run_archive_download_task(
@@ -180,12 +171,8 @@ class WorkflowRunArchiveDownloadApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self, download_id: str):
tenant_id, _ = _current_ids()
tenant_id, _ = _current_owner_or_admin_ids()
try:
task = get_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
@@ -209,12 +196,8 @@ class WorkflowRunArchiveDownloadFileApi(Resource):
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@is_admin_or_owner_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
def get(self, download_id: str):
tenant_id, _ = _current_ids()
tenant_id, _ = _current_owner_or_admin_ids()
try:
task = get_ready_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
+4 -1
View File
@@ -131,7 +131,10 @@ class WaterCrawlAPIClient(BaseAPIClient):
content_type = response.headers.get("Content-Type", "")
media_type = content_type.split(";", 1)[0].strip().lower()
if media_type == "application/json":
return response.json() or {}
try:
return response.json() or {}
except ValueError as exc:
raise ValueError("Invalid JSON response from WaterCrawl") from exc
if media_type == "application/octet-stream":
return response.content
@@ -1,5 +1,15 @@
"""WaterCrawl domain exceptions.
These exceptions are constructed from upstream HTTP responses, which may be
JSON API errors or plain text/HTML proxy errors. Keep the exception type stable
even when the body is not JSON so callers can handle WaterCrawl failures by
domain type instead of low-level parser errors.
"""
import json
from typing import override
from typing import Any, override
from httpx import Response
class WaterCrawlError(Exception):
@@ -7,11 +17,16 @@ class WaterCrawlError(Exception):
class WaterCrawlBadRequestError(WaterCrawlError):
def __init__(self, response):
def __init__(self, response: Response):
self.status_code = response.status_code
self.response = response
data = response.json()
self.message = data.get("message", "Unknown error occurred")
try:
data: Any = response.json()
except ValueError:
data = {}
if not isinstance(data, dict):
data = {}
self.message = data.get("message") or response.text or "Unknown error occurred"
self.errors = data.get("errors", {})
super().__init__(self.message)
+1
View File
@@ -22940,6 +22940,7 @@ in form definiton, or a variable while the workflow is running.
| ---- | ---- | ----------- | -------- |
| allow_email_code_login | boolean | | Yes |
| allow_email_password_login | boolean | | Yes |
| allow_public_access | boolean, <br>**Default:** true | | Yes |
| allow_sso | boolean | | Yes |
| enabled | boolean | | Yes |
| sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes |
+1
View File
@@ -1643,6 +1643,7 @@ in form definiton, or a variable while the workflow is running.
| ---- | ---- | ----------- | -------- |
| allow_email_code_login | boolean | | Yes |
| allow_email_password_login | boolean | | Yes |
| allow_public_access | boolean, <br>**Default:** true | | Yes |
| allow_sso | boolean | | Yes |
| enabled | boolean | | Yes |
| sso_config | [WebAppAuthSSOModel](#webappauthssomodel) | | Yes |
+35
View File
@@ -0,0 +1,35 @@
"""Shared database fixtures for provider tests.
Provider tests live outside ``tests/unit_tests`` and therefore cannot use that
suite's SQLite fixtures. Keep this fixture scoped to ``providers`` so each
provider package can exercise real SQLAlchemy queries without a service
database or mocked sessions.
"""
from collections.abc import Iterator
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from models.base import TypeBase
@pytest.fixture
def sqlite3_session(request: pytest.FixtureRequest) -> Iterator[Session]:
"""Yield an isolated SQLite session with the parametrized model tables.
Pass the required ORM classes through indirect parametrization. The engine
is per-test so committed rows and identity-map state cannot leak between
provider packages.
"""
models: tuple[type[TypeBase], ...] = request.param
engine = create_engine("sqlite:///:memory:")
tables = [model.metadata.tables[model.__tablename__] for model in models]
TypeBase.metadata.create_all(engine, tables=tables)
try:
with Session(engine, expire_on_commit=False) as session:
yield session
finally:
engine.dispose()
+227 -18
View File
@@ -9,12 +9,13 @@ import sqlalchemy as sa
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import aliased
from configs import dify_config
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp
from models.agent import WorkflowAgentNodeBinding
from models.enums import MessageStatus
from models.enums import CreatorUserRole, MessageStatus
from models.model import App, Conversation, Message
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun, WorkflowType
@dataclass(frozen=True)
@@ -580,9 +581,26 @@ class AgentObservabilityService:
def _load_daily_statistics(
self, *, app: App, agent_id: str, params: AgentStatisticsQueryParams, source_filter: AgentSourceFilter
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
if source_filter.kind in {"all", "webapp"}:
rows.extend(self._load_webapp_daily_statistics(app=app, params=params, source_filter=source_filter))
if source_filter.kind in {"all", "workflow"}:
rows.extend(
self._load_workflow_daily_statistics(
app=app,
agent_id=agent_id,
params=params,
source_filter=source_filter,
)
)
return self._merge_daily_statistics(rows)
def _load_webapp_daily_statistics(
self, *, app: App, params: AgentStatisticsQueryParams, source_filter: AgentSourceFilter
) -> list[dict[str, Any]]:
converted_created_at = convert_datetime_to_date("m.created_at")
message_scope = self._statistics_message_scope_sql(source_filter)
message_scope = self._statistics_webapp_message_scope_sql(source_filter)
sql_query = f"""SELECT
{converted_created_at} AS date,
COUNT(m.id) AS message_count,
@@ -602,12 +620,154 @@ WHERE
args: dict[str, Any] = {
"tz": params.timezone,
"app_id": app.id,
"tenant_id": app.tenant_id,
"agent_id": agent_id,
"debugger": InvokeFrom.DEBUGGER,
}
if source_filter.invoke_from is not None:
args["source"] = source_filter.invoke_from
if params.start:
sql_query += " AND m.created_at >= :start"
args["start"] = params.start
if params.end:
sql_query += " AND m.created_at < :end"
args["end"] = params.end
sql_query += " GROUP BY date ORDER BY date"
return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()]
@staticmethod
def _statistics_webapp_message_scope_sql(source_filter: AgentSourceFilter) -> str:
app_scope = "m.app_id = :app_id"
if source_filter.invoke_from is not None:
app_scope += " AND m.invoke_from = :source"
return app_scope
def _load_workflow_daily_statistics(
self,
*,
app: App,
agent_id: str,
params: AgentStatisticsQueryParams,
source_filter: AgentSourceFilter,
) -> list[dict[str, Any]]:
converted_run_created_at = convert_datetime_to_date("aru.created_at")
total_tokens = self._workflow_execution_metadata_numeric_sql(("total_tokens",), "BIGINT")
nested_total_tokens = self._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "total_tokens"), "BIGINT"
)
total_price = self._workflow_execution_metadata_numeric_sql(("total_price",), "DECIMAL(65, 30)")
nested_total_price = self._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "total_price"), "DECIMAL(65, 30)"
)
completion_tokens = self._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "completion_tokens"), "BIGINT"
)
binding_filters = self._statistics_workflow_binding_filters_sql(source_filter)
run_date_filters = ""
args: dict[str, Any] = {
"tz": params.timezone,
"tenant_id": app.tenant_id,
"agent_id": agent_id,
"chat_workflow_type": WorkflowType.CHAT,
"end_user_role": CreatorUserRole.END_USER,
}
if source_filter.app_id:
args["source_app_id"] = source_filter.app_id
if source_filter.workflow_id:
args["workflow_id"] = source_filter.workflow_id
if source_filter.workflow_version:
args["workflow_version"] = source_filter.workflow_version
if source_filter.node_id:
args["node_id"] = source_filter.node_id
if params.start:
run_date_filters += " AND wr.created_at >= :start"
args["start"] = params.start
if params.end:
run_date_filters += " AND wr.created_at < :end"
args["end"] = params.end
run_query = f"""WITH agent_run_usage AS (
SELECT
wr.id,
wr.created_by_role,
wr.created_by,
wr.created_at,
COALESCE(SUM(COALESCE({total_tokens}, {nested_total_tokens}, 0)), 0) AS token_count,
COALESCE(SUM(COALESCE({total_price}, {nested_total_price}, 0)), 0) AS total_price,
COALESCE(SUM(COALESCE(wne.elapsed_time, 0)), 0) AS latency,
COALESCE(SUM(COALESCE({completion_tokens}, 0)), 0) AS answer_tokens
FROM workflow_runs wr
JOIN workflow_agent_node_bindings wanb
ON wanb.tenant_id = :tenant_id
AND wanb.agent_id = :agent_id
AND wanb.app_id = wr.app_id
AND wanb.workflow_id = wr.workflow_id
AND wanb.workflow_version = wr.version
{binding_filters}
JOIN workflow_node_executions wne
ON wne.workflow_run_id = wr.id
AND wne.node_id = wanb.node_id
WHERE wr.type != :chat_workflow_type{run_date_filters}
GROUP BY wr.id, wr.created_by_role, wr.created_by, wr.created_at
)
SELECT
{converted_run_created_at} AS date,
COUNT(aru.id) AS message_count,
COUNT(aru.id) AS conversation_count,
COUNT(DISTINCT CASE
WHEN aru.created_by_role = :end_user_role THEN aru.created_by
ELSE NULL
END) AS end_user_count,
COALESCE(SUM(aru.token_count), 0) AS token_count,
COALESCE(SUM(aru.total_price), 0) AS total_price,
COALESCE(AVG(aru.latency), 0) AS avg_latency,
COALESCE(SUM(aru.latency), 0) AS latency_sum,
COALESCE(SUM(aru.answer_tokens), 0) AS answer_tokens,
0 AS like_count
FROM agent_run_usage aru
GROUP BY date
ORDER BY date"""
rows = [dict(row._mapping) for row in self._session.execute(sa.text(run_query), args).all()]
rows.extend(
self._load_workflow_chat_daily_context(
app=app,
agent_id=agent_id,
params=params,
source_filter=source_filter,
)
)
return self._merge_daily_statistics(rows)
def _load_workflow_chat_daily_context(
self,
*,
app: App,
agent_id: str,
params: AgentStatisticsQueryParams,
source_filter: AgentSourceFilter,
) -> list[dict[str, Any]]:
converted_created_at = convert_datetime_to_date("m.created_at")
workflow_scope = self._statistics_workflow_message_scope_sql(source_filter)
sql_query = f"""SELECT
{converted_created_at} AS date,
COUNT(m.id) AS message_count,
COUNT(DISTINCT m.conversation_id) AS conversation_count,
COUNT(DISTINCT m.from_end_user_id) AS end_user_count,
COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count,
COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price,
COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency,
COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum,
COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens,
COUNT(mf.id) AS like_count
FROM messages m
LEFT JOIN message_feedbacks mf
ON mf.message_id = m.id AND mf.rating = 'like'
WHERE
{workflow_scope}"""
args: dict[str, Any] = {
"tz": params.timezone,
"tenant_id": app.tenant_id,
"agent_id": agent_id,
"chat_workflow_type": WorkflowType.CHAT,
}
if source_filter.app_id:
args["source_app_id"] = source_filter.app_id
if source_filter.workflow_id:
@@ -627,10 +787,7 @@ WHERE
return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()]
@staticmethod
def _statistics_message_scope_sql(source_filter: AgentSourceFilter) -> str:
app_scope = "m.app_id = :app_id"
if source_filter.invoke_from is not None:
app_scope += " AND m.invoke_from = :source"
def _statistics_workflow_binding_filters_sql(source_filter: AgentSourceFilter) -> str:
workflow_binding_filters = []
if source_filter.app_id:
workflow_binding_filters.append("wanb.app_id = :source_app_id")
@@ -640,8 +797,12 @@ WHERE
workflow_binding_filters.append("wanb.workflow_version = :workflow_version")
if source_filter.node_id:
workflow_binding_filters.append("wanb.node_id = :node_id")
extra_workflow_filters = f"AND {' AND '.join(workflow_binding_filters)}" if workflow_binding_filters else ""
workflow_scope = f"""m.workflow_run_id IS NOT NULL
return f"AND {' AND '.join(workflow_binding_filters)}" if workflow_binding_filters else ""
@classmethod
def _statistics_workflow_message_scope_sql(cls, source_filter: AgentSourceFilter) -> str:
binding_filters = cls._statistics_workflow_binding_filters_sql(source_filter)
return f"""m.workflow_run_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM workflow_runs wr
@@ -651,17 +812,65 @@ WHERE
AND wanb.app_id = wr.app_id
AND wanb.workflow_id = wr.workflow_id
AND wanb.workflow_version = wr.version
{extra_workflow_filters}
{binding_filters}
JOIN workflow_node_executions wne
ON wne.workflow_run_id = wr.id
AND wne.node_id = wanb.node_id
WHERE wr.id = m.workflow_run_id
AND wr.type = :chat_workflow_type
)"""
if source_filter.kind == "webapp":
return app_scope
if source_filter.kind == "workflow":
return workflow_scope
return f"(({app_scope}) OR ({workflow_scope}))"
@staticmethod
def _workflow_execution_metadata_numeric_sql(path: tuple[str, ...], numeric_type: str) -> str:
if dify_config.DB_TYPE == "postgresql":
json_path = ",".join(path)
value = f"CAST(wne.execution_metadata AS JSONB) #>> '{{{json_path}}}'"
return f"CAST(NULLIF({value}, '') AS {numeric_type})"
if dify_config.DB_TYPE in {"mysql", "oceanbase", "seekdb"}:
json_path = "$." + ".".join(path)
mysql_numeric_type = "UNSIGNED" if numeric_type == "BIGINT" else numeric_type
value = f"JSON_UNQUOTE(JSON_EXTRACT(wne.execution_metadata, '{json_path}'))"
return f"CAST(NULLIF(NULLIF({value}, ''), 'null') AS {mysql_numeric_type})"
raise NotImplementedError(f"Unsupported database type: {dify_config.DB_TYPE}")
@staticmethod
def _merge_daily_statistics(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
merged: dict[Any, dict[str, Any]] = {}
weighted_latency: dict[Any, float] = {}
for row in rows:
date = row["date"]
target = merged.setdefault(
date,
{
"date": date,
"message_count": 0,
"conversation_count": 0,
"end_user_count": 0,
"token_count": 0,
"total_price": Decimal(0),
"avg_latency": 0.0,
"latency_sum": 0.0,
"answer_tokens": 0,
"like_count": 0,
},
)
message_count = int(row.get("message_count") or 0)
target["message_count"] += message_count
target["conversation_count"] += int(row.get("conversation_count") or 0)
target["end_user_count"] += int(row.get("end_user_count") or 0)
target["token_count"] += int(row.get("token_count") or 0)
target["total_price"] += Decimal(str(row.get("total_price") or 0))
target["latency_sum"] += float(row.get("latency_sum") or 0)
target["answer_tokens"] += int(row.get("answer_tokens") or 0)
target["like_count"] += int(row.get("like_count") or 0)
weighted_latency[date] = (
weighted_latency.get(date, 0.0) + float(row.get("avg_latency") or 0) * message_count
)
for date, row in merged.items():
message_count = int(row["message_count"])
row["avg_latency"] = weighted_latency[date] / message_count if message_count else 0.0
return sorted(merged.values(), key=lambda row: str(row["date"]))
@staticmethod
def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
-13
View File
@@ -317,9 +317,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"credential.use",
"credential.create",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app.acl.preview",
"app_library.access",
"app.create_and_management",
@@ -349,9 +346,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"credential.use",
"credential.create",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app_library.access",
"app.create_and_management",
"app.tag.manage",
@@ -377,9 +371,6 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"dataset.external.connect",
"snippets.create_and_modify",
"tool.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
]
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
@@ -387,9 +378,6 @@ _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] = [
@@ -805,7 +793,6 @@ class RBACService:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/role-permissions/catalog",
params={"billing_enabled": dify_config.BILLING_ENABLED},
tenant_id=tenant_id,
account_id=account_id,
)
+2
View File
@@ -100,6 +100,7 @@ class WebAppAuthModel(FeatureResponseModel):
sso_config: WebAppAuthSSOModel = WebAppAuthSSOModel()
allow_email_code_login: bool = False
allow_email_password_login: bool = False
allow_public_access: bool = True
class KnowledgePipeline(FeatureResponseModel):
@@ -286,6 +287,7 @@ class FeatureService:
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
@classmethod
+90 -11
View File
@@ -13,6 +13,7 @@ from sqlalchemy import desc, select
from sqlalchemy.orm import Session, sessionmaker
from core.app.apps.message_generator import MessageGenerator
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity
from core.app.entities.task_entities import (
HumanInputRequiredResponse,
MessageReplaceStreamResponse,
@@ -84,9 +85,6 @@ def build_workflow_event_stream(
topic = MessageGenerator.get_response_topic(app_mode, workflow_run.id)
workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(session_maker)
message_context = (
_get_message_context(session_maker, workflow_run.id) if app_mode == AppMode.ADVANCED_CHAT else None
)
pause_entity: WorkflowPauseEntity | None = None
if workflow_run.status == WorkflowExecutionStatus.PAUSED:
@@ -97,6 +95,38 @@ def build_workflow_event_stream(
pause_entity = None
resumption_context = _load_resumption_context(pause_entity)
message_context: MessageContext | None = None
if app_mode == AppMode.ADVANCED_CHAT:
if workflow_run.status == WorkflowExecutionStatus.PAUSED:
if resumption_context is None:
raise AssertionError(
"WorkflowResumptionContext is required for advanced-chat snapshot replay, "
f"workflow_run_id={workflow_run.id}"
)
generate_entity = resumption_context.get_generate_entity()
if not isinstance(generate_entity, AdvancedChatAppGenerateEntity):
raise AssertionError(
"AdvancedChatAppGenerateEntity is required for advanced-chat snapshot replay, "
f"workflow_run_id={workflow_run.id}, generate_entity_type={type(generate_entity).__name__}"
)
if not generate_entity.conversation_id:
raise AssertionError(
f"conversation_id is required for advanced-chat snapshot replay, workflow_run_id={workflow_run.id}"
)
message_context = _get_message_context_by_conversation(
session_maker,
conversation_id=generate_entity.conversation_id,
workflow_run_id=workflow_run.id,
)
else:
# Compatibility fallback for non-suspended snapshot requests. This app-scoped lookup is not optimal;
# a dedicated index or stronger lookup key would be preferable.
message_context = _get_message_context_by_app(
session_maker,
app_id=app_id,
workflow_run_id=workflow_run.id,
)
node_snapshots = node_execution_repo.get_execution_snapshots_by_workflow_run(
tenant_id=tenant_id,
app_id=app_id,
@@ -175,19 +205,68 @@ def build_workflow_event_stream(
return _generate()
def _get_message_context(session_maker: sessionmaker[Session], workflow_run_id: str) -> MessageContext | None:
def _get_message_context_by_conversation(
session_maker: sessionmaker[Session],
*,
conversation_id: str,
workflow_run_id: str,
) -> MessageContext | None:
"""Look up a paused or suspended Advanced Chat snapshot message by conversation and workflow run.
Use this exact lookup after recovering ``conversation_id`` from persisted resumption context. Its predicates match
``message_workflow_run_id_idx``.
"""
with session_maker() as session:
stmt = select(Message).where(Message.workflow_run_id == workflow_run_id).order_by(desc(Message.created_at))
stmt = (
select(Message)
.where(
Message.conversation_id == conversation_id,
Message.workflow_run_id == workflow_run_id,
)
.order_by(desc(Message.created_at))
.limit(1)
)
message = session.scalar(stmt)
if message is None:
return None
created_at = int(message.created_at.timestamp()) if message.created_at else 0
return MessageContext(
conversation_id=message.conversation_id,
message_id=message.id,
created_at=created_at,
answer=message.answer,
return _to_message_context(message)
def _get_message_context_by_app(
session_maker: sessionmaker[Session],
*,
app_id: str,
workflow_run_id: str,
) -> MessageContext | None:
"""Look up a non-suspended or running Advanced Chat reconnect snapshot by app and workflow run.
This compatibility path applies only when no resumption context is expected. The app-scoped query is not optimal;
a dedicated index or stronger lookup key would be preferable.
"""
with session_maker() as session:
stmt = (
select(Message)
.where(
Message.app_id == app_id,
Message.workflow_run_id == workflow_run_id,
)
.order_by(desc(Message.created_at))
.limit(1)
)
message = session.scalar(stmt)
if message is None:
return None
return _to_message_context(message)
def _to_message_context(message: Message) -> MessageContext:
created_at = int(message.created_at.timestamp()) if message.created_at else 0
return MessageContext(
conversation_id=message.conversation_id,
message_id=message.id,
created_at=created_at,
answer=message.answer,
)
def _load_resumption_context(pause_entity: WorkflowPauseEntity | None) -> WorkflowResumptionContext | None:
@@ -1,179 +0,0 @@
"""Testcontainers integration tests for controllers.console.app.app_import endpoints."""
from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from controllers.console.app import app_import as app_import_module
from services.app_dsl_service import ImportStatus
class _Result:
def __init__(self, status: ImportStatus, app_id: str | None = "app-1"):
self.status = status
self.app_id = app_id
def model_dump(self, mode: str = "json"):
return {"status": self.status, "app_id": self.app_id}
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=enabled))
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
class TestAppImportApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
def test_import_post_returns_failed_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED, app_id=None),
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
assert status == 400
assert response["status"] == ImportStatus.FAILED
def test_import_post_returns_pending_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.PENDING),
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
assert status == 202
assert response["status"] == ImportStatus.PENDING
def test_import_post_updates_webapp_auth_when_enabled(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=True)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"),
)
update_access = MagicMock()
monkeypatch.setattr(app_import_module.EnterpriseService.WebAppAuth, "update_app_access_mode", update_access)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
update_access.assert_called_once_with("app-123", "private")
assert status == 200
assert response["status"] == ImportStatus.COMPLETED
def test_import_post_commits_session_on_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"),
)
fake_session = MagicMock()
fake_session.__enter__.return_value = fake_session
fake_session.__exit__.return_value = None
monkeypatch.setattr(app_import_module, "Session", lambda *_args, **_kwargs: fake_session)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
fake_session.commit.assert_called_once_with()
fake_session.rollback.assert_not_called()
assert status == 200
assert response["status"] == ImportStatus.COMPLETED
def test_import_post_rolls_back_session_on_failure(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportApi()
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED, app_id=None),
)
fake_session = MagicMock()
fake_session.__enter__.return_value = fake_session
fake_session.__exit__.return_value = None
monkeypatch.setattr(app_import_module, "Session", lambda *_args, **_kwargs: fake_session)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
fake_session.rollback.assert_called_once_with()
fake_session.commit.assert_not_called()
assert status == 400
assert response["status"] == ImportStatus.FAILED
class TestAppImportConfirmApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
def test_import_confirm_returns_failed_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportConfirmApi()
method = unwrap(api.post)
monkeypatch.setattr(
app_import_module.AppDslService,
"confirm_import",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED),
)
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(api, SimpleNamespace(id="u1"), import_id="import-1")
assert status == 400
assert response["status"] == ImportStatus.FAILED
class TestAppImportCheckDependenciesApi:
@pytest.fixture
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
def test_import_check_dependencies_returns_result(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = app_import_module.AppImportCheckDependenciesApi()
method = unwrap(api.get)
monkeypatch.setattr(
app_import_module.AppDslService,
"check_dependencies",
lambda *_args, **_kwargs: SimpleNamespace(model_dump=lambda mode="json": {"leaked_dependencies": []}),
)
with app.test_request_context("/console/api/apps/imports/app-1/check-dependencies", method="GET"):
response, status = method(api, app_model=SimpleNamespace(id="app-1"))
assert status == 200
assert response["leaked_dependencies"] == []
@@ -1,23 +1,82 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from typing import Any
from uuid import NAMESPACE_URL, uuid5
import pytest
from sqlalchemy.orm import Session
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.model import AppAnnotationSetting
def _app_model(*, bound_agent_id: str | None, app_model_config=None):
def _stable_uuid(value: str) -> str:
return str(uuid5(NAMESPACE_URL, value))
def _app_model(*, tenant_id: str, bound_agent_id: str | None, app_model_config: object | None = None):
return SimpleNamespace(
id="app-1",
tenant_id="tenant-1",
id=_stable_uuid(f"app:{tenant_id}"),
tenant_id=tenant_id,
bound_agent_id=bound_agent_id,
app_model_config_with_session=lambda *, session: app_model_config,
)
def test_published_agent_app_parameters_use_soul_file_upload():
def _persist_agent(
session: Session,
*,
tenant_id: str,
agent_id: str,
active_config_snapshot_id: str | None,
active_config_is_published: bool,
) -> Agent:
agent = Agent(
id=agent_id,
tenant_id=tenant_id,
name="Agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id=active_config_snapshot_id,
active_config_is_published=active_config_is_published,
)
session.add(agent)
session.commit()
return agent
def _persist_snapshot(
session: Session,
*,
snapshot_id: str,
tenant_id: str,
agent_id: str,
config_snapshot: dict[str, Any],
) -> AgentConfigSnapshot:
snapshot = AgentConfigSnapshot(
id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
version=1,
config_snapshot=config_snapshot,
)
session.add(snapshot)
session.commit()
return snapshot
@pytest.mark.parametrize(
"sqlite_session",
[(Agent, AgentConfigSnapshot, AppAnnotationSetting)],
indirect=True,
)
def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:one")
agent_id = _stable_uuid("agent:one")
snapshot_id = _stable_uuid("snapshot:one")
app_model_config = SimpleNamespace(
to_dict=lambda **_kwargs: {
"opening_statement": "Hi from legacy presentation config",
@@ -27,14 +86,24 @@ def test_published_agent_app_parameters_use_soul_file_upload():
},
}
)
app_model = _app_model(bound_agent_id="agent-1", app_model_config=app_model_config)
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
app_model = _app_model(
tenant_id=tenant_id,
bound_agent_id=agent_id,
app_model_config=app_model_config,
)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=snapshot_id,
active_config_is_published=True,
)
snapshot = SimpleNamespace(
config_snapshot_dict={
_persist_snapshot(
sqlite_session,
snapshot_id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
config_snapshot={
"app_features": {
"file_upload": {
"enabled": True,
@@ -46,14 +115,12 @@ def test_published_agent_app_parameters_use_soul_file_upload():
}
},
"app_variables": [{"name": "topic", "type": "string", "required": True}],
}
},
)
session = MagicMock()
session.scalar.side_effect = [agent, snapshot, None]
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
app_model,
session=session,
session=sqlite_session,
)
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
@@ -69,20 +136,30 @@ def test_published_agent_app_parameters_use_soul_file_upload():
assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}]
def test_published_agent_app_parameters_requires_bound_agent():
app_model = _app_model(bound_agent_id=None)
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_bound_agent(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:unbound")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=None)
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=MagicMock())
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
def test_published_agent_app_parameters_requires_existing_active_agent():
app_model = _app_model(bound_agent_id="agent-1")
session = MagicMock()
session.scalar.return_value = None
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_session: Session):
requested_tenant_id = _stable_uuid("tenant:requested")
agent_id = _stable_uuid("agent:cross-tenant")
app_model = _app_model(tenant_id=requested_tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=_stable_uuid("tenant:other"),
agent_id=agent_id,
active_config_snapshot_id=None,
active_config_is_published=False,
)
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
@pytest.mark.parametrize(
@@ -92,68 +169,96 @@ def test_published_agent_app_parameters_requires_existing_active_agent():
False,
],
)
def test_published_agent_app_parameters_requires_published_agent(active_config_is_published):
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_published_agent(
active_config_is_published: bool, sqlite_session: Session
):
tenant_id = _stable_uuid(f"tenant:published:{active_config_is_published}")
agent_id = _stable_uuid(f"agent:published:{active_config_is_published}")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=None,
active_config_is_published=active_config_is_published,
)
session = MagicMock()
session.scalar.return_value = agent
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot():
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:unpublished-draft")
agent_id = _stable_uuid("agent:unpublished-draft")
snapshot_id = _stable_uuid("snapshot:unpublished-draft")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=snapshot_id,
active_config_is_published=False,
)
snapshot = SimpleNamespace(config_snapshot_dict={})
session = MagicMock()
session.scalar.side_effect = [agent, snapshot]
_persist_snapshot(
sqlite_session,
snapshot_id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
config_snapshot={},
)
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
app_model,
session=session,
session=sqlite_session,
)
assert features_dict["file_upload"]["enabled"] is True
assert user_input_form == []
def test_published_agent_app_parameters_requires_published_snapshot():
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_requires_published_snapshot(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:missing-snapshot")
agent_id = _stable_uuid("agent:missing-snapshot")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=_stable_uuid("snapshot:missing"),
active_config_is_published=True,
)
session = MagicMock()
session.scalar.side_effect = [agent, None]
with pytest.raises(AgentAppGeneratorError, match="published version not found"):
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
def test_published_agent_app_parameters_allows_missing_legacy_app_model_config():
app_model = _app_model(bound_agent_id="agent-1")
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(sqlite_session: Session):
tenant_id = _stable_uuid("tenant:no-legacy-config")
agent_id = _stable_uuid("agent:no-legacy-config")
snapshot_id = _stable_uuid("snapshot:no-legacy-config")
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
_persist_agent(
sqlite_session,
tenant_id=tenant_id,
agent_id=agent_id,
active_config_snapshot_id=snapshot_id,
active_config_is_published=True,
)
snapshot = SimpleNamespace(config_snapshot_dict={})
session = MagicMock()
session.scalar.side_effect = [agent, snapshot]
_persist_snapshot(
sqlite_session,
snapshot_id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
config_snapshot={},
)
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
app_model,
session=session,
session=sqlite_session,
)
assert features_dict["file_upload"] == {
@@ -2,15 +2,23 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from sqlalchemy import event
from sqlalchemy.orm import Session
from controllers.console.app import app_import as app_import_module
from models.account import Account
from models.engine import db
from models.model import App
from services.app_dsl_service import ImportStatus
from services.entities.dsl_entities import CheckDependenciesResult
from services.feature_service import SystemFeatureModel, WebAppAuthModel
def _unwrap(func):
@@ -38,17 +46,58 @@ class _Result:
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=enabled))
features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled))
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
def _mock_session(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
fake_session = MagicMock()
fake_session.__enter__.return_value = fake_session
fake_session.__exit__.return_value = None
monkeypatch.setattr(app_import_module, "db", SimpleNamespace(engine=object()))
monkeypatch.setattr(app_import_module, "Session", lambda *_args, **_kwargs: fake_session)
return fake_session
def _make_account(account_id: str = "u1") -> Account:
account = Account(name="Test User", email="test@example.com")
account.id = account_id
return account
@pytest.fixture
def app() -> Iterator[Flask]:
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
yield app
@dataclass
class TransactionEvents:
commits: int = 0
rollbacks: int = 0
@pytest.fixture
def transaction_events() -> TransactionEvents:
"""Observe transaction decisions while keeping the controller on a real SQLAlchemy session."""
observed = TransactionEvents()
def record_commit(_session: Session) -> None:
observed.commits += 1
def record_rollback(_session: Session) -> None:
observed.rollbacks += 1
event.listen(Session, "after_commit", record_commit)
event.listen(Session, "after_rollback", record_rollback)
try:
yield observed
finally:
event.remove(Session, "after_commit", record_commit)
event.remove(Session, "after_rollback", record_rollback)
def _failed_result_after_starting_transaction(
service: app_import_module.AppDslService, *, app_id: str | None = None
) -> _Result:
service._session.begin()
return _Result(ImportStatus.FAILED, app_id=app_id)
class TestAppImportApi:
@@ -57,33 +106,39 @@ class TestAppImportApi:
return app_import_module.AppImportApi()
def test_import_post_returns_failed_status_and_rolls_back(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED, app_id=None),
lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service, app_id=None),
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
response, status = method(api, _make_account())
session.rollback.assert_called_once_with()
session.commit.assert_not_called()
assert transaction_events.rollbacks == 1
assert transaction_events.commits == 0
assert status == 400
assert response["status"] == ImportStatus.FAILED
def test_import_post_returns_pending_status_and_commits(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
@@ -91,20 +146,23 @@ class TestAppImportApi:
)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
response, status = method(api, _make_account())
session.commit.assert_called_once_with()
session.rollback.assert_not_called()
assert transaction_events.commits == 1
assert transaction_events.rollbacks == 0
assert status == 202
assert response["status"] == ImportStatus.PENDING
def test_import_post_updates_webapp_auth_when_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
_install_features(monkeypatch, enabled=True)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"import_app",
@@ -114,25 +172,28 @@ class TestAppImportApi:
monkeypatch.setattr(app_import_module.EnterpriseService.WebAppAuth, "update_app_access_mode", update_access)
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method(api, SimpleNamespace(id="u1"))
response, status = method(api, _make_account())
session.commit.assert_called_once_with()
session.rollback.assert_not_called()
assert transaction_events.commits == 1
assert transaction_events.rollbacks == 0
update_access.assert_called_once_with("app-123", "private")
assert status == 200
assert response["status"] == ImportStatus.COMPLETED
def test_import_post_attaches_permission_keys_when_creating_new_app_and_rbac_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
@@ -149,21 +210,24 @@ class TestAppImportApi:
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
response, status = method()
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_import_post_does_not_attach_permission_keys_when_overwriting_existing_app(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
_install_features(monkeypatch, enabled=False)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
@@ -184,7 +248,7 @@ class TestAppImportApi:
):
response, status = method()
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == []
@@ -195,35 +259,41 @@ class TestAppImportConfirmApi:
return app_import_module.AppImportConfirmApi()
def test_import_confirm_returns_failed_status_and_rolls_back(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = unwrap(api.post)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module.AppDslService,
"confirm_import",
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED),
lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service),
)
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(api, SimpleNamespace(id="u1"), import_id="import-1")
response, status = method(api, _make_account(), import_id="import-1")
session.rollback.assert_called_once_with()
session.commit.assert_not_called()
assert transaction_events.rollbacks == 1
assert transaction_events.commits == 0
assert status == 400
assert response["status"] == ImportStatus.FAILED
def test_import_confirm_attaches_permission_keys_when_creating_new_app_and_rbac_enabled(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(
app_import_module.redis_client,
@@ -248,20 +318,23 @@ class TestAppImportConfirmApi:
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(import_id="import-1")
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_import_confirm_does_not_attach_permission_keys_when_overwriting_existing_app(
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
self,
api,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
transaction_events: TransactionEvents,
) -> None:
method = _unwrap(api.post)
session = _mock_session(monkeypatch)
monkeypatch.setattr(
app_import_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
lambda: (_make_account(), "tenant-1"),
)
monkeypatch.setattr(
app_import_module.redis_client,
@@ -286,6 +359,27 @@ class TestAppImportConfirmApi:
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
response, status = method(import_id="import-1")
session.commit.assert_called_once_with()
assert transaction_events.commits == 1
assert status == 200
assert response["permission_keys"] == []
class TestAppImportCheckDependenciesApi:
def test_import_check_dependencies_returns_result(
self,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
) -> None:
api = app_import_module.AppImportCheckDependenciesApi()
method = unwrap(api.get)
monkeypatch.setattr(
app_import_module.AppDslService,
"check_dependencies",
lambda *_args, **_kwargs: CheckDependenciesResult(leaked_dependencies=[]),
)
with app.test_request_context("/console/api/apps/imports/app-1/check-dependencies", method="GET"):
response, status = method(api, app_model=App(id="app-1"))
assert status == 200
assert response["leaked_dependencies"] == []
@@ -7,10 +7,28 @@ from unittest.mock import MagicMock
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from controllers.console.app import generator as generator_module
from controllers.console.app.error import ProviderNotInitializeError
from core.errors.error import ProviderTokenNotInitError
from models.model import App, AppMode
def _persist_app(session: Session, *, tenant_id: str = "t1") -> App:
app_model = App(
id="app-1",
tenant_id=tenant_id,
name="Workflow App",
description="",
mode=AppMode.WORKFLOW,
enable_site=False,
enable_api=False,
max_active_requests=None,
)
session.add(app_model)
session.commit()
return app_model
def _model_config_payload():
@@ -66,12 +84,11 @@ def test_rule_code_generate_maps_token_error(app: Flask, monkeypatch: pytest.Mon
method(api, "t1")
def test_instruction_generate_app_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_app_not_found(app: Flask, sqlite_session: Session) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
session = MagicMock()
session.scalar.return_value = None
_persist_app(sqlite_session, tenant_id="other-tenant")
with app.test_request_context(
"/console/api/instruction-generate",
@@ -83,25 +100,21 @@ def test_instruction_generate_app_not_found(app: Flask, monkeypatch: pytest.Monk
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "app app-1 not found"
stmt = session.scalar.call_args.args[0]
compiled = stmt.compile()
statement = str(compiled)
assert "apps.id" in statement
assert "apps.tenant_id" in statement
assert "app-1" in compiled.params.values()
assert "t1" in compiled.params.values()
assert sqlite_session.get(App, "app-1") is not None
def test_instruction_generate_workflow_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_workflow_not_found(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
app_model = _persist_app(sqlite_session)
_install_workflow_service(monkeypatch, workflow=None)
with app.test_request_context(
@@ -114,18 +127,20 @@ def test_instruction_generate_workflow_not_found(app: Flask, monkeypatch: pytest
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "workflow app-1 not found"
def test_instruction_generate_node_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_node_missing(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
app_model = _persist_app(sqlite_session)
workflow = SimpleNamespace(graph_dict={"nodes": []})
_install_workflow_service(monkeypatch, workflow=workflow)
@@ -140,18 +155,18 @@ def test_instruction_generate_node_missing(app: Flask, monkeypatch: pytest.Monke
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "node node-1 not found"
def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
app_model = _persist_app(sqlite_session)
workflow = SimpleNamespace(
graph_dict={
@@ -173,18 +188,19 @@ def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPa
"model_config": _model_config_payload(),
},
):
response = method(api, session, "t1")
response = method(api, sqlite_session, "t1")
assert response == {"code": "x"}
assert workflow_service.app_model is app_model
assert workflow_service.session is session
assert workflow_service.session is sqlite_session
def test_instruction_generate_legacy_modify(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_legacy_modify(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
session = SimpleNamespace()
monkeypatch.setattr(
generator_module.LLMGenerator,
"instruction_modify_legacy",
@@ -202,16 +218,15 @@ def test_instruction_generate_legacy_modify(app: Flask, monkeypatch: pytest.Monk
"model_config": _model_config_payload(),
},
):
response = method(api, session, "t1")
response = method(api, sqlite_session, "t1")
assert response == {"instruction": "ok"}
def test_instruction_generate_incompatible_params(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_instruction_generate_incompatible_params(app: Flask, sqlite_session: Session) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
session = SimpleNamespace()
with app.test_request_context(
"/console/api/instruction-generate",
method="POST",
@@ -223,7 +238,7 @@ def test_instruction_generate_incompatible_params(app: Flask, monkeypatch: pytes
"model_config": _model_config_payload(),
},
):
response, status = method(api, session, "t1")
response, status = method(api, sqlite_session, "t1")
assert status == 400
assert response["error"] == "incompatible parameters"
@@ -838,6 +838,29 @@ class TestTrialChatAudioApi:
with pytest.raises(module.NoAudioUploadedError):
method(api, account, trial_app_chat)
def test_missing_file_field_returns_400(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
"""A multipart POST with no `file` field must surface as 400, not 500.
Verifies the controller passes file=None to AudioService.transcript_asr
instead of raising a KeyError that would yield HTTP 500.
"""
def fake_asr(*args, **kwargs):
assert kwargs["file"] is None
raise module.services.errors.audio.NoAudioUploadedServiceError()
api = module.TrialChatAudioApi()
method = unwrap(api.post)
with (
app.test_request_context("/", method="POST", data={}, content_type="multipart/form-data"),
patch.object(module.AudioService, "transcript_asr", side_effect=fake_asr),
):
with pytest.raises(module.NoAudioUploadedError) as exc_info:
method(api, account, trial_app_chat)
assert exc_info.value.code == 400
def test_audio_too_large(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
api = module.TrialChatAudioApi()
method = unwrap(api.post)
@@ -1,11 +1,77 @@
import pytest
from types import SimpleNamespace
import pytest
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.console import workflow_run_archive
from controllers.console.workflow_run_archive import (
WorkflowRunArchiveDownloadApi,
WorkflowRunArchiveDownloadFileApi,
WorkflowRunArchiveDownloadsApi,
WorkflowRunArchivesApi,
)
from models import TenantAccountRole
@pytest.mark.parametrize(
"role",
[TenantAccountRole.EDITOR, TenantAccountRole.NORMAL, TenantAccountRole.DATASET_OPERATOR],
)
@pytest.mark.parametrize("rbac_enabled", [False, True])
def test_current_owner_or_admin_ids_rejects_non_manager(
monkeypatch: pytest.MonkeyPatch, role: TenantAccountRole, rbac_enabled: bool
) -> None:
current_user = SimpleNamespace(id="account-1", current_role=role)
monkeypatch.setattr(dify_config, "RBAC_ENABLED", rbac_enabled)
monkeypatch.setattr(
workflow_run_archive,
"current_account_with_tenant",
lambda: (current_user, "tenant-1"),
)
with pytest.raises(Forbidden):
workflow_run_archive._current_owner_or_admin_ids()
@pytest.mark.parametrize("role", [TenantAccountRole.OWNER, TenantAccountRole.ADMIN])
@pytest.mark.parametrize("rbac_enabled", [False, True])
def test_current_owner_or_admin_ids_returns_current_ids_for_manager(
monkeypatch: pytest.MonkeyPatch, role: TenantAccountRole, rbac_enabled: bool
) -> None:
current_user = SimpleNamespace(id="account-1", current_role=role)
monkeypatch.setattr(dify_config, "RBAC_ENABLED", rbac_enabled)
monkeypatch.setattr(
workflow_run_archive,
"current_account_with_tenant",
lambda: (current_user, "tenant-1"),
)
assert workflow_run_archive._current_owner_or_admin_ids() == ("tenant-1", "account-1")
@pytest.mark.parametrize(
("method", "args"),
[
(WorkflowRunArchivesApi.get, ()),
(WorkflowRunArchiveDownloadsApi.post, ()),
(WorkflowRunArchiveDownloadApi.get, ("download-1",)),
(WorkflowRunArchiveDownloadFileApi.get, ("download-1",)),
],
)
def test_workflow_run_archive_endpoints_enforce_fixed_workspace_roles(
monkeypatch: pytest.MonkeyPatch, method, args: tuple[str, ...]
) -> None:
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
def reject_non_manager() -> tuple[str, str]:
raise Forbidden()
monkeypatch.setattr(workflow_run_archive, "_current_owner_or_admin_ids", reject_non_manager)
with pytest.raises(Forbidden):
method(None, *args)
@pytest.mark.parametrize(
@@ -28,3 +94,4 @@ def test_workflow_run_archive_endpoints_require_cloud_paid_plan(method) -> None:
"cloud_edition_billing_enabled",
"cloud_edition_billing_paid_plan_required",
} <= decorator_names
assert "rbac_permission_required" not in decorator_names
@@ -1,9 +1,12 @@
import inspect
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
from unittest.mock import MagicMock, patch
from uuid import NAMESPACE_URL, uuid5
import pytest
from flask import Flask
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
@@ -12,7 +15,8 @@ from controllers.console.workspace.account import (
ChangeEmailSendEmailApi,
CheckEmailUnique,
)
from models import Account, AccountStatus, Tenant
from models import Account, AccountIntegrate, AccountStatus, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
from services.account_service import AccountService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
@@ -45,6 +49,38 @@ def _build_account(email: str, account_id: str = "acc", tenant: Tenant | None =
return account
def _stable_uuid(value: str) -> str:
return str(uuid5(NAMESPACE_URL, value))
def _persist_account_with_tenant(session: Session, email: str, account_name: str = "account") -> tuple[Account, Tenant]:
tenant = Tenant(name=f"{account_name} tenant")
tenant.id = _stable_uuid(f"tenant:{account_name}")
account = Account(name=account_name, email=email, status=AccountStatus.ACTIVE)
account.id = _stable_uuid(f"account:{account_name}")
membership = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
session.add_all([account, tenant, membership])
session.commit()
account._current_tenant = tenant
account.role = TenantAccountRole.OWNER
return account, tenant
@contextmanager
def _bind_database_session(session: Session):
database_session = scoped_session(sessionmaker(bind=session.get_bind(), expire_on_commit=False))
try:
with patch("extensions.ext_database.db.session", database_session):
yield database_session
finally:
database_session.remove()
def _build_change_email_token(
phase: str,
*,
@@ -387,47 +423,64 @@ class TestChangeEmailValidity:
class TestChangeEmailReset:
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, AccountIntegrate)],
indirect=True,
)
def test_should_normalize_new_email_before_update(
self,
mock_is_freeze: MagicMock,
mock_check_unique: MagicMock,
mock_get_data: MagicMock,
mock_revoke_token: MagicMock,
mock_update_account: MagicMock,
mock_send_notify: MagicMock,
app: Flask,
sqlite_session: Session,
):
current_user = _build_account("old@example.com", "acc3")
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id="acc3",
email="new@example.com",
old_email="OLD@example.com",
)
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
mock_update_account.return_value = mock_account_after_update
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "New@Example.com", "token": "token-123"},
):
api = ChangeEmailResetApi()
method = inspect.unwrap(api.post)
method(api, current_user)
with _bind_database_session(sqlite_session) as database_session:
current_user, _ = _persist_account_with_tenant(
database_session(),
"old@example.com",
"email-reset-account",
)
account_integration = AccountIntegrate(
account_id=current_user.id,
provider="google",
open_id="google-user",
encrypted_token="encrypted-token",
)
database_session.add(account_integration)
database_session.commit()
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id=current_user.id,
email="new@example.com",
old_email="OLD@example.com",
)
mock_is_freeze.assert_called_once_with("new@example.com")
mock_check_unique.assert_called_once_with("new@example.com", session=ANY)
mock_revoke_token.assert_called_once_with("token-123")
mock_update_account.assert_called_once_with(current_user, email="new@example.com", session=ANY)
mock_send_notify.assert_called_once_with(email="new@example.com")
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "New@Example.com", "token": "token-123"},
):
api = ChangeEmailResetApi()
method = inspect.unwrap(api.post)
response = method(api, current_user)
sqlite_session.expire_all()
persisted_account = sqlite_session.get(Account, current_user.id)
assert response["email"] == "new@example.com"
assert persisted_account is not None
assert persisted_account.email == "new@example.com"
assert sqlite_session.get(AccountIntegrate, account_integration.id) is None
mock_is_freeze.assert_called_once_with("new@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_send_notify.assert_called_once_with(email="new@example.com")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@@ -663,36 +716,47 @@ class TestAccountDeletionFeedback:
class TestCheckEmailUnique:
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
def test_should_normalize_email(self, mock_is_freeze: MagicMock, mock_check_unique: MagicMock, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin)],
indirect=True,
)
def test_should_normalize_email(
self,
mock_is_freeze: MagicMock,
app: Flask,
sqlite_session: Session,
):
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
with app.test_request_context(
"/account/change-email/check-email-unique",
method="POST",
json={"email": "Case@Test.com"},
):
api = CheckEmailUnique()
method = inspect.unwrap(api.post)
response = method(api)
with _bind_database_session(sqlite_session) as database_session:
_persist_account_with_tenant(database_session(), "different@test.com", "uniqueness-account")
with app.test_request_context(
"/account/change-email/check-email-unique",
method="POST",
json={"email": "Case@Test.com"},
):
api = CheckEmailUnique()
method = inspect.unwrap(api.post)
response = method(api)
assert response == {"result": "success"}
mock_is_freeze.assert_called_once_with("case@test.com")
mock_check_unique.assert_called_once_with("case@test.com", session=ANY)
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup():
mock_session = MagicMock()
first = MagicMock()
first.scalar_one_or_none.return_value = None
second = MagicMock()
expected_account = MagicMock()
second.scalar_one_or_none.return_value = expected_account
mock_session.execute.side_effect = [first, second]
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin)],
indirect=True,
)
def test_get_account_by_email_with_case_fallback_uses_lowercase_lookup(sqlite_session: Session):
expected_account, _ = _persist_account_with_tenant(
sqlite_session,
"mixed@test.com",
"case-fallback-account",
)
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=mock_session)
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=sqlite_session)
assert result is expected_account
assert mock_session.execute.call_count == 2
@@ -1,8 +1,11 @@
import inspect
from datetime import UTC, datetime
from unittest.mock import MagicMock, PropertyMock, patch
from uuid import NAMESPACE_URL, uuid5
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from controllers.console import console_ns
@@ -32,15 +35,17 @@ from controllers.console.workspace.error import (
CurrentPasswordIncorrectError,
InvalidAccountDeletionCodeError,
)
from models import Account
from models.account import AccountStatus
from extensions.storage.storage_type import StorageType
from models import Account, AccountIntegrate, InvitationCode, Tenant, TenantAccountJoin
from models.account import AccountStatus, InvitationCodeStatus, TenantAccountRole
from models.enums import CreatorUserRole
from models.model import UploadFile
from services.errors.account import CurrentPasswordIncorrectError as ServicePwdError
def make_account(account_id: str = "u1", *, status: AccountStatus = AccountStatus.ACTIVE) -> Account:
account = Account(name="John", email=f"{account_id}@test.com", status=status)
account.id = account_id
account.id = str(uuid5(NAMESPACE_URL, f"account:{account_id}"))
account.avatar = "avatar.png"
account.interface_language = "en-US"
account.interface_theme = "light"
@@ -49,12 +54,62 @@ def make_account(account_id: str = "u1", *, status: AccountStatus = AccountStatu
return account
def persist_account_with_tenant(
session: Session,
account_id: str = "u1",
*,
status: AccountStatus = AccountStatus.ACTIVE,
tenant_id: str = "tenant-1",
) -> tuple[Account, Tenant]:
account = make_account(account_id, status=status)
tenant = Tenant(name=tenant_id)
tenant.id = str(uuid5(NAMESPACE_URL, f"tenant:{tenant_id}"))
membership = TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
session.add_all([account, tenant, membership])
session.commit()
account._current_tenant = tenant
account.role = TenantAccountRole.OWNER
return account, tenant
def make_upload_file(*, tenant_id: str, created_by: str) -> UploadFile:
return UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key="avatar.png",
name="avatar.png",
size=128,
extension="png",
mime_type="image/png",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=created_by,
created_at=datetime.now(UTC).replace(tzinfo=None),
used=False,
)
class TestAccountInitApi:
def test_init_success(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, InvitationCode)],
indirect=True,
)
def test_init_success(self, app: Flask, sqlite_session: Session):
api = AccountInitApi()
method = inspect.unwrap(api.post)
account = make_account(status=AccountStatus.UNINITIALIZED)
account, tenant = persist_account_with_tenant(
sqlite_session,
status=AccountStatus.UNINITIALIZED,
)
invitation_code = InvitationCode(batch="batch-1", code="code123")
sqlite_session.add(invitation_code)
sqlite_session.commit()
payload = {
"interface_language": "en-US",
"timezone": "UTC",
@@ -63,14 +118,24 @@ class TestAccountInitApi:
with (
app.test_request_context("/account/init", json=payload),
patch("controllers.console.workspace.account.db.session.commit", return_value=None),
patch("controllers.console.workspace.account.dify_config.EDITION", "CLOUD"),
patch("controllers.console.workspace.account.db.session.scalar") as scalar_mock,
patch("controllers.console.workspace.account.db.session", sqlite_session),
):
scalar_mock.return_value = MagicMock(status="unused")
resp = method(api, account)
assert resp["result"] == "success"
sqlite_session.expire_all()
persisted_account = sqlite_session.get(Account, account.id)
persisted_invitation = sqlite_session.get(InvitationCode, invitation_code.id)
assert persisted_account is not None
assert persisted_account.status == AccountStatus.ACTIVE
assert persisted_account.interface_language == "en-US"
assert persisted_account.timezone == "UTC"
assert persisted_account.initialized_at is not None
assert persisted_invitation is not None
assert persisted_invitation.status == InvitationCodeStatus.USED
assert persisted_invitation.used_by_account_id == account.id
assert persisted_invitation.used_by_tenant_id == tenant.id
def test_init_already_initialized(self, app: Flask):
api = AccountInitApi()
@@ -93,7 +158,7 @@ class TestAccountProfileApi:
with app.test_request_context("/account/profile"):
result = method(api, user)
assert result["id"] == "u1"
assert result["id"] == user.id
class TestAccountUpdateApis:
@@ -119,29 +184,33 @@ class TestAccountUpdateApis:
):
result = method(api, user)
assert result["id"] == "u1"
assert result["id"] == user.id
class TestAccountAvatarApiGet:
"""GET /account/avatar must not sign arbitrary upload_file IDs (IDOR)."""
def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, UploadFile)],
indirect=True,
)
def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app: Flask, sqlite_session: Session):
api = AccountAvatarApi()
method = inspect.unwrap(api.get)
user = make_account("acc-owner")
tenant_id = "tenant-1"
user, tenant = persist_account_with_tenant(sqlite_session, "acc-owner")
tenant_id = tenant.id
file_id = "550e8400-e29b-41d4-a716-446655440000"
upload_file = MagicMock()
upload_file = make_upload_file(tenant_id=tenant_id, created_by=user.id)
upload_file.id = file_id
upload_file.tenant_id = tenant_id
upload_file.created_by = user.id
upload_file.created_by_role = CreatorUserRole.ACCOUNT
sqlite_session.add(upload_file)
sqlite_session.commit()
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch("controllers.console.workspace.account.db.session", sqlite_session),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/example",
@@ -152,23 +221,35 @@ class TestAccountAvatarApiGet:
assert result == {"avatar_url": "https://signed/example"}
sign_mock.assert_called_once_with(upload_file_id=file_id)
def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, UploadFile)],
indirect=True,
)
def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(
self, app: Flask, sqlite_session: Session
):
api = AccountAvatarApi()
method = inspect.unwrap(api.get)
user = make_account("acc-a")
tenant_id = "tenant-1"
user, tenant = persist_account_with_tenant(sqlite_session, "acc-a")
tenant_id = tenant.id
file_id = "550e8400-e29b-41d4-a716-446655440001"
upload_file = MagicMock()
other_account = make_account("acc-b")
other_membership = TenantAccountJoin(
tenant_id=tenant_id,
account_id=other_account.id,
role=TenantAccountRole.NORMAL,
)
upload_file = make_upload_file(tenant_id=tenant_id, created_by=other_account.id)
upload_file.id = file_id
upload_file.tenant_id = tenant_id
upload_file.created_by = "acc-b"
upload_file.created_by_role = CreatorUserRole.ACCOUNT
sqlite_session.add_all([other_account, other_membership, upload_file])
sqlite_session.commit()
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch("controllers.console.workspace.account.db.session", sqlite_session),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/leak",
@@ -179,23 +260,29 @@ class TestAccountAvatarApiGet:
sign_mock.assert_not_called()
def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, UploadFile)],
indirect=True,
)
def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app: Flask, sqlite_session: Session):
api = AccountAvatarApi()
method = inspect.unwrap(api.get)
user = make_account("acc-owner")
tenant_id = "tenant-1"
user, tenant = persist_account_with_tenant(sqlite_session, "acc-owner")
tenant_id = tenant.id
file_id = "550e8400-e29b-41d4-a716-446655440002"
upload_file = MagicMock()
other_tenant = Tenant(name="tenant-other")
other_tenant.id = str(uuid5(NAMESPACE_URL, "tenant:tenant-other"))
upload_file = make_upload_file(tenant_id=other_tenant.id, created_by=user.id)
upload_file.id = file_id
upload_file.tenant_id = "tenant-other"
upload_file.created_by = user.id
upload_file.created_by_role = CreatorUserRole.ACCOUNT
sqlite_session.add_all([other_tenant, upload_file])
sqlite_session.commit()
with (
app.test_request_context(f"/account/avatar?avatar={file_id}"),
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
patch("controllers.console.workspace.account.db.session", sqlite_session),
patch(
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
return_value="https://signed/leak",
@@ -246,7 +333,7 @@ class TestAccountPasswordApi:
):
result = method(api, user)
assert result["id"] == "u1"
assert result["id"] == user.id
def test_password_wrong_current(self, app: Flask):
api = AccountPasswordApi()
@@ -271,21 +358,38 @@ class TestAccountPasswordApi:
class TestAccountIntegrateApi:
def test_get_integrates(self, app: Flask):
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, AccountIntegrate)],
indirect=True,
)
def test_get_integrates(self, app: Flask, sqlite_session: Session):
api = AccountIntegrateApi()
method = inspect.unwrap(api.get)
account = make_account("acc1")
account, _ = persist_account_with_tenant(sqlite_session, "acc1")
sqlite_session.add(
AccountIntegrate(
account_id=account.id,
provider="github",
open_id="github-user",
encrypted_token="encrypted-token",
)
)
sqlite_session.commit()
with (
app.test_request_context("/"),
patch("controllers.console.workspace.account.db.session.scalars") as scalars_mock,
patch("controllers.console.workspace.account.db.session", sqlite_session),
):
scalars_mock.return_value.all.return_value = []
result = method(api, account)
assert "data" in result
assert len(result["data"]) == 2
assert result["data"][0]["provider"] == "github"
assert result["data"][0]["is_bound"] is True
assert result["data"][0]["link"] is None
assert result["data"][1]["provider"] == "google"
assert result["data"][1]["is_bound"] is False
assert result["data"][1]["link"].endswith("/console/api/oauth/login/google")
class TestAccountDeleteApi:
@@ -1,15 +1,52 @@
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from sqlalchemy.orm import Session
from clients.agent_backend.errors import AgentBackendRunFailedError
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueErrorEvent
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.errors.error import QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError, InvokeRateLimitError
from models.enums import MessageStatus
from models.enums import ConversationFromSource, MessageStatus
from models.model import AppMode, Message
def _persist_message(session: Session, *, message_id: str) -> Message:
message = Message(
id=message_id,
app_id="app-1",
model_provider=None,
model_id=None,
override_model_configs=None,
conversation_id="conversation-1",
inputs={},
query="query",
message={},
message_unit_price=Decimal(0),
answer="",
answer_unit_price=Decimal(0),
parent_message_id=None,
total_price=None,
currency="USD",
status=MessageStatus.NORMAL,
error=None,
message_metadata=None,
invoke_from=InvokeFrom.WEB_APP,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="account-1",
workflow_run_id=None,
app_mode=AppMode.COMPLETION,
)
session.add(message)
session.commit()
session.expunge_all()
return message
class TestBasedGenerateTaskPipeline:
@@ -58,26 +95,33 @@ class TestBasedGenerateTaskPipeline:
assert "Knowledge retrieval failed" in str(err)
assert "agent_run_id=run-1" in str(err)
def test_handle_error_updates_message_when_found(self, pipeline):
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
def test_handle_error_updates_message_when_found(self, pipeline, sqlite_session: Session):
event = QueueErrorEvent(error=ValueError("oops"))
message = SimpleNamespace(status=MessageStatus.NORMAL, error=None)
session = Mock()
session.scalar.return_value = message
_persist_message(sqlite_session, message_id="msg-1")
err = pipeline.handle_error(event=event, session=session, message_id="msg-1")
err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1")
assert err is event.error
assert message.status == MessageStatus.ERROR
assert message.error == "oops"
sqlite_session.flush()
sqlite_session.expire_all()
updated_message = sqlite_session.get(Message, "msg-1")
assert updated_message is not None
assert updated_message.status == MessageStatus.ERROR
assert updated_message.error == "oops"
def test_handle_error_returns_err_when_message_missing(self, pipeline):
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
def test_handle_error_returns_err_when_message_missing(self, pipeline, sqlite_session: Session):
event = QueueErrorEvent(error=ValueError("oops"))
session = Mock()
session.scalar.return_value = None
_persist_message(sqlite_session, message_id="other-message")
err = pipeline.handle_error(event=event, session=session, message_id="msg-1")
err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1")
assert err is event.error
untouched_message = sqlite_session.get(Message, "other-message")
assert untouched_message is not None
assert untouched_message.status == MessageStatus.NORMAL
assert untouched_message.error is None
def test_error_to_stream_response_and_ping(self, pipeline):
error_response = pipeline.error_to_stream_response(ValueError("boom"))
@@ -1,11 +1,51 @@
"""Unit tests for Notion extraction, HTTP parsing, and persisted metadata updates."""
import json
import uuid
from collections.abc import Iterator
from types import SimpleNamespace
from unittest import mock
import httpx
import pytest
from pytest_mock import MockerFixture
from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.rag.extractor import notion_extractor
from core.rag.index_processor.constant.index_type import IndexStructureType
from models.base import TypeBase
from models.dataset import Document as DocumentModel
from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus
@pytest.fixture
def persisted_document(
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
) -> Iterator[tuple[sessionmaker[Session], DocumentModel]]:
"""Persist a Notion document and bind the extractor's ``db.session`` to SQLite."""
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
document = DocumentModel(
id=str(uuid.uuid4()),
tenant_id=str(uuid.uuid4()),
dataset_id=str(uuid.uuid4()),
position=1,
data_source_type=DataSourceType.NOTION_IMPORT,
data_source_info=json.dumps({"source": "notion", "last_edited_time": "2025-01-01T00:00:00.000Z"}),
batch="batch",
name="Notion page",
created_from=DocumentCreatedFrom.API,
created_by=str(uuid.uuid4()),
indexing_status=IndexingStatus.COMPLETED,
doc_form=IndexStructureType.PARAGRAPH_INDEX,
)
with session_maker() as sqlite_session:
sqlite_session.add(document)
sqlite_session.commit()
monkeypatch.setattr(notion_extractor, "db", SimpleNamespace(session=sqlite_session))
yield session_maker, document
def _mock_response(data, status_code: int = 200, text: str = ""):
@@ -394,7 +434,11 @@ class TestNotionMetadataAndCredentialMethods:
assert extractor.update_last_edited_time(None) is None
def test_update_last_edited_time_updates_document_and_commits(self, monkeypatch: pytest.MonkeyPatch):
def test_update_last_edited_time_updates_document_and_commits(
self,
monkeypatch: pytest.MonkeyPatch,
persisted_document: tuple[sessionmaker[Session], DocumentModel],
):
extractor = notion_extractor.NotionExtractor(
notion_workspace_id="ws",
notion_obj_id="obj",
@@ -402,40 +446,20 @@ class TestNotionMetadataAndCredentialMethods:
tenant_id="tenant",
notion_access_token="token",
)
class FakeDocumentModel:
data_source_info = "data_source_info"
id = "id"
execute_calls = []
class FakeUpdateStmt:
def where(self, *args):
return self
def values(self, **kwargs):
return self
class FakeSession:
committed = False
def execute(self, stmt):
execute_calls.append(stmt)
def commit(self):
self.committed = True
fake_db = SimpleNamespace(session=FakeSession())
monkeypatch.setattr(notion_extractor, "DocumentModel", FakeDocumentModel)
monkeypatch.setattr(notion_extractor, "update", lambda model: FakeUpdateStmt())
monkeypatch.setattr(notion_extractor, "db", fake_db)
monkeypatch.setattr(extractor, "get_notion_last_edited_time", lambda: "2026-01-01T00:00:00.000Z")
session_maker, document = persisted_document
doc_model = SimpleNamespace(id="doc-1", data_source_info_dict={"source": "notion"})
extractor.update_last_edited_time(doc_model)
extractor.update_last_edited_time(document)
assert execute_calls
assert fake_db.session.committed is True
# Closing the writer session rolls back an uncommitted update before the independent read below.
notion_extractor.db.session.close()
with session_maker() as verification_session:
stored_document = verification_session.get(DocumentModel, document.id)
assert stored_document is not None
assert stored_document.data_source_info_dict == {
"source": "notion",
"last_edited_time": "2026-01-01T00:00:00.000Z",
}
def test_get_notion_last_edited_time_uses_page_and_database_urls(self, mocker: MockerFixture):
extractor_page = notion_extractor.NotionExtractor(
@@ -176,6 +176,22 @@ class TestWaterCrawlAPIClient:
with pytest.raises(expected_exception):
client.process_response(_response(status, {"message": "bad", "errors": {"url": ["x"]}}))
@pytest.mark.parametrize(
("status", "expected_exception"),
[
(401, WaterCrawlAuthenticationError),
(403, WaterCrawlPermissionError),
(422, WaterCrawlBadRequestError),
],
)
def test_process_response_error_statuses_with_non_json_body(self, status: int, expected_exception: type[Exception]):
client = WaterCrawlAPIClient(api_key="k")
response = _response(status, text="<html>upstream error</html>")
response.json.side_effect = json.JSONDecodeError("Expecting value", response.text, 0)
with pytest.raises(expected_exception):
client.process_response(response)
def test_process_response_204_returns_none(self):
client = WaterCrawlAPIClient(api_key="k")
assert client.process_response(_response(204, None)) is None
@@ -185,6 +201,14 @@ class TestWaterCrawlAPIClient:
assert client.process_response(_response(200, {"ok": True})) == {"ok": True}
assert client.process_response(_response(200, None)) == {}
def test_process_response_json_payload_with_invalid_body_raises_clear_error(self):
client = WaterCrawlAPIClient(api_key="k")
response = _response(200, text="<html>upstream error</html>")
response.json.side_effect = json.JSONDecodeError("Expecting value", response.text, 0)
with pytest.raises(ValueError, match="Invalid JSON response from WaterCrawl"):
client.process_response(response)
def test_process_response_accepts_json_content_type_parameters(self):
client = WaterCrawlAPIClient(api_key="k")
@@ -1,27 +1,61 @@
"""Unit tests for `ToolFileManager` behavior.
"""Unit tests for ``ToolFileManager`` behavior.
Covers signing, file persistence flows, and retrieval APIs with mocked
storage/session boundaries (httpx, SimpleNamespace, Mock/patch) to avoid real
IO.
File metadata is persisted through real SQLite-backed sessions. Storage and
remote HTTP remain mocked because they are external I/O boundaries.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from collections.abc import Iterator
from unittest.mock import Mock, patch
from uuid import UUID, uuid4
import httpx
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
import core.tools.tool_file_manager as tool_file_manager_module
from core.tools.tool_file_manager import ToolFileManager
from graphon.file import FileTransferMethod
from graphon.file import FileTransferMethod, FileType
from models.base import TypeBase
from models.enums import CreatorUserRole
from models.model import MessageFile
from models.tools import ToolFile
def _patch_session_factory(session: Mock):
session_cm = MagicMock()
session_cm.__enter__.return_value = session
session_cm.__exit__.return_value = False
return patch("core.tools.tool_file_manager.session_factory.create_session", return_value=session_cm)
@pytest.fixture
def sqlite_tool_file_session(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[Session]:
"""Bind manager-owned sessions to SQLite and expose a setup/assertion session."""
TypeBase.metadata.create_all(sqlite_engine, tables=[ToolFile.__table__, MessageFile.__table__])
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(tool_file_manager_module.session_factory, "create_session", factory)
with factory() as session:
yield session
def _tool_file(*, file_key: str = "k1", mimetype: str = "text/plain", name: str = "file.txt") -> ToolFile:
return ToolFile(
user_id=str(uuid4()),
tenant_id=str(uuid4()),
conversation_id=str(uuid4()),
file_key=file_key,
mimetype=mimetype,
original_url=None,
name=name,
size=12,
)
def _message_file(*, url: str | None) -> MessageFile:
return MessageFile(
message_id=str(uuid4()),
type=FileType.IMAGE,
transfer_method=FileTransferMethod.TOOL_FILE,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=str(uuid4()),
url=url,
)
def test_tool_file_manager_sign_file_builds_url() -> None:
@@ -29,120 +63,105 @@ def test_tool_file_manager_sign_file_builds_url() -> None:
assert "/files/tools/tf-1.png" in url
def test_create_file_by_raw_stores_file_and_persists_record() -> None:
def test_create_file_by_raw_stores_file_and_persists_record(sqlite_tool_file_session: Session) -> None:
manager = ToolFileManager()
session = Mock()
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-1")
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
user_id = str(uuid4())
tenant_id = str(uuid4())
conversation_id = str(uuid4())
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.guess_extension", return_value=".txt"),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="abc")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xABC)),
):
file_model = manager.create_file_by_raw(
user_id="u1",
tenant_id="t1",
conversation_id="c1",
user_id=user_id,
tenant_id=tenant_id,
conversation_id=conversation_id,
file_binary=b"hello",
mimetype="text/plain",
filename="readme",
)
assert file_model.name.endswith(".txt")
storage.save.assert_called_once()
session.add.assert_called_once()
session.commit.assert_called_once()
session.refresh.assert_called_once_with(file_model)
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.name == "readme.txt"
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xABC).hex}.txt"
storage.save.assert_called_once_with(persisted.file_key, b"hello")
def test_create_file_by_raw_prefers_filename_extension_over_mimetype() -> None:
def test_create_file_by_raw_prefers_filename_extension_over_mimetype(
sqlite_tool_file_session: Session,
) -> None:
manager = ToolFileManager()
session = Mock()
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-docx")
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
tenant_id = str(uuid4())
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="abc")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xABC)),
):
file_model = manager.create_file_by_raw(
user_id="u1",
tenant_id="t1",
conversation_id="c1",
user_id=str(uuid4()),
tenant_id=tenant_id,
conversation_id=str(uuid4()),
file_binary=b"docx",
mimetype="application/octet-stream",
filename="report.docx",
)
assert file_model.name == "report.docx"
assert file_model.file_key == "tools/t1/abc.docx"
storage.save.assert_called_once_with("tools/t1/abc.docx", b"docx")
session.add.assert_called_once_with(file_model)
session.commit.assert_called_once()
session.refresh.assert_called_once_with(file_model)
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.name == "report.docx"
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xABC).hex}.docx"
storage.save.assert_called_once_with(persisted.file_key, b"docx")
def test_create_file_by_url_downloads_and_persists_record() -> None:
def test_create_file_by_url_downloads_and_persists_record(sqlite_tool_file_session: Session) -> None:
manager = ToolFileManager()
tenant_id = str(uuid4())
response = Mock()
response.content = b"binary"
response.headers = {"Content-Type": "application/octet-stream"}
response.raise_for_status.return_value = None
session = Mock()
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-2")
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="def")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xDEF)),
patch("core.tools.tool_file_manager.remote_fetcher.make_request", return_value=response),
):
file_model = manager.create_file_by_url("u1", "t1", "https://example.com/f.bin", "c1")
file_model = manager.create_file_by_url(str(uuid4()), tenant_id, "https://example.com/f.bin", str(uuid4()))
assert file_model.file_key.startswith("tools/t1/")
storage.save.assert_called_once()
session.add.assert_called_once_with(file_model)
session.commit.assert_called_once()
session.refresh.assert_called_once_with(file_model)
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xDEF).hex}.bin"
assert persisted.original_url == "https://example.com/f.bin"
storage.save.assert_called_once_with(persisted.file_key, b"binary")
def test_create_file_by_url_prefers_url_extension_over_mimetype() -> None:
def test_create_file_by_url_prefers_url_extension_over_mimetype(
sqlite_tool_file_session: Session,
) -> None:
manager = ToolFileManager()
tenant_id = str(uuid4())
response = Mock()
response.content = b"docx"
response.headers = {"Content-Type": "application/octet-stream"}
response.raise_for_status.return_value = None
session = Mock()
def tool_file_factory(**kwargs):
return SimpleNamespace(**kwargs)
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-docx")
with (
patch("core.tools.tool_file_manager.storage") as storage,
patch("core.tools.tool_file_manager.ToolFile", side_effect=tool_file_factory),
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="urlabc")),
_patch_session_factory(session),
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xABC)),
patch("core.tools.tool_file_manager.remote_fetcher.make_request", return_value=response),
):
file_model = manager.create_file_by_url("u1", "t1", "https://example.com/report.docx?download=1", "c1")
file_model = manager.create_file_by_url(
str(uuid4()), tenant_id, "https://example.com/report.docx?download=1", str(uuid4())
)
assert file_model.file_key == "tools/t1/urlabc.docx"
assert file_model.name == "urlabc.docx"
storage.save.assert_called_once_with("tools/t1/urlabc.docx", b"docx")
persisted = sqlite_tool_file_session.get(ToolFile, file_model.id)
assert persisted is not None
assert persisted.file_key == f"tools/{tenant_id}/{UUID(int=0xABC).hex}.docx"
assert persisted.name == f"{UUID(int=0xABC).hex}.docx"
storage.save.assert_called_once_with(persisted.file_key, b"docx")
def test_create_file_by_url_raises_on_timeout() -> None:
@@ -156,121 +175,72 @@ def test_create_file_by_url_raises_on_timeout() -> None:
manager.create_file_by_url("u1", "t1", "https://example.com/f.bin", "c1")
def test_get_file_binary_returns_none_when_not_found() -> None:
# Arrange
manager = ToolFileManager()
session = Mock()
session.scalar.return_value = None
# Act
with _patch_session_factory(session):
result = manager.get_file_binary("missing")
# Assert
assert result is None
def test_get_file_binary_returns_none_when_not_found(sqlite_tool_file_session: Session) -> None:
assert ToolFileManager().get_file_binary(str(uuid4())) is None
def test_get_file_binary_returns_bytes_when_found() -> None:
# Arrange
manager = ToolFileManager()
tool_file = SimpleNamespace(file_key="k1", mimetype="text/plain")
session = Mock()
session.scalar.return_value = tool_file
def test_get_file_binary_returns_bytes_when_found(sqlite_tool_file_session: Session) -> None:
tool_file = _tool_file()
sqlite_tool_file_session.add(tool_file)
sqlite_tool_file_session.commit()
# Act
with patch("core.tools.tool_file_manager.storage") as storage:
storage.load_once.return_value = b"hello"
with _patch_session_factory(session):
result = manager.get_file_binary("id1")
result = ToolFileManager().get_file_binary(tool_file.id)
# Assert
assert result == (b"hello", "text/plain")
storage.load_once.assert_called_once_with("k1")
def test_get_file_binary_by_message_file_id_when_messagefile_missing() -> None:
# Arrange
manager = ToolFileManager()
session = Mock()
session.scalar.side_effect = [None, None]
# Act
with _patch_session_factory(session):
result = manager.get_file_binary_by_message_file_id("mf-1")
# Assert
assert result is None
def test_get_file_binary_by_message_file_id_when_messagefile_missing(
sqlite_tool_file_session: Session,
) -> None:
assert ToolFileManager().get_file_binary_by_message_file_id(str(uuid4())) is None
def test_get_file_binary_by_message_file_id_when_url_is_none() -> None:
# Arrange
manager = ToolFileManager()
message_file = SimpleNamespace(url=None)
session = Mock()
session.scalar.side_effect = [message_file, None]
def test_get_file_binary_by_message_file_id_when_url_is_none(sqlite_tool_file_session: Session) -> None:
message_file = _message_file(url=None)
sqlite_tool_file_session.add(message_file)
sqlite_tool_file_session.commit()
# Act
with _patch_session_factory(session):
result = manager.get_file_binary_by_message_file_id("mf-1")
# Assert
assert result is None
assert ToolFileManager().get_file_binary_by_message_file_id(message_file.id) is None
def test_get_file_binary_by_message_file_id_returns_bytes_when_found() -> None:
# Arrange
manager = ToolFileManager()
message_file = SimpleNamespace(url="https://x/files/tools/tool123.png")
tool_file = SimpleNamespace(file_key="k2", mimetype="image/png")
session = Mock()
session.scalar.side_effect = [message_file, tool_file]
def test_get_file_binary_by_message_file_id_returns_bytes_when_found(
sqlite_tool_file_session: Session,
) -> None:
tool_file = _tool_file(file_key="k2", mimetype="image/png", name="image.png")
message_file = _message_file(url=f"https://x/files/tools/{tool_file.id}.png")
sqlite_tool_file_session.add_all([tool_file, message_file])
sqlite_tool_file_session.commit()
# Act
with patch("core.tools.tool_file_manager.storage") as storage:
storage.load_once.return_value = b"img"
with _patch_session_factory(session):
result = manager.get_file_binary_by_message_file_id("mf-1")
result = ToolFileManager().get_file_binary_by_message_file_id(message_file.id)
# Assert
assert result == (b"img", "image/png")
storage.load_once.assert_called_once_with("k2")
def test_get_file_generator_returns_none_when_toolfile_missing() -> None:
# Arrange
manager = ToolFileManager()
session = Mock()
session.scalar.return_value = None
def test_get_file_generator_returns_none_when_toolfile_missing(sqlite_tool_file_session: Session) -> None:
stream, tool_file = ToolFileManager().get_file_generator_by_tool_file_id(str(uuid4()))
# Act
with _patch_session_factory(session):
stream, tool_file = manager.get_file_generator_by_tool_file_id("tool123")
# Assert
assert stream is None
assert tool_file is None
def test_get_file_generator_returns_stream_when_found() -> None:
# Arrange
manager = ToolFileManager()
tool_file = SimpleNamespace(
id="tool123",
file_key="k2",
mimetype="image/png",
original_url=None,
name="image.png",
size=12,
)
session = Mock()
session.scalar.return_value = tool_file
def test_get_file_generator_returns_stream_when_found(sqlite_tool_file_session: Session) -> None:
tool_file = _tool_file(file_key="k2", mimetype="image/png", name="image.png")
sqlite_tool_file_session.add(tool_file)
sqlite_tool_file_session.commit()
# Act
with patch("core.tools.tool_file_manager.storage") as storage:
stream = iter([b"a", b"b"])
storage.load_stream.return_value = stream
with _patch_session_factory(session):
result_stream, result_file = manager.get_file_generator_by_tool_file_id("tool123")
assert list(result_stream) == [b"a", b"b"]
assert result_file is not None
assert result_file.related_id == "tool123"
assert result_file.mime_type == "image/png"
assert result_file.transfer_method == FileTransferMethod.TOOL_FILE
storage.load_stream.return_value = iter([b"a", b"b"])
result_stream, result_file = ToolFileManager().get_file_generator_by_tool_file_id(tool_file.id)
assert result_stream is not None
assert list(result_stream) == [b"a", b"b"]
assert result_file is not None
assert result_file.related_id == tool_file.id
assert result_file.mime_type == "image/png"
assert result_file.transfer_method == FileTransferMethod.TOOL_FILE
@@ -1,36 +1,28 @@
import uuid
from unittest.mock import MagicMock, patch
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from unittest.mock import patch
import pytest
from httpx import Response
from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
from core.workflow.file_reference import build_file_reference, parse_file_reference, resolve_file_record_id
from extensions.storage.storage_type import StorageType
from factories.file_factory.builders import build_from_mapping as _build_from_mapping
from graphon.file import File, FileTransferMethod, FileType, FileUploadConfig
from models import ToolFile, UploadFile
def _make_session_ctx_mock(scalar_return=None):
"""Return a mock usable as the ``session_factory.create_session()`` context manager.
Patch ``factories.file_factory.builders.session_factory`` and set
``mock_sf.create_session.return_value = <this mock>`` to intercept DB calls
without requiring a live Flask app or database engine.
"""
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.scalar.return_value = scalar_return
return session
from models import CreatorUserRole, ToolFile, UploadFile
# Test Data
TEST_TENANT_ID = "test_tenant_id"
TEST_UPLOAD_FILE_ID = str(uuid.uuid4())
TEST_TOOL_FILE_ID = str(uuid.uuid4())
TEST_REMOTE_URL = "http://example.com/test.jpg"
TEST_END_USER_ID = "end-user-id"
TEST_ACCESS_CONTROLLER = DatabaseFileAccessController()
# Test Config
@@ -52,39 +44,52 @@ def build_from_mapping(*, mapping, tenant_id, config=None, strict_type_validatio
)
@dataclass(frozen=True)
class FileRecords:
session: Session
upload_file: UploadFile
tool_file: ToolFile
# Fixtures
@pytest.fixture
def mock_upload_file():
mock = MagicMock(spec=UploadFile)
mock.id = TEST_UPLOAD_FILE_ID
mock.tenant_id = TEST_TENANT_ID
mock.name = "test.jpg"
mock.extension = "jpg"
mock.mime_type = "image/jpeg"
mock.source_url = TEST_REMOTE_URL
mock.size = 1024
mock.key = "test_key"
session = _make_session_ctx_mock(scalar_return=mock)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
# yield session.scalar so callers can inspect call_args and mutate return_value
yield session.scalar
@pytest.fixture(autouse=True)
def file_records(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[FileRecords]:
"""Persist authorized file rows and bind builder-owned sessions to SQLite."""
UploadFile.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__, ToolFile.__table__])
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
upload_file = UploadFile(
tenant_id=TEST_TENANT_ID,
storage_type=StorageType.LOCAL,
key="test_key",
name="test.jpg",
size=1024,
extension="jpg",
mime_type="image/jpeg",
created_by_role=CreatorUserRole.END_USER,
created_by=TEST_END_USER_ID,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
used=False,
source_url=TEST_REMOTE_URL,
)
upload_file.id = TEST_UPLOAD_FILE_ID
tool_file = ToolFile(
user_id=TEST_END_USER_ID,
tenant_id=TEST_TENANT_ID,
conversation_id=None,
file_key="tool_file.pdf",
mimetype="application/pdf",
original_url="http://example.com/tool.pdf",
name="tool_file.pdf",
size=2048,
)
tool_file.id = TEST_TOOL_FILE_ID
@pytest.fixture
def mock_tool_file():
mock = MagicMock(spec=ToolFile)
mock.id = TEST_TOOL_FILE_ID
mock.tenant_id = TEST_TENANT_ID
mock.name = "tool_file.pdf"
mock.file_key = "tool_file.pdf"
mock.mimetype = "application/pdf"
mock.original_url = "http://example.com/tool.pdf"
mock.size = 2048
session = _make_session_ctx_mock(scalar_return=mock)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
yield mock
with sqlite_session_maker() as session:
session.add_all([upload_file, tool_file])
session.commit()
yield FileRecords(session=session, upload_file=upload_file, tool_file=tool_file)
@pytest.fixture
@@ -122,7 +127,7 @@ def tool_file_mapping(file_type="document"):
# Tests
def test_build_from_mapping_backward_compatibility(mock_upload_file):
def test_build_from_mapping_backward_compatibility():
mapping = local_file_mapping(file_type="image")
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
assert isinstance(file, File)
@@ -133,7 +138,7 @@ def test_build_from_mapping_backward_compatibility(mock_upload_file):
assert file.storage_key == "test_key"
def test_build_from_mapping_accepts_opaque_reference_for_local_file(mock_upload_file):
def test_build_from_mapping_accepts_opaque_reference_for_local_file():
mapping = {
"transfer_method": "local_file",
"reference": build_file_reference(record_id=TEST_UPLOAD_FILE_ID, storage_key="test_key"),
@@ -148,7 +153,7 @@ def test_build_from_mapping_accepts_opaque_reference_for_local_file(mock_upload_
assert resolve_file_record_id(file.reference) == TEST_UPLOAD_FILE_ID
def test_build_from_mapping_accepts_opaque_related_id_for_tool_file(mock_tool_file):
def test_build_from_mapping_accepts_opaque_related_id_for_tool_file():
mapping = {
"transfer_method": "tool_file",
"related_id": build_file_reference(record_id=TEST_TOOL_FILE_ID, storage_key="tool_file.pdf"),
@@ -165,10 +170,11 @@ def test_build_from_mapping_accepts_opaque_related_id_for_tool_file(mock_tool_fi
assert file.storage_key == "tool_file.pdf"
def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(mock_tool_file):
mock_tool_file.name = "report.docx"
mock_tool_file.file_key = "tools/test_tenant_id/file.bin"
mock_tool_file.mimetype = "application/octet-stream"
def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(file_records: FileRecords):
file_records.tool_file.name = "report.docx"
file_records.tool_file.file_key = "tools/test_tenant_id/file.bin"
file_records.tool_file.mimetype = "application/octet-stream"
file_records.session.commit()
mapping = tool_file_mapping(file_type="document")
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
@@ -186,7 +192,7 @@ def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(mock_t
("document", False, "Detected file type does not match"),
],
)
def test_build_from_local_file_strict_validation(mock_upload_file, file_type, should_pass, expected_error):
def test_build_from_local_file_strict_validation(file_type, should_pass, expected_error):
mapping = local_file_mapping(file_type=file_type)
if should_pass:
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, strict_type_validation=True)
@@ -203,7 +209,7 @@ def test_build_from_local_file_strict_validation(mock_upload_file, file_type, sh
("image", False, "Detected file type does not match"),
],
)
def test_build_from_tool_file_strict_validation(mock_tool_file, file_type, should_pass, expected_error):
def test_build_from_tool_file_strict_validation(file_type, should_pass, expected_error):
"""Strict type validation for tool_file."""
mapping = tool_file_mapping(file_type=file_type)
if should_pass:
@@ -282,27 +288,27 @@ def test_build_from_remote_url_without_strict_validation(mock_http_head):
assert file.filename == "remote_test.jpg"
def test_tool_file_not_found():
def test_tool_file_not_found(file_records: FileRecords):
"""Test ToolFile not found in database."""
session = _make_session_ctx_mock(scalar_return=None)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
mapping = tool_file_mapping()
with pytest.raises(ValueError, match=f"ToolFile {TEST_TOOL_FILE_ID} not found"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
file_records.session.delete(file_records.tool_file)
file_records.session.commit()
mapping = tool_file_mapping()
with pytest.raises(ValueError, match=f"ToolFile {TEST_TOOL_FILE_ID} not found"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_local_file_not_found():
def test_local_file_not_found(file_records: FileRecords):
"""Test UploadFile not found in database."""
session = _make_session_ctx_mock(scalar_return=None)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
file_records.session.delete(file_records.upload_file)
file_records.session.commit()
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_build_without_type_specification(mock_upload_file):
def test_build_without_type_specification():
"""Test the situation where no file type is specified"""
mapping = {
"transfer_method": "local_file",
@@ -321,7 +327,7 @@ def test_build_without_type_specification(mock_upload_file):
("video", False, "File validation failed"),
],
)
def test_file_validation_with_config(mock_upload_file, file_type, should_pass, expected_error):
def test_file_validation_with_config(file_type, should_pass, expected_error):
"""Test the validation of files and configurations"""
mapping = local_file_mapping(file_type=file_type)
if should_pass:
@@ -354,73 +360,65 @@ def test_invalid_uuid_format():
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_tenant_mismatch():
def test_tenant_mismatch(file_records: FileRecords):
"""Test that tenant mismatch raises security error."""
# Create a mock upload file with a different tenant_id
mock_file = MagicMock(spec=UploadFile)
mock_file.id = TEST_UPLOAD_FILE_ID
mock_file.tenant_id = "different_tenant_id"
mock_file.name = "test.jpg"
mock_file.extension = "jpg"
mock_file.mime_type = "image/jpeg"
mock_file.source_url = TEST_REMOTE_URL
mock_file.size = 1024
mock_file.key = "test_key"
file_records.upload_file.tenant_id = "different_tenant_id"
file_records.session.commit()
# Mock the database query to return None (no file found for this tenant)
session = _make_session_ctx_mock(scalar_return=None)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
mapping = local_file_mapping()
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
def test_build_from_mapping_scopes_upload_file_to_end_user(mock_upload_file):
def test_build_from_mapping_scopes_upload_file_to_end_user():
scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="end-user-id",
user_id=TEST_END_USER_ID,
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(scope):
build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
file = build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
stmt = mock_upload_file.call_args.args[0]
whereclause = str(stmt.whereclause)
assert "upload_files.created_by_role" in whereclause
assert "upload_files.created_by" in whereclause
assert resolve_file_record_id(file.reference) == TEST_UPLOAD_FILE_ID
unauthorized_scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="different-end-user",
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(unauthorized_scope):
with pytest.raises(ValueError, match="Invalid upload file"):
build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
def test_build_from_mapping_scopes_tool_file_to_end_user():
tool_file = MagicMock(spec=ToolFile)
tool_file.id = TEST_TOOL_FILE_ID
tool_file.tenant_id = TEST_TENANT_ID
tool_file.name = "tool_file.pdf"
tool_file.file_key = "tool_file.pdf"
tool_file.mimetype = "application/pdf"
tool_file.original_url = "http://example.com/tool.pdf"
tool_file.size = 2048
scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="end-user-id",
user_id=TEST_END_USER_ID,
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
session = _make_session_ctx_mock(scalar_return=tool_file)
with patch("factories.file_factory.builders.session_factory") as mock_sf:
mock_sf.create_session.return_value = session
with bind_file_access_scope(scope):
with bind_file_access_scope(scope):
file = build_from_mapping(mapping=tool_file_mapping(), tenant_id=TEST_TENANT_ID)
assert resolve_file_record_id(file.reference) == TEST_TOOL_FILE_ID
unauthorized_scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="different-end-user",
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(unauthorized_scope):
with pytest.raises(ValueError, match=f"ToolFile {TEST_TOOL_FILE_ID} not found"):
build_from_mapping(mapping=tool_file_mapping(), tenant_id=TEST_TENANT_ID)
stmt = session.scalar.call_args.args[0]
whereclause = str(stmt.whereclause)
assert "tool_files.user_id" in whereclause
def test_disallowed_file_types(mock_upload_file):
def test_disallowed_file_types():
"""Test that disallowed file types are rejected."""
# Config that only allows image and document types
restricted_config = FileUploadConfig(
@@ -433,12 +431,12 @@ def test_disallowed_file_types(mock_upload_file):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config)
def test_disallowed_extensions(mock_upload_file):
def test_disallowed_extensions(file_records: FileRecords):
"""Test that disallowed file extensions are rejected for custom type."""
# Mock a file with .exe extension
mock_upload_file.return_value.extension = "exe"
mock_upload_file.return_value.name = "malicious.exe"
mock_upload_file.return_value.mime_type = "application/x-msdownload"
file_records.upload_file.extension = "exe"
file_records.upload_file.name = "malicious.exe"
file_records.upload_file.mime_type = "application/x-msdownload"
file_records.session.commit()
# Config that only allows specific extensions for custom files
restricted_config = FileUploadConfig(
@@ -456,11 +454,12 @@ def test_disallowed_extensions(mock_upload_file):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config)
def test_custom_file_type_uses_extension_validation_under_strict_mode(mock_upload_file):
def test_custom_file_type_uses_extension_validation_under_strict_mode(file_records: FileRecords):
"""Custom form uploads are classified by the configured extension list."""
mock_upload_file.return_value.extension = "txt"
mock_upload_file.return_value.name = "notes.txt"
mock_upload_file.return_value.mime_type = "text/plain"
file_records.upload_file.extension = "txt"
file_records.upload_file.name = "notes.txt"
file_records.upload_file.mime_type = "text/plain"
file_records.session.commit()
custom_config = FileUploadConfig(
allowed_file_types=[FileType.CUSTOM],
@@ -6,6 +6,7 @@ import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from models.enums import ConversationFromSource, MessageStatus
from services.agent import observability_service as observability_service_module
from services.agent.observability_service import AgentLogQueryParams, AgentObservabilityService
@@ -66,7 +67,7 @@ def test_resolve_source_filters_accepts_multiple_structured_sources() -> None:
def test_statistics_all_source_includes_debugger_messages() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("all")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
scope_sql = AgentObservabilityService._statistics_webapp_message_scope_sql(source_filter)
assert "m.app_id = :app_id" in scope_sql
assert "m.invoke_from != :debugger" not in scope_sql
@@ -75,7 +76,7 @@ def test_statistics_all_source_includes_debugger_messages() -> None:
def test_statistics_explicit_source_filters_invoke_from() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("debugger")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
scope_sql = AgentObservabilityService._statistics_webapp_message_scope_sql(source_filter)
assert "m.invoke_from = :source" in scope_sql
@@ -83,7 +84,7 @@ def test_statistics_explicit_source_filters_invoke_from() -> None:
def test_statistics_workflow_app_source_covers_all_versions_and_nodes() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("workflow:app-2")
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
scope_sql = AgentObservabilityService._statistics_workflow_binding_filters_sql(source_filter)
assert "wanb.app_id = :source_app_id" in scope_sql
assert "wanb.workflow_id = :workflow_id" not in scope_sql
@@ -91,6 +92,144 @@ def test_statistics_workflow_app_source_covers_all_versions_and_nodes() -> None:
assert "wanb.node_id = :node_id" not in scope_sql
def test_statistics_workflow_chat_context_only_uses_chat_runs() -> None:
source_filter = AgentObservabilityService.resolve_source_filter("workflow:app-2")
scope_sql = AgentObservabilityService._statistics_workflow_message_scope_sql(source_filter)
assert "wr.id = m.workflow_run_id" in scope_sql
assert "wr.type = :chat_workflow_type" in scope_sql
def test_workflow_metadata_numeric_sql_supports_postgresql_and_mysql(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="postgresql"))
postgres_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql(
("agent_log", "agent_backend", "usage", "total_tokens"), "BIGINT"
)
assert "CAST(wne.execution_metadata AS JSONB)" in postgres_sql
assert "#>> '{agent_log,agent_backend,usage,total_tokens}'" in postgres_sql
monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="mysql"))
mysql_sql = AgentObservabilityService._workflow_execution_metadata_numeric_sql(("total_tokens",), "BIGINT")
assert "JSON_EXTRACT(wne.execution_metadata, '$.total_tokens')" in mysql_sql
assert " AS UNSIGNED)" in mysql_sql
def test_workflow_statistics_include_run_without_message(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResult:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
class FakeSession:
def __init__(self):
self.queries: list[str] = []
def execute(self, stmt, args):
query = str(stmt)
self.queries.append(query)
if "WITH agent_run_usage" in query:
return FakeResult(
[
SimpleNamespace(
_mapping={
"date": "2026-07-21",
"message_count": 1,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 454_064,
"total_price": Decimal("2.323470"),
"avg_latency": 59.93,
"latency_sum": 59.93,
"answer_tokens": 2_126,
"like_count": 0,
}
)
]
)
return FakeResult([])
monkeypatch.setattr(observability_service_module, "dify_config", SimpleNamespace(DB_TYPE="postgresql"))
monkeypatch.setattr(
observability_service_module,
"convert_datetime_to_date",
lambda field: f"DATE({field})",
)
session = FakeSession()
service = AgentObservabilityService(session)
payload = service.get_statistics_summary(
app=SimpleNamespace(id="agent-app", tenant_id="tenant-1"), # type: ignore[arg-type]
agent_id="agent-1",
params=observability_service_module.AgentStatisticsQueryParams(source="workflow:workflow-app"),
)
assert payload["summary"]["total_messages"] == 1
assert payload["summary"]["total_conversations"] == 1
assert payload["summary"]["total_end_users"] == 1
assert payload["summary"]["total_tokens"] == 454_064
assert payload["summary"]["total_price"] == "2.323470"
assert len(session.queries) == 2
assert "FROM workflow_runs wr" in session.queries[0]
assert "FROM messages m" not in session.queries[0]
assert "WHERE wr.type != :chat_workflow_type" in session.queries[0]
assert "FROM messages m" in session.queries[1]
assert "COUNT(m.id) AS message_count" in session.queries[1]
assert "SUM(COALESCE(m.message_tokens, 0)" in session.queries[1]
def test_merge_daily_statistics_combines_webapp_and_workflow_rows() -> None:
rows = [
{
"date": "2026-07-21",
"message_count": 2,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 30,
"total_price": Decimal("0.003"),
"avg_latency": 1.5,
"latency_sum": 3,
"answer_tokens": 12,
"like_count": 1,
},
{
"date": "2026-07-21",
"message_count": 1,
"conversation_count": 1,
"end_user_count": 1,
"token_count": 20,
"total_price": Decimal("0.002"),
"avg_latency": 2,
"latency_sum": 2,
"answer_tokens": 8,
"like_count": 0,
},
]
merged = AgentObservabilityService._merge_daily_statistics(rows)
assert merged == [
{
"date": "2026-07-21",
"message_count": 3,
"conversation_count": 2,
"end_user_count": 2,
"token_count": 50,
"total_price": Decimal("0.005"),
"avg_latency": pytest.approx(5 / 3),
"latency_sum": 5.0,
"answer_tokens": 20,
"like_count": 1,
}
]
def test_apply_status_filter_accepts_multiple_statuses() -> None:
class FakeStmt:
def __init__(self):
@@ -46,7 +46,7 @@ class TestCatalog:
assert call.tenant_id == "tenant-1"
assert call.account_id == "acct-1"
assert call.json is None
assert call.params == {"billing_enabled": svc.dify_config.BILLING_ENABLED}
assert call.params is None
assert len(out.groups) == 1
assert out.groups[0].group_key == "workspace"
@@ -624,12 +624,12 @@ class TestMyPermissions:
assert out.app.overrides == []
assert out.dataset.overrides == []
if role == "owner":
assert "billing.view" in out.workspace.permission_keys
assert "snippets.management" in out.workspace.permission_keys
assert "app.acl.preview" in out.workspace.permission_keys
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
assert not any(key.startswith("billing.") for key in out.workspace.permission_keys)
if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
@@ -1,4 +1,6 @@
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.orm import Session
from services.rag_pipeline.pipeline_template.built_in.built_in_retrieval import BuiltInPipelineTemplateRetrieval
from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType
@@ -10,7 +12,8 @@ def test_get_type() -> None:
assert retrieval.get_type() == PipelineTemplateType.BUILTIN
def test_get_pipeline_templates(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_templates(mocker: MockerFixture, sqlite_session: Session) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
@@ -22,14 +25,15 @@ def test_get_pipeline_templates(mocker: MockerFixture) -> None:
},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
templates = retrieval.get_pipeline_templates("en-US", session=session)
templates = retrieval.get_pipeline_templates("en-US", session=sqlite_session)
assert templates == {"pipeline_templates": [{"id": "tpl-1"}]}
assert not sqlite_session.in_transaction()
def test_get_pipeline_template_detail(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_template_detail(mocker: MockerFixture, sqlite_session: Session) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
@@ -40,39 +44,45 @@ def test_get_pipeline_template_detail(mocker: MockerFixture) -> None:
},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
detail = retrieval.get_pipeline_template_detail("tpl-1", session=session)
detail = retrieval.get_pipeline_template_detail("tpl-1", session=sqlite_session)
assert detail == {"id": "tpl-1", "name": "Template 1"}
assert not sqlite_session.in_transaction()
def test_get_pipeline_templates_missing_language_returns_empty_dict(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_templates_missing_language_returns_empty_dict(
mocker: MockerFixture, sqlite_session: Session
) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
return_value={"pipeline_templates": {}},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
result = retrieval.get_pipeline_templates("fr-FR", session=session)
result = retrieval.get_pipeline_templates("fr-FR", session=sqlite_session)
assert result == {}
assert not sqlite_session.in_transaction()
def test_get_pipeline_template_detail_returns_none_for_unknown_id(mocker: MockerFixture) -> None:
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_pipeline_template_detail_returns_none_for_unknown_id(
mocker: MockerFixture, sqlite_session: Session
) -> None:
mocker.patch.object(
BuiltInPipelineTemplateRetrieval,
"_get_builtin_data",
return_value={"pipeline_templates": {"tpl-1": {"id": "tpl-1"}}},
)
retrieval = BuiltInPipelineTemplateRetrieval()
session = mocker.Mock()
result = retrieval.get_pipeline_template_detail("nonexistent-id", session=session)
result = retrieval.get_pipeline_template_detail("nonexistent-id", session=sqlite_session)
assert result is None
assert not sqlite_session.in_transaction()
def test_get_builtin_data_reads_from_file_and_caches(mocker: MockerFixture) -> None:
@@ -1,35 +1,54 @@
from types import SimpleNamespace
from pytest_mock import MockerFixture
import pytest
from sqlalchemy.orm import Session
from models.dataset import PipelineBuiltInTemplate
from services.rag_pipeline.pipeline_template.database.database_retrieval import DatabasePipelineTemplateRetrieval
from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType
TEMPLATE_ID = "11111111-1111-1111-1111-111111111111"
def test_get_pipeline_templates(mocker: MockerFixture) -> None:
built_in_template = SimpleNamespace(
id="tpl-1",
name="Template 1",
def _template(
*,
template_id: str = TEMPLATE_ID,
language: str = "en-US",
name: str = "Template 1",
) -> PipelineBuiltInTemplate:
template = PipelineBuiltInTemplate(
name=name,
description="desc",
icon={"background": "#fff"},
copyright="copyright",
privacy_policy="https://example.com/privacy",
position=1,
chunk_structure="general",
yaml_content="workflow:\n graph:\n nodes: []",
install_count=0,
language=language,
)
scalars_mock = mocker.Mock()
scalars_mock.all.return_value = [built_in_template]
session_mock = mocker.Mock()
session_mock.scalars.return_value = scalars_mock
template.id = template_id
return template
@pytest.mark.parametrize("sqlite_session", [(PipelineBuiltInTemplate,)], indirect=True)
def test_get_pipeline_templates(sqlite_session: Session) -> None:
target = _template()
wrong_language = _template(
template_id="22222222-2222-2222-2222-222222222222",
language="zh-Hans",
name="Wrong Language",
)
sqlite_session.add_all([target, wrong_language])
sqlite_session.commit()
retrieval = DatabasePipelineTemplateRetrieval()
result = retrieval.get_pipeline_templates("en-US", session=session_mock)
result = retrieval.get_pipeline_templates("en-US", session=sqlite_session)
assert retrieval.get_type() == PipelineTemplateType.DATABASE
assert result == {
"pipeline_templates": [
{
"id": "tpl-1",
"id": TEMPLATE_ID,
"name": "Template 1",
"description": "desc",
"icon": {"background": "#fff"},
@@ -40,24 +59,19 @@ def test_get_pipeline_templates(mocker: MockerFixture) -> None:
}
]
}
assert sqlite_session.in_transaction()
def test_get_pipeline_template_detail_returns_detail(mocker: MockerFixture) -> None:
session_mock = mocker.Mock()
session_mock.get.return_value = SimpleNamespace(
id="tpl-1",
name="Template 1",
icon={"background": "#fff"},
description="desc",
chunk_structure="general",
yaml_content="workflow:\n graph:\n nodes: []",
)
@pytest.mark.parametrize("sqlite_session", [(PipelineBuiltInTemplate,)], indirect=True)
def test_get_pipeline_template_detail_returns_detail(sqlite_session: Session) -> None:
sqlite_session.add(_template())
sqlite_session.commit()
retrieval = DatabasePipelineTemplateRetrieval()
detail = retrieval.get_pipeline_template_detail("tpl-1", session=session_mock)
detail = retrieval.get_pipeline_template_detail(TEMPLATE_ID, session=sqlite_session)
assert detail == {
"id": "tpl-1",
"id": TEMPLATE_ID,
"name": "Template 1",
"icon_info": {"background": "#fff"},
"description": "desc",
@@ -65,13 +79,14 @@ def test_get_pipeline_template_detail_returns_detail(mocker: MockerFixture) -> N
"export_data": "workflow:\n graph:\n nodes: []",
"graph": {"nodes": []},
}
assert sqlite_session.in_transaction()
def test_get_pipeline_template_detail_returns_none_when_not_found(mocker: MockerFixture) -> None:
session_mock = mocker.Mock()
session_mock.get.return_value = None
@pytest.mark.parametrize("sqlite_session", [(PipelineBuiltInTemplate,)], indirect=True)
def test_get_pipeline_template_detail_returns_none_when_not_found(sqlite_session: Session) -> None:
retrieval = DatabasePipelineTemplateRetrieval()
result = retrieval.get_pipeline_template_detail("missing", session=session_mock)
result = retrieval.get_pipeline_template_detail(TEMPLATE_ID, session=sqlite_session)
assert result is None
assert sqlite_session.in_transaction()
@@ -692,30 +692,41 @@ class TestTenantService:
@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True)
def test_iter_member_account_id_batches_uses_offset_limit(self, sqlite_session: Session):
tenant_id = "00000000-0000-0000-0000-000000000001"
account_ids = [
"00000000-0000-0000-0000-000000000011",
"00000000-0000-0000-0000-000000000012",
"00000000-0000-0000-0000-000000000013",
]
for index, account_id in enumerate(account_ids, start=1):
membership = TenantAccountJoin(
tenant_id="00000000-0000-0000-0000-000000000001",
joins = [
TenantAccountJoin(
tenant_id=tenant_id,
account_id=account_id,
role=TenantAccountRole.NORMAL,
current=False,
)
membership.id = f"00000000-0000-0000-0000-{index:012d}"
sqlite_session.add(membership)
for account_id in account_ids
]
for index, join in enumerate(joins, start=21):
join.id = f"00000000-0000-0000-0000-{index:012d}"
sqlite_session.add_all(joins)
sqlite_session.commit()
batches = list(
TenantService.iter_member_account_id_batches(
"00000000-0000-0000-0000-000000000001",
2,
session=sqlite_session,
)
)
pagination_parameters: list[tuple[int, int]] = []
def record_sql(_conn, _cursor, statement, parameters, _context, _executemany):
if "FROM tenant_account_joins" in statement:
pagination_parameters.append((parameters[-2], parameters[-1]))
bind = sqlite_session.get_bind()
event.listen(bind, "before_cursor_execute", record_sql)
try:
batches = list(TenantService.iter_member_account_id_batches(tenant_id, 2, session=sqlite_session))
finally:
event.remove(bind, "before_cursor_execute", record_sql)
assert batches == [account_ids[:2], account_ids[2:]]
assert pagination_parameters == [(2, 0), (2, 2), (2, 4)]
# ==================== get_account_role_in_tenant Tests ====================
# Backs the auth pipeline's `load_workspace_role`: None => non-member
@@ -1216,7 +1227,8 @@ class TestTenantService:
mock_tenant, mock_operator, mock_member, "remove", session=sqlite_session
)
def test_get_rbac_workspace_owner_account_id(self):
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_get_rbac_workspace_owner_account_id(self, sqlite_session: Session):
mock_roles = Paginated[MembersInRole](data=[MembersInRole(account_id="owner-account")])
mock_rbac_roles = MagicMock()
mock_rbac_roles.members.return_value = mock_roles
@@ -1229,10 +1241,11 @@ class TestTenantService:
patch("services.account_service.RBACService.Roles", mock_rbac_roles),
):
owner_account_id = AccountService.get_rbac_workspace_owner_account_id(
"tenant-1", "acct-1", session=MagicMock()
"tenant-1", "acct-1", session=sqlite_session
)
assert owner_account_id == "owner-account"
assert not sqlite_session.in_transaction()
call = mock_rbac_roles.members.call_args
assert call.kwargs["tenant_id"] == "tenant-1"
assert call.kwargs["account_id"] == "acct-1"
@@ -1,22 +1,38 @@
"""Unit tests for API token caching and SQLite-backed token lookup."""
from __future__ import annotations
from collections.abc import Iterator
from datetime import datetime
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import Unauthorized
import services.api_token_service as api_token_service_module
from models.engine import db
from models.model import ApiToken
from services.api_token_service import ApiTokenCache, CachedApiToken
@pytest.fixture
def api_token_db() -> Iterator[Session]:
"""Provide the production database extension with an isolated SQLite token table."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
ApiToken.__table__.create(db.engine)
with Session(db.engine, expire_on_commit=False) as session:
yield session
class TestQueryTokenFromDb:
def test_should_return_api_token_and_cache_when_token_exists(
self, flask_app_with_containers: Flask, db_session_with_containers
):
def test_should_return_api_token_and_cache_when_token_exists(self, api_token_db: Session) -> None:
tenant_id = str(uuid4())
app_id = str(uuid4())
token_value = f"app-test-{uuid4()}"
@@ -27,8 +43,8 @@ class TestQueryTokenFromDb:
api_token.tenant_id = tenant_id
api_token.type = "app"
api_token.token = token_value
db_session_with_containers.add(api_token)
db_session_with_containers.commit()
api_token_db.add(api_token)
api_token_db.commit()
with (
patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set,
@@ -41,9 +57,7 @@ class TestQueryTokenFromDb:
mock_cache_set.assert_called_once()
mock_record_usage.assert_called_once_with(token_value, "app")
def test_should_cache_null_and_raise_unauthorized_when_token_not_found(
self, flask_app_with_containers: Flask, db_session_with_containers
):
def test_should_cache_null_and_raise_unauthorized_when_token_not_found(self, api_token_db: Session) -> None:
with (
patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set,
patch.object(api_token_service_module, "record_token_usage") as mock_record_usage,
@@ -3,204 +3,156 @@ Comprehensive unit tests for ConversationService.
This file provides complete test coverage for all ConversationService methods.
Tests are organized by functionality and include edge cases, error handling,
and both positive and negative test scenarios.
and both positive and negative test scenarios. Database paths use isolated
in-memory SQLite sessions with persisted ORM rows.
"""
from unittest.mock import MagicMock, Mock, create_autospec, patch
import json
from unittest.mock import patch
import pytest
from sqlalchemy import asc, desc
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.datetime_utils import naive_utc_now
from models import Account, ConversationVariable
from models.model import App, Conversation, EndUser, Message
from models.enums import AppStatus, ConversationFromSource, ConversationStatus
from models.model import App, AppMode, Conversation
from services.conversation_service import ConversationService
TENANT_ID = "11111111-1111-1111-1111-111111111111"
APP_ID = "22222222-2222-2222-2222-222222222222"
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
CONVERSATION_ID = "44444444-4444-4444-4444-444444444444"
VARIABLE_ID = "55555555-5555-5555-5555-555555555555"
OTHER_VARIABLE_ID = "66666666-6666-6666-6666-666666666666"
OTHER_APP_ID = "77777777-7777-7777-7777-777777777777"
OTHER_CONVERSATION_ID = "88888888-8888-8888-8888-888888888888"
OTHER_APP_VARIABLE_ID = "99999999-9999-9999-9999-999999999999"
OTHER_CONVERSATION_VARIABLE_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
def _conversation_variable(
*,
variable_id: str,
name: str,
value: str,
conversation_id: str = CONVERSATION_ID,
app_id: str = APP_ID,
) -> ConversationVariable:
return ConversationVariable(
id=variable_id,
conversation_id=conversation_id,
app_id=app_id,
data=json.dumps(
{
"id": variable_id,
"name": name,
"value_type": "string",
"value": value,
}
),
)
class ConversationServiceTestDataFactory:
"""
Factory for creating test data and mock objects.
Factory for creating test ORM objects.
Provides reusable methods to create consistent mock objects for testing
Provides reusable methods to create consistent model objects for testing
conversation-related operations.
"""
@staticmethod
def create_account_mock(account_id: str = "account-123", **kwargs) -> Mock:
def create_account(account_id: str = ACCOUNT_ID, **kwargs) -> Account:
"""
Create a mock Account object.
Create an Account object.
Args:
account_id: Unique identifier for the account
**kwargs: Additional attributes to set on the mock
**kwargs: Additional attributes to set on the model
Returns:
Mock Account object with specified attributes
Account object with specified attributes
"""
account = create_autospec(Account, instance=True)
account = Account(name="Test User", email="test@example.com")
account.id = account_id
for key, value in kwargs.items():
setattr(account, key, value)
return account
@staticmethod
def create_end_user_mock(user_id: str = "user-123", **kwargs) -> Mock:
def create_app(app_id: str = APP_ID, tenant_id: str = TENANT_ID, **kwargs) -> App:
"""
Create a mock EndUser object.
Args:
user_id: Unique identifier for the end user
**kwargs: Additional attributes to set on the mock
Returns:
Mock EndUser object with specified attributes
"""
user = create_autospec(EndUser, instance=True)
user.id = user_id
for key, value in kwargs.items():
setattr(user, key, value)
return user
@staticmethod
def create_app_mock(app_id: str = "app-123", tenant_id: str = "tenant-123", **kwargs) -> Mock:
"""
Create a mock App object.
Create an App object.
Args:
app_id: Unique identifier for the app
tenant_id: Tenant/workspace identifier
**kwargs: Additional attributes to set on the mock
**kwargs: Additional attributes to set on the model
Returns:
Mock App object with specified attributes
App object with specified attributes
"""
app = create_autospec(App, instance=True)
app.id = app_id
app.tenant_id = tenant_id
app.name = kwargs.get("name", "Test App")
app.mode = kwargs.get("mode", "chat")
app.status = kwargs.get("status", "normal")
app = App(
id=app_id,
tenant_id=tenant_id,
name=kwargs.get("name", "Test App"),
mode=kwargs.get("mode", AppMode.CHAT),
status=kwargs.get("status", AppStatus.NORMAL),
description="",
enable_site=False,
enable_api=False,
max_active_requests=None,
)
for key, value in kwargs.items():
setattr(app, key, value)
return app
@staticmethod
def create_conversation_mock(
conversation_id: str = "conv-123",
app_id: str = "app-123",
from_source: str = "console",
def create_conversation(
conversation_id: str = CONVERSATION_ID,
app_id: str = APP_ID,
from_source: ConversationFromSource = ConversationFromSource.CONSOLE,
**kwargs,
) -> Mock:
) -> Conversation:
"""
Create a mock Conversation object.
Create a Conversation object.
Args:
conversation_id: Unique identifier for the conversation
app_id: Associated app identifier
from_source: Source of conversation ('console' or 'api')
**kwargs: Additional attributes to set on the mock
**kwargs: Additional attributes to set on the model
Returns:
Mock Conversation object with specified attributes
Conversation object with specified attributes
"""
conversation = create_autospec(Conversation, instance=True)
conversation.id = conversation_id
conversation.app_id = app_id
conversation.from_source = from_source
conversation.from_end_user_id = kwargs.get("from_end_user_id")
conversation.from_account_id = kwargs.get("from_account_id")
conversation.is_deleted = kwargs.get("is_deleted", False)
conversation.name = kwargs.get("name", "Test Conversation")
conversation.status = kwargs.get("status", "normal")
conversation.created_at = kwargs.get("created_at", naive_utc_now())
conversation.updated_at = kwargs.get("updated_at", naive_utc_now())
conversation = Conversation(
id=conversation_id,
app_id=app_id,
mode=AppMode.CHAT,
name=kwargs.get("name", "Test Conversation"),
status=kwargs.get("status", ConversationStatus.NORMAL),
from_source=from_source,
from_end_user_id=kwargs.get("from_end_user_id"),
from_account_id=kwargs.get("from_account_id", ACCOUNT_ID),
is_deleted=kwargs.get("is_deleted", False),
created_at=kwargs.get("created_at", naive_utc_now()),
updated_at=kwargs.get("updated_at", naive_utc_now()),
)
conversation._inputs = {}
for key, value in kwargs.items():
setattr(conversation, key, value)
return conversation
@staticmethod
def create_message_mock(
message_id: str = "msg-123",
conversation_id: str = "conv-123",
app_id: str = "app-123",
**kwargs,
) -> Mock:
"""
Create a mock Message object.
Args:
message_id: Unique identifier for the message
conversation_id: Associated conversation identifier
app_id: Associated app identifier
**kwargs: Additional attributes to set on the mock
Returns:
Mock Message object with specified attributes
"""
message = create_autospec(Message, instance=True)
message.id = message_id
message.conversation_id = conversation_id
message.app_id = app_id
message.query = kwargs.get("query", "Test message content")
message.created_at = kwargs.get("created_at", naive_utc_now())
for key, value in kwargs.items():
setattr(message, key, value)
return message
@staticmethod
def create_conversation_variable_mock(
variable_id: str = "var-123",
conversation_id: str = "conv-123",
app_id: str = "app-123",
**kwargs,
) -> Mock:
"""
Create a mock ConversationVariable object.
Args:
variable_id: Unique identifier for the variable
conversation_id: Associated conversation identifier
app_id: Associated app identifier
**kwargs: Additional attributes to set on the mock
Returns:
Mock ConversationVariable object with specified attributes
"""
variable = create_autospec(ConversationVariable, instance=True)
variable.id = variable_id
variable.conversation_id = conversation_id
variable.app_id = app_id
variable.data = {"name": kwargs.get("name", "test_var"), "value": kwargs.get("value", "test_value")}
variable.created_at = kwargs.get("created_at", naive_utc_now())
variable.updated_at = kwargs.get("updated_at", naive_utc_now())
# Mock to_variable method
mock_variable = Mock()
mock_variable.id = variable_id
mock_variable.name = kwargs.get("name", "test_var")
mock_variable.value_type = kwargs.get("value_type", "string")
mock_variable.value = kwargs.get("value", "test_value")
mock_variable.description = kwargs.get("description", "")
mock_variable.selector = kwargs.get("selector", {})
mock_variable.model_dump.return_value = {
"id": variable_id,
"name": kwargs.get("name", "test_var"),
"value_type": kwargs.get("value_type", "string"),
"value": kwargs.get("value", "test_value"),
"description": kwargs.get("description", ""),
"selector": kwargs.get("selector", {}),
}
variable.to_variable.return_value = mock_variable
for key, value in kwargs.items():
setattr(variable, key, value)
return variable
@pytest.mark.parametrize("sqlite_session", [(Conversation,)], indirect=True)
class TestConversationServicePagination:
"""Test conversation pagination operations."""
def test_pagination_with_empty_include_ids(self):
def test_pagination_with_empty_include_ids(self, sqlite_session: Session):
"""
Test that empty include_ids returns empty result.
@@ -208,15 +160,14 @@ class TestConversationServicePagination:
and return empty results without querying the database.
"""
# Arrange - Set up test data
mock_session = MagicMock() # Mock database session
mock_app_model = ConversationServiceTestDataFactory.create_app_mock()
mock_user = ConversationServiceTestDataFactory.create_account_mock()
app_model = ConversationServiceTestDataFactory.create_app()
user = ConversationServiceTestDataFactory.create_account()
# Act - Call the service method with empty include_ids
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
user=mock_user,
session=sqlite_session,
app_model=app_model,
user=user,
last_id=None,
limit=20,
invoke_from=InvokeFrom.WEB_APP,
@@ -228,21 +179,21 @@ class TestConversationServicePagination:
assert result.data == [] # No conversations returned
assert result.has_more is False # No more pages available
assert result.limit == 20 # Limit preserved in response
assert not sqlite_session.in_transaction()
def test_pagination_returns_empty_when_user_is_none(self):
def test_pagination_returns_empty_when_user_is_none(self, sqlite_session: Session):
"""
Test that pagination returns empty result when user is None.
This ensures proper handling of unauthenticated requests.
"""
# Arrange
mock_session = MagicMock()
mock_app_model = ConversationServiceTestDataFactory.create_app_mock()
app_model = ConversationServiceTestDataFactory.create_app()
# Act
result = ConversationService.pagination_by_last_id(
session=mock_session,
app_model=mock_app_model,
session=sqlite_session,
app_model=app_model,
user=None, # No user provided
last_id=None,
limit=20,
@@ -253,6 +204,7 @@ class TestConversationServicePagination:
assert result.data == []
assert result.has_more is False
assert result.limit == 20
assert not sqlite_session.in_transaction()
class TestConversationServiceHelpers:
@@ -291,14 +243,14 @@ class TestConversationServiceHelpers:
Should create a less-than filter condition.
"""
# Arrange
mock_conversation = ConversationServiceTestDataFactory.create_conversation_mock()
mock_conversation.updated_at = naive_utc_now()
conversation = ConversationServiceTestDataFactory.create_conversation()
conversation.updated_at = naive_utc_now()
# Act
condition = ConversationService._build_filter_condition(
sort_field="updated_at",
sort_direction=desc,
reference_conversation=mock_conversation,
reference_conversation=conversation,
)
# Assert
@@ -312,14 +264,14 @@ class TestConversationServiceHelpers:
Should create a greater-than filter condition.
"""
# Arrange
mock_conversation = ConversationServiceTestDataFactory.create_conversation_mock()
mock_conversation.created_at = naive_utc_now()
conversation = ConversationServiceTestDataFactory.create_conversation()
conversation.created_at = naive_utc_now()
# Act
condition = ConversationService._build_filter_condition(
sort_field="created_at",
sort_direction=asc,
reference_conversation=mock_conversation,
reference_conversation=conversation,
)
# Assert
@@ -330,36 +282,71 @@ class TestConversationServiceHelpers:
class TestConversationServiceConversationalVariable:
"""Test conversational variable operations."""
@patch("services.conversation_service.ConversationService.get_conversation")
@pytest.mark.parametrize("sqlite_session", [(Conversation, ConversationVariable)], indirect=True)
@patch("services.conversation_service.dify_config")
def test_get_conversational_variable_with_name_filter_mysql(self, mock_config, mock_get_conversation):
def test_get_conversational_variable_with_name_filter_mysql(
self,
mock_config,
sqlite_session: Session,
):
"""
Test variable filtering by name for MySQL databases.
Should apply JSON extraction filter for variable names.
"""
# Arrange
app_model = ConversationServiceTestDataFactory.create_app_mock()
user = ConversationServiceTestDataFactory.create_account_mock()
conversation = ConversationServiceTestDataFactory.create_conversation_mock()
mock_get_conversation.return_value = conversation
app_model = ConversationServiceTestDataFactory.create_app()
user = ConversationServiceTestDataFactory.create_account()
conversation = ConversationServiceTestDataFactory.create_conversation()
matching_variable = _conversation_variable(
variable_id=VARIABLE_ID,
name="test_var",
value="matching",
)
other_variable = _conversation_variable(
variable_id=OTHER_VARIABLE_ID,
name="unrelated",
value="excluded",
)
other_app_variable = _conversation_variable(
variable_id=OTHER_APP_VARIABLE_ID,
name="test_var",
value="other-app",
app_id=OTHER_APP_ID,
)
other_conversation_variable = _conversation_variable(
variable_id=OTHER_CONVERSATION_VARIABLE_ID,
name="test_var",
value="other-conversation",
conversation_id=OTHER_CONVERSATION_ID,
)
sqlite_session.add_all(
[
conversation,
matching_variable,
other_variable,
other_app_variable,
other_conversation_variable,
]
)
sqlite_session.commit()
mock_config.DB_TYPE = "mysql"
# Mock session
mock_session = MagicMock()
mock_session.scalars.return_value.all.return_value = []
# Act
ConversationService.get_conversational_variable(
result = ConversationService.get_conversational_variable(
app_model=app_model,
conversation_id="conv-123",
conversation_id=CONVERSATION_ID,
user=user,
limit=10,
last_id=None,
variable_name="test_var",
session=mock_session,
session=sqlite_session,
)
# Assert - JSON filter should be applied
assert mock_session.scalars.called
# Assert - SQLite executes the MySQL-compatible JSON extraction boundary.
assert result.has_more is False
assert result.limit == 10
assert len(result.data) == 1
assert result.data[0]["id"] == VARIABLE_ID
assert result.data[0]["name"] == "test_var"
assert result.data[0]["value"] == "matching"
@@ -0,0 +1,30 @@
import pytest
from services.feature_service import FeatureService, SystemFeatureModel
@pytest.mark.parametrize(
("env_value", "expected"),
[
(False, False),
(True, True),
],
ids=["disabled_by_env", "enabled_by_env"],
)
def test_fulfill_system_params_from_env_sets_allow_public_access(
monkeypatch: pytest.MonkeyPatch,
env_value: bool,
expected: bool,
):
monkeypatch.setattr("services.feature_service.dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED", env_value)
system_features = SystemFeatureModel()
FeatureService._fulfill_system_params_from_env(system_features)
assert system_features.webapp_auth.allow_public_access is expected
def test_get_system_features_defaults_allow_public_access_to_true():
system_features = FeatureService.get_system_features()
assert system_features.webapp_auth.allow_public_access is True
@@ -1,6 +1,7 @@
"""Unit tests for human-input test delivery with SQLite-backed member lookup."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
@@ -19,7 +20,9 @@ from core.workflow.human_input_adapter import (
)
from graphon.runtime import VariablePool
from models.account import Account, TenantAccountJoin
from models.engine import db
from services import human_input_delivery_test_service as service_module
from services.feature_service import FeatureModel
from services.human_input_delivery_test_service import (
DeliveryTestContext,
DeliveryTestEmailRecipient,
@@ -90,8 +93,14 @@ class TestDeliveryTestRegistry:
with pytest.raises(DeliveryTestUnsupportedError, match="Delivery method does not support test send."):
registry.dispatch(context=context, method=method)
def test_default(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
registry = DeliveryTestRegistry.default()
def test_default(self) -> None:
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
registry = DeliveryTestRegistry.default()
assert len(registry._handlers) == 1
assert isinstance(registry._handlers[0], EmailDeliveryTestHandler)
@@ -112,24 +121,24 @@ class TestEmailDeliveryTestHandler:
handler = EmailDeliveryTestHandler(session_factory=engine)
assert handler._session_factory.kw["bind"] == engine
def test_supports(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_supports(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(config=_make_valid_email_config())
assert handler.supports(method) is True
assert handler.supports(MagicMock()) is False
def test_send_test_unsupported_method(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_send_test_unsupported_method(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
with pytest.raises(DeliveryTestUnsupportedError):
handler.send_test(context=MagicMock(), method=MagicMock())
def test_send_test_feature_disabled(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_feature_disabled(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=False),
lambda _tenant_id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=False),
)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
context = DeliveryTestContext(
tenant_id="t1", app_id="a1", node_id="n1", node_title="title", rendered_content="content"
)
@@ -138,15 +147,15 @@ class TestEmailDeliveryTestHandler:
with pytest.raises(DeliveryTestError, match="Email delivery is not available"):
handler.send_test(context=context, method=method)
def test_send_test_mail_not_inited(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_mail_not_inited(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: False)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
context = DeliveryTestContext(
tenant_id="t1", app_id="a1", node_id="n1", node_title="title", rendered_content="content"
)
@@ -155,15 +164,15 @@ class TestEmailDeliveryTestHandler:
with pytest.raises(DeliveryTestError, match="Mail client is not initialized."):
handler.send_test(context=context, method=method)
def test_send_test_no_recipients(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_no_recipients(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
handler._resolve_recipients = MagicMock(return_value=[])
context = DeliveryTestContext(
@@ -174,18 +183,18 @@ class TestEmailDeliveryTestHandler:
with pytest.raises(DeliveryTestError, match="No recipients configured"):
handler.send_test(context=context, method=method)
def test_send_test_success(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_success(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
mock_mail_send = MagicMock()
monkeypatch.setattr(service_module.mail, "send", mock_mail_send)
monkeypatch.setattr(service_module, "render_email_template", lambda t, s: f"RENDERED_{t}")
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
variable_pool = VariablePool()
@@ -210,11 +219,11 @@ class TestEmailDeliveryTestHandler:
assert kwargs["to"] == "test@example.com"
assert "RENDERED_Subj" in kwargs["subject"]
def test_send_test_sanitizes_subject(self, monkeypatch: pytest.MonkeyPatch):
def test_send_test_sanitizes_subject(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
mock_mail_send = MagicMock()
@@ -225,7 +234,7 @@ class TestEmailDeliveryTestHandler:
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
context = DeliveryTestContext(
@@ -249,8 +258,8 @@ class TestEmailDeliveryTestHandler:
_, kwargs = mock_mail_send.call_args
assert kwargs["subject"] == "Notice BCC:test@example.com"
def test_resolve_recipients_external(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_resolve_recipients_external(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(
@@ -262,19 +271,18 @@ class TestEmailDeliveryTestHandler:
)
assert handler._resolve_recipients(tenant_id="t1", method=method) == ["ext@example.com"]
def test_resolve_recipients_member(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_resolve_recipients_member(self, sqlite_engine: Engine, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
account = Account(name="Test User", email="member@example.com")
db_session_with_containers.add(account)
db_session_with_containers.commit()
sqlite_session.add(account)
sqlite_session.commit()
join = TenantAccountJoin(tenant_id=tenant_id, account_id=account.id)
db_session_with_containers.add(join)
db_session_with_containers.commit()
sqlite_session.add(join)
sqlite_session.commit()
from extensions.ext_database import db
handler = EmailDeliveryTestHandler(session_factory=db.engine)
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(items=[MemberRecipient(reference_id=account.id)], include_bound_group=False),
@@ -284,23 +292,20 @@ class TestEmailDeliveryTestHandler:
)
assert handler._resolve_recipients(tenant_id=tenant_id, method=method) == ["member@example.com"]
def test_resolve_recipients_whole_workspace(
self, flask_app_with_containers: Flask, db_session_with_containers: Session
):
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
def test_resolve_recipients_whole_workspace(self, sqlite_engine: Engine, sqlite_session: Session) -> None:
tenant_id = str(uuid4())
account1 = Account(name="User 1", email=f"u1-{uuid4()}@example.com")
account2 = Account(name="User 2", email=f"u2-{uuid4()}@example.com")
db_session_with_containers.add_all([account1, account2])
db_session_with_containers.commit()
sqlite_session.add_all([account1, account2])
sqlite_session.commit()
for acc in [account1, account2]:
join = TenantAccountJoin(tenant_id=tenant_id, account_id=acc.id)
db_session_with_containers.add(join)
db_session_with_containers.commit()
sqlite_session.add(join)
sqlite_session.commit()
from extensions.ext_database import db
handler = EmailDeliveryTestHandler(session_factory=db.engine)
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(items=[], include_bound_group=True),
@@ -311,8 +316,8 @@ class TestEmailDeliveryTestHandler:
recipients = handler._resolve_recipients(tenant_id=tenant_id, method=method)
assert set(recipients) == {account1.email, account2.email}
def test_query_workspace_member_emails_empty_ids(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
def test_query_workspace_member_emails_empty_ids(self, sqlite_engine: Engine) -> None:
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
assert handler._query_workspace_member_emails(tenant_id="t1", user_ids=[]) == {}
def test_build_substitutions(self):
@@ -5,11 +5,46 @@ from unittest.mock import Mock, patch
import pytest
from socketio.exceptions import TimeoutError as SocketIOTimeoutError
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from models.base import TypeBase
from models.model import App, AppMode, IconType
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
from services.workflow_collaboration_service import SYNC_REQUEST_TIMEOUT_SECONDS, WorkflowCollaborationService
@pytest.fixture
def db_session(sqlite_engine: Engine) -> Iterator[Session]:
"""Provide a real session for tenant-scoped workflow app access checks."""
TypeBase.metadata.create_all(sqlite_engine, tables=[App.__table__])
with Session(sqlite_engine, expire_on_commit=False) as session:
yield session
def _app(*, app_id: str, tenant_id: str) -> App:
return App(
id=app_id,
tenant_id=tenant_id,
name="Workflow app",
description="",
mode=AppMode.WORKFLOW,
icon_type=IconType.EMOJI,
icon="robot",
icon_background="#ffffff",
enable_site=False,
enable_api=False,
api_rpm=0,
api_rph=0,
is_demo=False,
is_public=False,
is_universal=False,
max_active_requests=None,
use_icon_as_answer_icon=False,
)
class TestWorkflowCollaborationService:
@pytest.fixture
def service(self) -> tuple[WorkflowCollaborationService, Mock, Mock]:
@@ -19,7 +54,7 @@ class TestWorkflowCollaborationService:
return WorkflowCollaborationService(repository, socketio, server_id="server-1"), repository, socketio
def test_authorize_and_join_workflow_room_returns_leader_status(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
# Arrange
collaboration_service, repository, socketio = service
@@ -36,7 +71,7 @@ class TestWorkflowCollaborationService:
patch.object(collaboration_service, "broadcast_online_users"),
):
# Act
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
# Assert
assert result == ("u-1", True)
@@ -49,25 +84,25 @@ class TestWorkflowCollaborationService:
socketio.emit.assert_called_once_with("status", {"isLeader": True}, room="sid-1")
def test_authorize_and_join_workflow_room_returns_none_when_missing_user(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
# Arrange
collaboration_service, _repository, socketio = service
socketio.get_session.return_value = {}
# Act
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
# Assert
assert result is None
def test_authorize_and_join_workflow_room_returns_none_when_missing_tenant(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
collaboration_service, repository, socketio = service
socketio.get_session.return_value = {"user_id": "u-1", "username": "Jane", "avatar": None}
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
assert result is None
repository.set_session_info.assert_not_called()
@@ -75,7 +110,7 @@ class TestWorkflowCollaborationService:
socketio.emit.assert_not_called()
def test_authorize_and_join_workflow_room_returns_none_when_workflow_is_not_accessible(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
collaboration_service, repository, socketio = service
socketio.get_session.return_value = {
@@ -86,7 +121,7 @@ class TestWorkflowCollaborationService:
}
with patch.object(collaboration_service, "_can_access_workflow", return_value=False):
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
assert result is None
repository.set_session_info.assert_not_called()
@@ -110,15 +145,23 @@ class TestWorkflowCollaborationService:
{"user_id": "u-1", "username": "Jane", "avatar": "avatar.png", "tenant_id": "t-1"},
)
def test_can_access_workflow_uses_session(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None:
def test_can_access_workflow_uses_session(
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
) -> None:
collaboration_service, _repository, _socketio = service
session = Mock()
session.scalar.return_value = "wf-1"
db_session.add_all(
[
_app(app_id="wf-1", tenant_id="tenant-1"),
_app(app_id="wf-other", tenant_id="tenant-other"),
]
)
db_session.commit()
result = collaboration_service._can_access_workflow("wf-1", "tenant-1", session=session)
result = collaboration_service._can_access_workflow("wf-1", "tenant-1", session=db_session)
assert result is True
session.scalar.assert_called_once()
assert collaboration_service._can_access_workflow("wf-1", "tenant-other", session=db_session) is False
assert collaboration_service._can_access_workflow("wf-other", "tenant-other", session=db_session) is True
def test_relay_collaboration_event_unauthorized(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
@@ -5,7 +5,6 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import pytest
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from core.workflow.system_variables import SystemVariableKey
@@ -35,17 +34,11 @@ from services.workflow_draft_variable_service import (
_model_to_insertion_dict,
)
@pytest.fixture
def mock_engine() -> Engine:
return Mock(spec=Engine)
@pytest.fixture
def mock_session(mock_engine) -> Session:
mock_session = Mock(spec=Session)
mock_session.get_bind.return_value = mock_engine
return mock_session
SQLITE_MODELS = (Workflow, WorkflowDraftVariable, WorkflowDraftVariableFile, WorkflowNodeExecutionModel)
pytestmark = [
pytest.mark.usefixtures("sqlite_session"),
pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True),
]
class TestDraftVariableSaver:
@@ -53,13 +46,12 @@ class TestDraftVariableSaver:
suffix = secrets.token_hex(6)
return f"test_app_id_{suffix}"
def test__should_variable_be_visible(self):
mock_session = MagicMock(spec=Session)
def test__should_variable_be_visible(self, sqlite_session: Session):
mock_user = Account(name="test", email="test@example.com")
mock_user.id = str(uuid.uuid4())
test_app_id = self._get_test_app_id()
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id=test_app_id,
node_id="test_node_id",
@@ -70,7 +62,7 @@ class TestDraftVariableSaver:
assert saver._should_variable_be_visible("123_456", BuiltinNodeTypes.IF_ELSE, "output") == False
assert saver._should_variable_be_visible("123", BuiltinNodeTypes.START, "output") == True
def test__normalize_variable_for_start_node(self):
def test__normalize_variable_for_start_node(self, sqlite_session: Session):
@dataclasses.dataclass(frozen=True)
class TestCase:
name: str
@@ -118,11 +110,10 @@ class TestDraftVariableSaver:
),
]
mock_session = MagicMock(spec=Session)
mock_user = MagicMock()
test_app_id = self._get_test_app_id()
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id=test_app_id,
node_id=_NODE_ID,
@@ -136,12 +127,11 @@ class TestDraftVariableSaver:
assert node_id == c.expected_node_id, fail_msg
assert name == c.expected_name, fail_msg
def test_build_variables_from_start_mapping_rebuilds_system_files(self):
mock_session = MagicMock(spec=Session)
def test_build_variables_from_start_mapping_rebuilds_system_files(self, sqlite_session: Session):
mock_user = MagicMock(spec=Account)
mock_user.id = str(uuid.uuid4())
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="tenant-1",
app_id=self._get_test_app_id(),
node_id="start",
@@ -176,17 +166,7 @@ class TestDraftVariableSaver:
rebuild_file.assert_called_once_with(file_mapping=raw_file, tenant_id="tenant-1")
@pytest.fixture
def mock_session(self):
"""Mock SQLAlchemy session."""
from sqlalchemy import Engine
mock_session = MagicMock(spec=Session)
mock_engine = MagicMock(spec=Engine)
mock_session.get_bind.return_value = mock_engine
return mock_session
@pytest.fixture
def draft_saver(self, mock_session):
def draft_saver(self, sqlite_session: Session):
"""Create DraftVariableSaver instance with user context."""
# Create a mock user
mock_user = MagicMock(spec=Account)
@@ -194,7 +174,7 @@ class TestDraftVariableSaver:
mock_user.tenant_id = "test-tenant-id"
return DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id="test-app-id",
node_id="test-node-id",
@@ -203,7 +183,7 @@ class TestDraftVariableSaver:
user=mock_user,
)
def test_draft_saver_with_small_variables(self, draft_saver: DraftVariableSaver, mock_session):
def test_draft_saver_with_small_variables(self, draft_saver: DraftVariableSaver):
with patch(
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
) as _mock_try_offload:
@@ -215,7 +195,7 @@ class TestDraftVariableSaver:
assert draft_var.file_id is None
_mock_try_offload.return_value = None
def test_draft_saver_with_large_variables(self, draft_saver: DraftVariableSaver, mock_session):
def test_draft_saver_with_large_variables(self, draft_saver: DraftVariableSaver):
with patch(
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
) as _mock_try_offload:
@@ -237,12 +217,12 @@ class TestDraftVariableSaver:
# Should not have large variable metadata
assert draft_var.file_id == mock_draft_var_file.id
def test_try_offload_large_variable_uses_resource_tenant(self, mock_session):
def test_try_offload_large_variable_uses_resource_tenant(self, sqlite_session: Session):
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.current_tenant_id = ""
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="app-tenant-id",
app_id="test-app-id",
node_id="test-node-id",
@@ -284,15 +264,16 @@ class TestDraftVariableSaver:
assert len(draft_vars) == 2
@patch("services.workflow_draft_variable_service._batch_upsert_draft_variable", autospec=True)
def test_start_node_save_persists_sys_timestamp_and_workflow_run_id(self, mock_batch_upsert):
def test_start_node_save_persists_sys_timestamp_and_workflow_run_id(
self, mock_batch_upsert, sqlite_session: Session
):
"""Start node should persist common `sys.*` variables, not only `sys.files`."""
mock_session = MagicMock(spec=Session)
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.tenant_id = "test-tenant-id"
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id="test-app-id",
node_id="start-node-id",
@@ -321,14 +302,13 @@ class TestDraftVariableSaver:
}
@patch("services.workflow_draft_variable_service._batch_upsert_draft_variable", autospec=True)
def test_start_node_save_normalizes_reserved_prefix_outputs(self, mock_batch_upsert):
mock_session = MagicMock(spec=Session)
def test_start_node_save_normalizes_reserved_prefix_outputs(self, mock_batch_upsert, sqlite_session: Session):
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.tenant_id = "test-tenant-id"
saver = DraftVariableSaver(
session=mock_session,
session=sqlite_session,
tenant_id="test-tenant-id",
app_id="test-app-id",
node_id="start-node-id",
@@ -382,8 +362,8 @@ class TestWorkflowDraftVariableService:
rag_pipeline_variables=[],
)
def test_list_variables_without_values_excludes_node_ids(self, mock_session):
service = WorkflowDraftVariableService(mock_session)
def test_list_variables_without_values_excludes_node_ids(self, sqlite_session: Session):
service = WorkflowDraftVariableService(sqlite_session)
variable = WorkflowDraftVariable.new_node_variable(
app_id="app-1",
node_id="node-1",
@@ -391,8 +371,21 @@ class TestWorkflowDraftVariableService:
value=StringSegment(value="value"),
node_execution_id="execution-1",
)
mock_session.scalar.return_value = 1
mock_session.scalars.return_value = [variable]
variable.user_id = "user-1"
excluded_system = WorkflowDraftVariable.new_sys_variable(
app_id="app-1", name="query", value=StringSegment(value="hidden"), node_execution_id="execution-1"
)
excluded_system.user_id = "user-1"
other_user = WorkflowDraftVariable.new_node_variable(
app_id="app-1",
node_id="node-2",
name="output",
value=StringSegment(value="other"),
node_execution_id="execution-2",
)
other_user.user_id = "user-2"
sqlite_session.add_all([variable, excluded_system, other_user])
sqlite_session.commit()
result = service.list_variables_without_values(
app_id="app-1",
@@ -403,16 +396,11 @@ class TestWorkflowDraftVariableService:
)
assert result.total == 1
assert result.variables == [variable]
assert [item.id for item in result.variables] == [variable.id]
stmt = mock_session.scalars.call_args.args[0]
compiled = stmt.compile()
excluded_node_ids = next(value for value in compiled.params.values() if isinstance(value, (list, tuple)))
assert set(excluded_node_ids) == {SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
def test_reset_conversation_variable(self, mock_session):
def test_reset_conversation_variable(self, sqlite_session: Session):
"""Test resetting a conversation variable"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -435,9 +423,9 @@ class TestWorkflowDraftVariableService:
mock_reset_conv.assert_called_once_with(workflow, variable)
assert result == expected_result
def test_reset_node_variable_with_no_execution_id(self, mock_session):
def test_reset_node_variable_with_no_execution_id(self, sqlite_session: Session):
"""Test resetting a node variable with no execution ID - should delete variable"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -453,33 +441,18 @@ class TestWorkflowDraftVariableService:
)
# Manually set to None to simulate the test condition
variable.node_execution_id = None
sqlite_session.add(variable)
sqlite_session.commit()
result = service._reset_node_var_or_sys_var(workflow, variable)
# Should delete the variable and return None
mock_session.delete.assert_called_once_with(instance=variable)
mock_session.flush.assert_called_once()
assert sqlite_session.get(WorkflowDraftVariable, variable.id) is None
assert result is None
def test_reset_node_variable_with_missing_execution_record(
self,
mock_engine,
mock_session,
monkeypatch: pytest.MonkeyPatch,
):
def test_reset_node_variable_with_missing_execution_record(self, sqlite_session: Session):
"""Test resetting a node variable when execution record doesn't exist"""
mock_repo_session = Mock(spec=Session)
mock_session_maker = MagicMock()
# Mock the context manager protocol for sessionmaker
mock_session_maker.return_value.__enter__.return_value = mock_repo_session
mock_session_maker.return_value.__exit__.return_value = None
monkeypatch.setattr("services.workflow_draft_variable_service.sessionmaker", mock_session_maker)
service = WorkflowDraftVariableService(mock_session)
# Mock the repository to return None (no execution record found)
service._api_node_execution_repo = Mock()
service._api_node_execution_repo.get_execution_by_id.return_value = None
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -489,32 +462,17 @@ class TestWorkflowDraftVariableService:
variable = WorkflowDraftVariable.new_node_variable(
app_id=test_app_id, node_id="test_node_id", name="test_var", value=test_value, node_execution_id="exec-id"
)
# Variable is editable by default from factory method
sqlite_session.add(variable)
sqlite_session.commit()
result = service._reset_node_var_or_sys_var(workflow, variable)
mock_session_maker.assert_called_once_with(bind=mock_engine, expire_on_commit=False)
# Should delete the variable and return None
mock_session.delete.assert_called_once_with(instance=variable)
mock_session.flush.assert_called_once()
assert sqlite_session.get(WorkflowDraftVariable, variable.id) is None
assert result is None
def test_reset_node_variable_with_valid_execution_record(
self,
mock_session,
monkeypatch: pytest.MonkeyPatch,
):
"""Test resetting a node variable with valid execution record - should restore from execution"""
mock_repo_session = Mock(spec=Session)
mock_session_maker = MagicMock()
# Mock the context manager protocol for sessionmaker
mock_session_maker.return_value.__enter__.return_value = mock_repo_session
mock_session_maker.return_value.__exit__.return_value = None
mock_session_maker = monkeypatch.setattr(
"services.workflow_draft_variable_service.sessionmaker", mock_session_maker
)
service = WorkflowDraftVariableService(mock_session)
def test_reset_node_variable_with_valid_execution_record(self, sqlite_session: Session):
"""Reset a node variable from its execution output and flush the restored value."""
service = WorkflowDraftVariableService(sqlite_session)
# Create mock execution record
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
@@ -532,11 +490,13 @@ class TestWorkflowDraftVariableService:
variable = WorkflowDraftVariable.new_node_variable(
app_id=test_app_id, node_id="test_node_id", name="test_var", value=test_value, node_execution_id="exec-id"
)
# Variable is editable by default from factory method
sqlite_session.add(variable)
sqlite_session.commit()
# Mock workflow methods
mock_node_config = {"type": "test_node"}
with (
patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush,
patch.object(workflow, "get_node_config_by_id", return_value=mock_node_config, autospec=True),
patch.object(workflow, "get_node_type_from_node_config", return_value=BuiltinNodeTypes.LLM, autospec=True),
):
@@ -544,15 +504,13 @@ class TestWorkflowDraftVariableService:
# Verify last_edited_at was reset
assert variable.last_edited_at is None
# Verify session.flush was called
mock_session.flush.assert_called()
flush.assert_called()
# Should return the updated variable
assert result == variable
def test_reset_non_editable_system_variable_raises_error(self, mock_session):
def test_reset_non_editable_system_variable_raises_error(self, sqlite_session: Session):
"""Test that resetting a non-editable system variable raises an error"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -572,9 +530,9 @@ class TestWorkflowDraftVariableService:
assert "cannot reset system variable" in str(exc_info.value)
assert f"variable_id={variable.id}" in str(exc_info.value)
def test_reset_editable_system_variable_succeeds(self, mock_session):
def test_reset_editable_system_variable_succeeds(self, sqlite_session: Session):
"""Test that resetting an editable system variable succeeds"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -588,6 +546,8 @@ class TestWorkflowDraftVariableService:
node_execution_id="exec-id",
editable=True, # Editable system variable
)
sqlite_session.add(variable)
sqlite_session.commit()
# Create mock execution record
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
@@ -597,16 +557,17 @@ class TestWorkflowDraftVariableService:
service._api_node_execution_repo = Mock()
service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
result = service._reset_node_var_or_sys_var(workflow, variable)
with patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush:
result = service._reset_node_var_or_sys_var(workflow, variable)
# Should succeed and return the variable
assert result == variable
assert variable.last_edited_at is None
mock_session.flush.assert_called()
flush.assert_called()
def test_reset_query_system_variable_succeeds(self, mock_session):
def test_reset_query_system_variable_succeeds(self, sqlite_session: Session):
"""Test that resetting query system variable (another editable one) succeeds"""
service = WorkflowDraftVariableService(mock_session)
service = WorkflowDraftVariableService(sqlite_session)
test_app_id = self._get_test_app_id()
workflow = self._create_test_workflow(test_app_id)
@@ -620,6 +581,8 @@ class TestWorkflowDraftVariableService:
node_execution_id="exec-id",
editable=True, # Editable system variable
)
sqlite_session.add(variable)
sqlite_session.commit()
# Create mock execution record
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
@@ -629,12 +592,13 @@ class TestWorkflowDraftVariableService:
service._api_node_execution_repo = Mock()
service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
result = service._reset_node_var_or_sys_var(workflow, variable)
with patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush:
result = service._reset_node_var_or_sys_var(workflow, variable)
# Should succeed and return the variable
assert result == variable
assert variable.last_edited_at is None
mock_session.flush.assert_called()
flush.assert_called()
def test_system_variable_editability_check(self):
"""Test the system variable editability function directly"""
@@ -13,9 +13,13 @@ import pytest
from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.task_entities import StreamEvent
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.app.layers.pause_state_persist_layer import (
WorkflowResumptionContext,
_AdvancedChatAppGenerateEntityWrapper,
_WorkflowGenerateEntityWrapper,
)
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource
from core.workflow.nodes.human_input.enums import ValueSourceType
@@ -251,6 +255,34 @@ def _build_resumption_context_additional(task_id: str) -> WorkflowResumptionCont
)
def _build_advanced_chat_resumption_context(conversation_id: str | None) -> WorkflowResumptionContext:
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant-1",
app_id="app-1",
app_mode=AppMode.ADVANCED_CHAT,
workflow_id="workflow-1",
)
generate_entity = AdvancedChatAppGenerateEntity(
task_id="task-ctx",
app_config=app_config,
inputs={},
files=[],
user_id="user-1",
stream=True,
invoke_from=InvokeFrom.EXPLORE,
call_depth=0,
conversation_id=conversation_id,
workflow_run_id="run-1",
query="hello",
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
wrapper = _AdvancedChatAppGenerateEntityWrapper(entity=generate_entity)
return WorkflowResumptionContext(
generate_entity=wrapper,
serialized_graph_runtime_state=runtime_state.dumps(),
)
class _SessionContext:
def __init__(self, session: Any) -> None:
self._session = session
@@ -327,19 +359,69 @@ class _PauseEntity(WorkflowPauseEntity):
return []
def test_get_message_context_should_return_none_when_no_message() -> None:
def test_get_message_context_by_conversation_should_return_none_when_no_message() -> None:
# Arrange
session = SimpleNamespace(scalar=MagicMock(return_value=None))
session_maker = _SessionMaker(session)
# Act
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
result = service_module._get_message_context_by_conversation(
cast(sessionmaker[Session], session_maker),
conversation_id="conv-1",
workflow_run_id="run-1",
)
# Assert
assert result is None
def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp() -> None:
def test_get_message_context_by_conversation_should_scope_and_bound_message_lookup() -> None:
# Arrange
session = SimpleNamespace(scalar=MagicMock(return_value=None))
session_maker = _SessionMaker(session)
# Act
service_module._get_message_context_by_conversation(
cast(sessionmaker[Session], session_maker),
conversation_id="conv-1",
workflow_run_id="run-1",
)
# Assert
stmt = session.scalar.call_args.args[0]
compiled = " ".join(str(stmt.compile(compile_kwargs={"literal_binds": True})).split())
where_clause = compiled.split(" WHERE ", maxsplit=1)[1].split(" ORDER BY ", maxsplit=1)[0]
assert "messages.conversation_id = 'conv-1'" in compiled
assert "messages.workflow_run_id = 'run-1'" in compiled
assert "messages.app_id" not in where_clause
assert "ORDER BY messages.created_at DESC" in compiled
assert compiled.endswith("LIMIT 1")
def test_get_message_context_by_app_should_scope_and_bound_compatibility_lookup() -> None:
# Arrange
session = SimpleNamespace(scalar=MagicMock(return_value=None))
session_maker = _SessionMaker(session)
# Act
service_module._get_message_context_by_app(
cast(sessionmaker[Session], session_maker),
app_id="app-1",
workflow_run_id="run-1",
)
# Assert
stmt = session.scalar.call_args.args[0]
compiled = " ".join(str(stmt.compile(compile_kwargs={"literal_binds": True})).split())
where_clause = compiled.split(" WHERE ", maxsplit=1)[1].split(" ORDER BY ", maxsplit=1)[0]
assert "messages.app_id = 'app-1'" in where_clause
assert "messages.workflow_run_id = 'run-1'" in where_clause
assert "messages.conversation_id" not in where_clause
assert "ORDER BY messages.created_at DESC" in compiled
assert compiled.endswith("LIMIT 1")
def test_get_message_context_by_conversation_should_default_created_at_to_zero_when_message_has_no_timestamp() -> None:
# Arrange
message = SimpleNamespace(
id="msg-1",
@@ -351,7 +433,11 @@ def test_get_message_context_should_default_created_at_to_zero_when_message_has_
session_maker = _SessionMaker(session)
# Act
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
result = service_module._get_message_context_by_conversation(
cast(sessionmaker[Session], session_maker),
conversation_id="conv-1",
workflow_run_id="run-1",
)
# Assert
assert result is not None
@@ -559,9 +645,14 @@ def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_even
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange
workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING)
workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED)
topic = _Topic(_StaticSubscription())
workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock())
pause_entity = _PauseEntity(state=b"state")
resumption_context = _build_advanced_chat_resumption_context(conversation_id="conv-1")
call_order: list[str] = []
workflow_run_repo = SimpleNamespace(
get_workflow_pause=MagicMock(side_effect=lambda _run_id: call_order.append("pause") or pause_entity)
)
node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[]))
factory = SimpleNamespace(
create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo),
@@ -569,12 +660,19 @@ def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_even
)
monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory)
monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic))
message_context_lookup = MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("message") or None)
app_lookup = MagicMock(return_value=None)
monkeypatch.setattr(
service_module,
"_get_message_context",
MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)),
"_get_message_context_by_conversation",
message_context_lookup,
)
monkeypatch.setattr(service_module, "_get_message_context_by_app", app_lookup)
monkeypatch.setattr(
service_module,
"_load_resumption_context",
MagicMock(side_effect=lambda _pause_entity: call_order.append("state") or resumption_context),
)
monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None))
buffer_state = BufferState(
queue=queue.Queue(),
stop_event=Event(),
@@ -589,6 +687,7 @@ def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_even
"_build_snapshot_events",
MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED, "task_id": "task-1"}]),
)
session_maker = MagicMock()
# Act
events = list(
@@ -597,7 +696,7 @@ def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_even
workflow_run=workflow_run,
tenant_id="tenant-1",
app_id="app-1",
session_maker=MagicMock(),
session_maker=session_maker,
)
)
@@ -609,6 +708,119 @@ def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_even
node_repo.get_execution_snapshots_by_workflow_run.assert_called_once()
called_kwargs = node_repo.get_execution_snapshots_by_workflow_run.call_args.kwargs
assert called_kwargs["workflow_run_id"] == "run-1"
assert call_order == ["pause", "state", "message"]
message_context_lookup.assert_called_once_with(
session_maker,
conversation_id="conv-1",
workflow_run_id="run-1",
)
app_lookup.assert_not_called()
@pytest.mark.parametrize(
("resumption_context", "expected_error"),
[
pytest.param(None, "WorkflowResumptionContext.*workflow_run_id=run-1", id="missing-state"),
pytest.param(
_build_resumption_context_additional(task_id="task-ctx"),
"AdvancedChatAppGenerateEntity.*workflow_run_id=run-1",
id="wrong-entity-type",
),
pytest.param(
_build_advanced_chat_resumption_context(conversation_id=None),
"conversation_id.*workflow_run_id=run-1",
id="missing-conversation-id",
),
pytest.param(
_build_advanced_chat_resumption_context(conversation_id=""),
"conversation_id.*workflow_run_id=run-1",
id="empty-conversation-id",
),
],
)
def test_build_advanced_chat_snapshot_requires_conversation_context(
monkeypatch: pytest.MonkeyPatch,
resumption_context: WorkflowResumptionContext | None,
expected_error: str,
) -> None:
# Arrange
workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED)
pause_entity = _PauseEntity(state=b"state")
workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(return_value=pause_entity))
node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[]))
factory = SimpleNamespace(
create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo),
create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo),
)
monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory)
monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock())
monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=resumption_context))
conversation_lookup = MagicMock(return_value=None)
app_lookup = MagicMock(return_value=None)
monkeypatch.setattr(
service_module,
"_get_message_context_by_conversation",
conversation_lookup,
)
monkeypatch.setattr(service_module, "_get_message_context_by_app", app_lookup)
# Act / Assert
with pytest.raises(AssertionError, match=expected_error):
build_workflow_event_stream(
app_mode=AppMode.ADVANCED_CHAT,
workflow_run=workflow_run,
tenant_id="tenant-1",
app_id="app-1",
session_maker=MagicMock(),
)
conversation_lookup.assert_not_called()
app_lookup.assert_not_called()
def test_build_non_suspended_advanced_chat_snapshot_uses_app_scoped_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange
workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING)
workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock())
node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[]))
factory = SimpleNamespace(
create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo),
create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo),
)
monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory)
monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock())
load_resumption_context = MagicMock(return_value=None)
monkeypatch.setattr(service_module, "_load_resumption_context", load_resumption_context)
conversation_lookup = MagicMock(return_value=None)
app_lookup = MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000))
monkeypatch.setattr(
service_module,
"_get_message_context_by_conversation",
conversation_lookup,
)
monkeypatch.setattr(service_module, "_get_message_context_by_app", app_lookup)
session_maker = MagicMock()
# Act
event_stream = build_workflow_event_stream(
app_mode=AppMode.ADVANCED_CHAT,
workflow_run=workflow_run,
tenant_id="tenant-1",
app_id="app-1",
session_maker=session_maker,
)
# Assert
assert event_stream is not None
workflow_run_repo.get_workflow_pause.assert_not_called()
load_resumption_context.assert_called_once_with(None)
conversation_lookup.assert_not_called()
app_lookup.assert_called_once_with(
session_maker,
app_id="app-1",
workflow_run_id="run-1",
)
def test_build_workflow_event_stream_should_emit_periodic_ping_and_stop_after_idle_timeout(
@@ -13,9 +13,12 @@ import pytest
from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.task_entities import StreamEvent
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.app.layers.pause_state_persist_layer import (
WorkflowResumptionContext,
_WorkflowGenerateEntityWrapper,
)
from graphon.enums import WorkflowExecutionStatus
from graphon.runtime import GraphRuntimeState, VariablePool
from models.enums import CreatorUserRole
@@ -147,15 +150,21 @@ class _PauseEntity(WorkflowPauseEntity):
class TestWorkflowEventSnapshotHelpers:
def test_get_message_context_should_return_none_when_no_message(self) -> None:
def test_get_message_context_by_conversation_should_return_none_when_no_message(self) -> None:
session = SimpleNamespace(scalar=MagicMock(return_value=None))
session_maker = _SessionMaker(session)
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
result = service_module._get_message_context_by_conversation(
cast(sessionmaker[Session], session_maker),
conversation_id="conv-1",
workflow_run_id="run-1",
)
assert result is None
def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp(self) -> None:
def test_get_message_context_by_conversation_should_default_created_at_to_zero_when_message_has_no_timestamp(
self,
) -> None:
message = SimpleNamespace(
id="msg-1",
conversation_id="conv-1",
@@ -165,7 +174,11 @@ class TestWorkflowEventSnapshotHelpers:
session = SimpleNamespace(scalar=MagicMock(return_value=message))
session_maker = _SessionMaker(session)
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
result = service_module._get_message_context_by_conversation(
cast(sessionmaker[Session], session_maker),
conversation_id="conv-1",
workflow_run_id="run-1",
)
assert result is not None
assert result.created_at == 0
@@ -324,9 +337,10 @@ class TestBuildWorkflowEventStream:
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.RUNNING)
workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.PAUSED)
topic = _Topic(_StaticSubscription())
workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock())
pause_entity = _PauseEntity(state=b"state")
workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(return_value=pause_entity))
node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[]))
factory = SimpleNamespace(
create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo),
@@ -336,10 +350,18 @@ class TestBuildWorkflowEventStream:
monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic))
monkeypatch.setattr(
service_module,
"_get_message_context",
"_get_message_context_by_conversation",
MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)),
)
monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None))
generate_entity = AdvancedChatAppGenerateEntity.model_construct(conversation_id="conv-1")
resumption_context = SimpleNamespace(
get_generate_entity=MagicMock(return_value=generate_entity),
)
monkeypatch.setattr(
service_module,
"_load_resumption_context",
MagicMock(return_value=resumption_context),
)
buffer_state = BufferState(
queue=queue.Queue(),
stop_event=Event(),
@@ -1,9 +1,11 @@
"""Tests for human-input delivery with a persisted draft workflow."""
import json
import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from core.workflow.human_input_adapter import (
EmailDeliveryConfig,
@@ -14,12 +16,42 @@ from core.workflow.human_input_adapter import (
)
from core.workflow.nodes.human_input.entities import HumanInputNodeData
from graphon.enums import BuiltinNodeTypes
from models import Account
from models.enums import AppStatus
from models.model import App, AppMode
from models.workflow import Workflow, WorkflowType
from services import workflow_service as workflow_service_module
from services.workflow_service import WorkflowService
TENANT_ID = "11111111-1111-1111-1111-111111111111"
APP_ID = "22222222-2222-2222-2222-222222222222"
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
def _make_service() -> WorkflowService:
return WorkflowService(session_maker=sessionmaker())
def _make_service(sqlite_session: Session) -> WorkflowService:
return WorkflowService(
session_maker=sessionmaker(bind=sqlite_session.get_bind(), expire_on_commit=False),
)
def _app() -> App:
return App(
id=APP_ID,
tenant_id=TENANT_ID,
name="Test App",
description="",
mode=AppMode.WORKFLOW,
status=AppStatus.NORMAL,
enable_site=False,
enable_api=False,
max_active_requests=None,
)
def _account() -> Account:
account = Account(name="Test User", email="test@example.com")
account.id = ACCOUNT_ID
return account
def _build_node_config(delivery_methods: list[EmailDeliveryMethod]) -> dict[str, object]:
@@ -36,6 +68,37 @@ def _build_node_config(delivery_methods: list[EmailDeliveryMethod]) -> dict[str,
}
def _persist_workflow(sqlite_session: Session, delivery_methods: list[EmailDeliveryMethod]) -> None:
node_config = _build_node_config(delivery_methods)
node_data = node_config["data"]
assert isinstance(node_data, HumanInputNodeData)
workflow = Workflow.new(
tenant_id=TENANT_ID,
app_id=APP_ID,
type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": node_config["id"],
"data": node_data.model_dump(mode="json"),
}
],
"edges": [],
}
),
features="{}",
created_by=ACCOUNT_ID,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
sqlite_session.add(workflow)
sqlite_session.commit()
sqlite_session.expunge_all()
def _make_email_method(enabled: bool = True, debug_mode: bool = False) -> EmailDeliveryMethod:
return EmailDeliveryMethod(
id=uuid.uuid4(),
@@ -52,11 +115,11 @@ def _make_email_method(enabled: bool = True, debug_mode: bool = False) -> EmailD
)
def test_human_input_delivery_requires_draft_workflow():
service = _make_service()
service.get_draft_workflow = MagicMock(return_value=None) # type: ignore[method-assign]
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
account = SimpleNamespace(id="account-1")
@pytest.mark.parametrize("sqlite_session", [(Workflow,)], indirect=True)
def test_human_input_delivery_requires_draft_workflow(sqlite_session: Session):
service = _make_service(sqlite_session)
app_model = _app()
account = _account()
with pytest.raises(ValueError, match="Workflow not initialized"):
service.test_human_input_delivery(
@@ -64,17 +127,19 @@ def test_human_input_delivery_requires_draft_workflow():
account=account,
node_id="node-1",
delivery_method_id="delivery-1",
session=MagicMock(),
session=sqlite_session,
)
assert sqlite_session.in_transaction()
def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyPatch):
service = _make_service()
@pytest.mark.parametrize("sqlite_session", [(Workflow,)], indirect=True)
def test_human_input_delivery_allows_disabled_method(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
service = _make_service(sqlite_session)
delivery_method = _make_email_method(enabled=False)
node_config = _build_node_config([delivery_method])
workflow = MagicMock()
workflow.get_node_config_by_id.return_value = node_config
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
_persist_workflow(sqlite_session, [delivery_method])
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
node_stub = MagicMock()
node_stub.render_form_content_before_submission.return_value = "rendered"
@@ -91,27 +156,29 @@ def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyP
MagicMock(return_value=test_service_instance),
)
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
account = SimpleNamespace(id="account-1")
app_model = _app()
account = _account()
service.test_human_input_delivery(
app_model=app_model,
account=account,
node_id="node-1",
delivery_method_id=str(delivery_method.id),
session=MagicMock(),
session=sqlite_session,
)
test_service_instance.send_test.assert_called_once()
assert sqlite_session.in_transaction()
def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.MonkeyPatch):
service = _make_service()
@pytest.mark.parametrize("sqlite_session", [(Workflow,)], indirect=True)
def test_human_input_delivery_dispatches_to_test_service(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
service = _make_service(sqlite_session)
delivery_method = _make_email_method(enabled=True)
node_config = _build_node_config([delivery_method])
workflow = MagicMock()
workflow.get_node_config_by_id.return_value = node_config
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
_persist_workflow(sqlite_session, [delivery_method])
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
node_stub = MagicMock()
node_stub.render_form_content_before_submission.return_value = "rendered"
@@ -128,8 +195,8 @@ def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.Mon
MagicMock(return_value=test_service_instance),
)
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
account = SimpleNamespace(id="account-1")
app_model = _app()
account = _account()
service.test_human_input_delivery(
app_model=app_model,
@@ -137,21 +204,23 @@ def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.Mon
node_id="node-1",
delivery_method_id=str(delivery_method.id),
inputs={"#node-1.output#": "value"},
session=MagicMock(),
session=sqlite_session,
)
pool_args = service._build_human_input_variable_pool.call_args.kwargs
assert pool_args["manual_inputs"] == {"#node-1.output#": "value"}
test_service_instance.send_test.assert_called_once()
assert sqlite_session.in_transaction()
def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytest.MonkeyPatch):
service = _make_service()
@pytest.mark.parametrize("sqlite_session", [(Workflow,)], indirect=True)
def test_human_input_delivery_debug_mode_overrides_recipients(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
service = _make_service(sqlite_session)
delivery_method = _make_email_method(enabled=True, debug_mode=True)
node_config = _build_node_config([delivery_method])
workflow = MagicMock()
workflow.get_node_config_by_id.return_value = node_config
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
_persist_workflow(sqlite_session, [delivery_method])
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
node_stub = MagicMock()
node_stub.render_form_content_before_submission.return_value = "rendered"
@@ -168,15 +237,15 @@ def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytes
MagicMock(return_value=test_service_instance),
)
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
account = SimpleNamespace(id="account-1")
app_model = _app()
account = _account()
service.test_human_input_delivery(
app_model=app_model,
account=account,
node_id="node-1",
delivery_method_id=str(delivery_method.id),
session=MagicMock(),
session=sqlite_session,
)
test_service_instance.send_test.assert_called_once()
@@ -188,3 +257,4 @@ def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytes
recipient = sent_method.config.recipients.items[0]
assert isinstance(recipient, MemberRecipient)
assert recipient.reference_id == account.id
assert sqlite_session.in_transaction()
@@ -1,8 +1,13 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from uuid import uuid4
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from models.human_input import HumanInputForm
from tasks import mail_human_input_delivery_task as task_module
@@ -18,18 +23,17 @@ class _DummyMail:
self.sent.append({"to": to, "subject": subject, "html": html})
class _DummySession:
def __init__(self, form):
self._form = form
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
return False
def get(self, _model, _form_id):
return self._form
def _form(*, workflow_run_id: str | None = None) -> HumanInputForm:
return HumanInputForm(
tenant_id=str(uuid4()),
app_id=str(uuid4()),
workflow_run_id=workflow_run_id,
conversation_id=None,
node_id="human-input",
form_definition="{}",
rendered_content="content",
expiration_time=datetime.now(UTC) + timedelta(hours=1),
)
def _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob:
@@ -46,9 +50,14 @@ def _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob:
)
def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_dispatch_human_input_email_task_sends_to_each_recipient(
monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session
):
mail = _DummyMail()
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
form = _form()
sqlite_session.add(form)
sqlite_session.commit()
monkeypatch.setattr(task_module, "mail", mail)
monkeypatch.setattr(
@@ -60,9 +69,9 @@ def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: py
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: jobs)
task_module.dispatch_human_input_email_task(
form_id="form-1",
form_id=form.id,
node_title="Approve",
session_factory=lambda: _DummySession(form),
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
)
assert len(mail.sent) == 2
@@ -70,9 +79,14 @@ def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: py
assert all("Body for" in payload["html"] for payload in mail.sent)
def test_dispatch_human_input_email_task_skips_when_feature_disabled(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_dispatch_human_input_email_task_skips_when_feature_disabled(
monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session
):
mail = _DummyMail()
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
form = _form()
sqlite_session.add(form)
sqlite_session.commit()
monkeypatch.setattr(task_module, "mail", mail)
monkeypatch.setattr(
@@ -83,17 +97,22 @@ def test_dispatch_human_input_email_task_skips_when_feature_disabled(monkeypatch
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [])
task_module.dispatch_human_input_email_task(
form_id="form-1",
form_id=form.id,
node_title="Approve",
session_factory=lambda: _DummySession(form),
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
)
assert mail.sent == []
def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_dispatch_human_input_email_task_replaces_body_variables(
monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session
):
mail = _DummyMail()
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id="run-1")
form = _form(workflow_run_id=str(uuid4()))
sqlite_session.add(form)
sqlite_session.commit()
job = task_module._EmailDeliveryJob(
form_id="form-1",
subject="Subject",
@@ -115,21 +134,26 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py
monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: variable_pool)
task_module.dispatch_human_input_email_task(
form_id="form-1",
form_id=form.id,
node_title="Approve",
session_factory=lambda: _DummySession(form),
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
)
assert mail.sent[0]["html"] == "<p>Body OK</p>"
@pytest.mark.parametrize("line_break", ["\r\n", "\r", "\n"])
@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True)
def test_dispatch_human_input_email_task_sanitizes_subject(
monkeypatch: pytest.MonkeyPatch,
line_break: str,
sqlite_engine: Engine,
sqlite_session: Session,
):
mail = _DummyMail()
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
form = _form()
sqlite_session.add(form)
sqlite_session.commit()
job = task_module._EmailDeliveryJob(
form_id="form-1",
subject=f"Notice{line_break}BCC:attacker@example.com <b>Alert</b>",
@@ -148,9 +172,9 @@ def test_dispatch_human_input_email_task_sanitizes_subject(
monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None)
task_module.dispatch_human_input_email_task(
form_id="form-1",
form_id=form.id,
node_title="Approve",
session_factory=lambda: _DummySession(form),
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
)
assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert"
+1 -1
View File
@@ -8,4 +8,4 @@ cd "$SCRIPT_DIR/../api"
uv run flask db upgrade
uv run \
flask run --host 0.0.0.0 --port=5001 --debug
dotenv -f .env run --no-override -- python -m app
@@ -23,7 +23,7 @@ import { expectProviderToolActionVisible, getCurrentAgentId } from './configure-
const getToolsSection = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Tools' })
const getToolSelectorSearch = (world: DifyWorld) =>
world.getPage().getByRole('textbox', { name: 'Search integrations...' })
world.getPage().getByRole('searchbox', { name: 'Search integrations...' })
const jsonReplaceRuntimePrompt = [
'You are a Dify Agent E2E JSON tool verifier.',
-142
View File
@@ -394,14 +394,6 @@
"count": 1
}
},
"web/app/components/app/configuration/base/operation-btn/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -3282,11 +3274,6 @@
"count": 1
}
},
"web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 1
@@ -3382,14 +3369,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -3466,43 +3445,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 3
},
"jsx_a11y/mouse-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 3
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -3970,14 +3912,6 @@
"count": 3
}
},
"web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow-app/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 13
@@ -4018,69 +3952,16 @@
"count": 2
}
},
"web/app/components/workflow/block-selector/all-start-blocks.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/block-selector/constants.tsx": {
"react/only-export-components": {
"count": 3
}
},
"web/app/components/workflow/block-selector/index-bar.tsx": {
"react/only-export-components": {
"count": 1
}
},
"web/app/components/workflow/block-selector/market-place-plugin/list.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/workflow/block-selector/use-sticky-scroll.ts": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/workflow/block-selector/view-type-select.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
},
"react/only-export-components": {
"count": 1
}
},
"web/app/components/workflow/comment/comment-input.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
@@ -4152,14 +4033,6 @@
"count": 1
}
},
"web/app/components/workflow/header/view-workflow-history.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 3
},
"jsx_a11y/no-static-element-interactions": {
"count": 3
}
},
"web/app/components/workflow/hooks-store/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@@ -5329,11 +5202,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/tool/components/tool-form/item.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/tool/default.ts": {
"typescript/no-explicit-any": {
"count": 6
@@ -5372,11 +5240,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-plugin/default.ts": {
"typescript/no-explicit-any": {
"count": 10
@@ -6336,11 +6199,6 @@
"count": 1
}
},
"web/service/access-control/use-permission-catalog.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/use-workspace-access-rules.ts": {
"no-restricted-imports": {
"count": 1
@@ -58,6 +58,7 @@ export type PluginManagerModel = {
export type WebAppAuthModel = {
allow_email_code_login: boolean
allow_email_password_login: boolean
allow_public_access: boolean
allow_sso: boolean
enabled: boolean
sso_config: WebAppAuthSsoModel
@@ -87,6 +87,7 @@ export const zWebAppAuthSsoModel = z.object({
export const zWebAppAuthModel = z.object({
allow_email_code_login: z.boolean().default(false),
allow_email_password_login: z.boolean().default(false),
allow_public_access: z.boolean().default(true),
allow_sso: z.boolean().default(false),
enabled: z.boolean().default(false),
sso_config: zWebAppAuthSsoModel.default({ protocol: '' }),
@@ -144,6 +145,7 @@ export const zSystemFeatureModel = z.object({
webapp_auth: zWebAppAuthModel.default({
allow_email_code_login: false,
allow_email_password_login: false,
allow_public_access: true,
allow_sso: false,
enabled: false,
sso_config: { protocol: '' },
@@ -567,6 +567,7 @@ export type VerificationTokenResponse = {
export type WebAppAuthModel = {
allow_email_code_login: boolean
allow_email_password_login: boolean
allow_public_access: boolean
allow_sso: boolean
enabled: boolean
sso_config: WebAppAuthSsoModel
@@ -765,6 +765,7 @@ export const zWebAppAuthSsoModel = z.object({
export const zWebAppAuthModel = z.object({
allow_email_code_login: z.boolean().default(false),
allow_email_password_login: z.boolean().default(false),
allow_public_access: z.boolean().default(true),
allow_sso: z.boolean().default(false),
enabled: z.boolean().default(false),
sso_config: zWebAppAuthSsoModel.default({ protocol: '' }),
@@ -822,6 +823,7 @@ export const zSystemFeatureModel = z.object({
webapp_auth: zWebAppAuthModel.default({
allow_email_code_login: false,
allow_email_password_login: false,
allow_public_access: true,
allow_sso: false,
enabled: false,
sso_config: { protocol: '' },
+8 -6
View File
@@ -21,11 +21,11 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles
Pick by the **trigger's purpose** and **a11y reach**, not visual richness.
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
| ------------- | --------------------- | -------------------------- | ------------------------- | ------------------------ |
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
| `PreviewCard` | hover / focus | has a primary click target | supplementary preview | ❌ (via click target) |
| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ |
| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? |
| ------------- | --------------------- | --------------------- | ------------------------- | ------------------------ |
| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) |
| `PreviewCard` | hover / focus | navigate through link | link destination preview | ❌ (visual enhancement) |
| `Popover` | click / tap (+ hover) | **open the popup** | anything, incl. long text | ✅ |
Base UI decision rule ([docs]):
@@ -35,9 +35,11 @@ Base UI decision rule ([docs]):
Apply this first, then narrow:
- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able.
- `PreviewCard`hover-revealed rich supplementary preview anchored to a trigger whose click goes somewhere (link, selectable row, jumpable chip). **Hard contract:** the popup MUST NOT contain information or actions unreachable from the trigger's click destination touch and SR users can't open it. If the info is unique to the popup, switch to `Popover` (click or `openOnHover`) or move it to the click destination. Do not hand-roll "hover to open" on top of `Popover` to evade this split.
- `PreviewCard`a visual enhancement for a link that previews its destination. Prefer the canonical anchor trigger and keep the popup non-interactive. Do not place unique or essential information or actions in the popup unless they are also available at the linked destination; touch and screen reader users cannot access the preview. If opening the popup is itself the trigger's purpose, or its content must be accessible across input modes, use `Popover` instead.
- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus.
Product-level polymorphic trigger compositions are local feature decisions. Document them in the owning feature and do not broaden or weaken the shared primitive contract to match one business workflow.
## Border Radius: Figma Token → Tailwind Class Mapping
The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset.
+2 -1
View File
@@ -179,9 +179,10 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b
- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal.
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
### Tooltip, infotip, and popover semantics
### Tooltip, preview card, infotip, and popover semantics
- Use `Tooltip` only for short, non-interactive visual labels. The trigger must already have visible text or an `aria-label`; the tooltip is not the accessible name and must not contain links, buttons, forms, or structured prose.
- Use `PreviewCard` as a visual enhancement for a link that previews its destination. Its popup must remain non-interactive and must not contain unique or essential information unless that information is also available at the linked destination. Use `Popover` when opening the popup is the trigger's purpose or when users need to access its content on touch or with assistive technology.
- Use `Popover` for explanatory content, long text, rich layout, or anything users may need to reach on touch or with assistive technology. In `web/`, the `Infotip` wrapper is the preferred pattern for a `?` help glyph backed by `Popover`.
- Pick a `placement` and let the primitive own spacing. Avoid per-call-site offsets unless the component API explicitly needs a measured layout exception.
- When passing a Base UI trigger `render` prop, render a real `<button type="button">` for button-like triggers. If a Popover trigger must render a `div`, `span`, or another non-button element, pass `nativeButton={false}`.
@@ -17,7 +17,7 @@ const meta = {
docs: {
description: {
component:
'Hover- and focus-activated rich link preview built on Base UI PreviewCard.\n\n**A11y contract:** touch and screen-reader users cannot open the preview. Keep popup content available on the link destination. A polymorphic action trigger is a Dify application-level extension and is only valid when its click result exposes the same information.',
"A visual enhancement for a link that previews its destination, built on Base UI PreviewCard. The popup is unavailable to touch and screen-reader users, so it must remain non-interactive and must not contain unique or essential information unless that information is also available at the linked destination. Use Popover when opening the popup is the trigger's purpose or when its content must be accessible across input modes.",
},
},
},
+9 -12
View File
@@ -10,19 +10,16 @@ import { parsePlacement } from '../placement'
export type { Placement }
/**
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
* link. Base UI's canonical trigger renders an anchor.
* PreviewCard follows Base UI's canonical semantics: a hover/focus-triggered
* visual enhancement for a link that previews its destination.
*
* A11y contract match Base UI's guidance:
* - The popup MUST NOT contain information or actions that are not also
* reachable from the link destination. Touch and screen reader users cannot
* open the card and must be able to get the same information/actions without
* it.
* - A polymorphic action trigger is an application-level extension and is only
* valid when its primary click result exposes the same information.
* - If content is unique to the popup, either (a) add a separate click-triggered
* affordance (Popover) next to the trigger, or (b) move the unique content
* onto the click destination.
* Contract:
* - Prefer the canonical anchor trigger and keep the popup non-interactive.
* - Do not place unique or essential information or actions in the popup unless
* they are also available at the linked destination.
* - Touch and screen reader users cannot access the preview. Use Popover when
* opening the popup is itself the trigger's purpose or its content must be
* accessible across input modes.
*/
export const PreviewCard = BasePreviewCard.Root
export const PreviewCardTrigger = BasePreviewCard.Trigger
@@ -41,10 +41,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/version-state', async () => {
const { createVersionStateModuleMock } = await import('@/test/console/state-fixture')
return createVersionStateModuleMock(() => mockConsoleState)
@@ -143,7 +139,7 @@ const setupProviderContext = (
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
workspacePermissionKeys: [],
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...overrides,
@@ -245,11 +241,11 @@ describe('Billing Page + Plan Integration', () => {
// Verify billing URL button visibility and behavior
describe('Billing URL button', () => {
it('should show billing button when manager has subscription management permission', () => {
it('should show billing button to managers without billing permission keys', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.subscription.manage'],
workspacePermissionKeys: [],
})
render(<Billing />)
@@ -258,11 +254,10 @@ describe('Billing Page + Plan Integration', () => {
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when subscription management permission is granted without manager role', () => {
it('should hide billing button from non-manager members', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.subscription.manage'],
})
render(<Billing />)
@@ -270,16 +265,16 @@ describe('Billing Page + Plan Integration', () => {
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when subscription management permission is missing', () => {
it('should show billing button when a manager has no billing permission keys', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage'],
workspacePermissionKeys: [],
})
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
})
it('should hide billing button when billing is disabled', () => {
@@ -289,17 +284,6 @@ describe('Billing Page + Plan Integration', () => {
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when no billing permissions are granted', () => {
setupProviderContext({ type: Plan.sandbox })
setupConsoleState({
workspacePermissionKeys: [],
})
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
})
})
@@ -366,6 +350,39 @@ describe('Plan Type Display Integration', () => {
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
})
it('should show education discount to managers without billing permission keys', () => {
setupProviderContext(
{ type: Plan.sandbox },
{
enableEducationPlan: true,
isEducationAccount: true,
},
)
setupConsoleState({ isCurrentWorkspaceManager: true, workspacePermissionKeys: [] })
render(<PlanComp loc="test" />)
expect(screen.getByText(/useEducationDiscount/i)).toBeInTheDocument()
})
it('should hide education discount from non-manager members', () => {
setupProviderContext(
{ type: Plan.sandbox },
{
enableEducationPlan: true,
isEducationAccount: true,
},
)
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage'],
})
render(<PlanComp loc="test" />)
expect(screen.queryByText(/useEducationDiscount/i)).not.toBeInTheDocument()
})
})
// ═══════════════════════════════════════════════════════════════════════════
@@ -2,7 +2,7 @@
* Integration test: Cloud Plan Payment Flow
*
* Tests the payment flow for cloud plan items:
* CloudPlanItem Button click permission check fetch URL redirect
* CloudPlanItem Button click payment capability check fetch URL redirect
*
* Covers plan comparison, downgrade prevention, monthly/yearly pricing,
* and workspace manager permission enforcement.
@@ -30,10 +30,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
// ─── Service mocks ───────────────────────────────────────────────────────────
vi.mock('@/service/billing', () => ({
@@ -65,7 +61,6 @@ vi.mock('@/next/navigation', () => ({
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
...overrides,
}
}
@@ -275,15 +270,11 @@ describe('Cloud Plan Payment Flow', () => {
})
})
// ─── 5. Permission Check ────────────────────────────────────────────────
describe('Permission check', () => {
it('should change plans when billing manage permission is granted without manager role', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage'],
})
// ─── 5. Payment capability ──────────────────────────────────────────────
describe('Payment capability', () => {
it('should change plans when payment is allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional, canPay: true })
const button = getPlanButton('billing.plansCommon.startBuilding')
await user.click(button)
@@ -293,13 +284,9 @@ describe('Cloud Plan Payment Flow', () => {
})
})
it('should show error toast when billing manage permission is missing for plan changes', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.view', 'billing.subscription.manage'],
})
it('should block plan changes when payment is not allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional, canPay: false })
const button = getPlanButton('billing.plansCommon.startBuilding')
await user.click(button)
@@ -310,13 +297,13 @@ describe('Cloud Plan Payment Flow', () => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
it('should open billing portal when subscription management permission is granted without manager role', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.subscription.manage'],
})
it('should open billing portal when payment is allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
renderCloudPlanItem({
currentPlan: Plan.professional,
plan: Plan.professional,
canPay: true,
})
const button = getPlanButton('billing.plansCommon.currentPlan')
await user.click(button)
@@ -327,13 +314,13 @@ describe('Cloud Plan Payment Flow', () => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
it('should show error toast when subscription management permission is missing for current paid plan', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.view', 'billing.manage'],
})
it('should block billing portal access when payment is not allowed', async () => {
const user = userEvent.setup()
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
renderCloudPlanItem({
currentPlan: Plan.professional,
plan: Plan.professional,
canPay: false,
})
const button = getPlanButton('billing.plansCommon.currentPlan')
await user.click(button)
@@ -46,10 +46,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/version-state', async () => {
const { createVersionStateModuleMock } = await import('@/test/console/state-fixture')
return createVersionStateModuleMock(() => mockConsoleState)
@@ -157,7 +153,6 @@ const setupContexts = (
}
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
userProfile: { email: 'student@university.edu' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...appOverrides,
@@ -224,9 +219,13 @@ describe('Education Verification Flow', () => {
// ─── 2. Successful Verification Flow ────────────────────────────────────
describe('Successful verification flow', () => {
it('should navigate to education-apply with token on successful verification', async () => {
it('should let non-manager members start education verification', async () => {
mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
setupContexts(
{},
{ enableEducationPlan: true, isEducationAccount: false },
{ isCurrentWorkspaceManager: false },
)
const user = userEvent.setup()
render(<PlanComp loc="test" />)
@@ -8,7 +8,7 @@
* Validates cross-component state propagation when the user switches between
* cloud / self-hosted categories and monthly / yearly plan ranges.
*/
import { cleanup, screen } from '@testing-library/react'
import { cleanup, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { ALL_PLANS } from '@/app/components/billing/config'
@@ -19,6 +19,7 @@ import { render } from '@/test/console/render'
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
let mockConsoleState: Record<string, unknown> = {}
const mockFetchSubscriptionUrls = vi.hoisted(() => vi.fn())
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
@@ -33,10 +34,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/version-state', async () => {
const { createVersionStateModuleMock } = await import('@/test/console/state-fixture')
return createVersionStateModuleMock(() => mockConsoleState)
@@ -49,7 +46,7 @@ vi.mock('@/context/i18n', () => ({
// ─── Service mocks ───────────────────────────────────────────────────────────
vi.mock('@/service/billing', () => ({
fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }),
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
}))
vi.mock('@/service/client', () => ({
@@ -131,7 +128,6 @@ const setupContexts = (
}
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...appOverrides,
@@ -145,6 +141,7 @@ describe('Pricing Modal Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com' })
setupContexts()
})
@@ -266,6 +263,54 @@ describe('Pricing Modal Flow', () => {
// ─── 4. Cloud Plan Button States ─────────────────────────────────────────
describe('Cloud plan button states', () => {
it('should allow managers without billing permission keys to change plans', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
})
})
it('should default education account managers to yearly checkout', async () => {
setupContexts()
mockProviderCtx = {
...mockProviderCtx,
enableEducationPlan: true,
isEducationAccount: true,
}
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
expect(screen.getByRole('switch')).toBeChecked()
await user.click(screen.getByRole('button', { name: 'education.useEducationDiscount' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
})
})
it('should block non-manager members even when billing permission keys are present', async () => {
setupContexts(
{},
{
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage', 'billing.subscription.manage'],
},
)
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
})
})
it('should show "Current Plan" for the current plan (sandbox)', () => {
setupContexts({ type: Plan.sandbox })
render(<Pricing onCancel={onCancel} />)
@@ -1,14 +1,12 @@
import { toast, ToastHost } from '@langgenius/dify-ui/toast'
/**
* Integration test: Self-Hosted Plan Flow
*
* Tests the self-hosted plan items:
* SelfHostedPlanItem Button click permission check redirect to external URL
* SelfHostedPlanItem Button click redirect to external URL
*
* Covers community/premium/enterprise plan rendering, external URL navigation,
* and workspace manager permission enforcement.
* Covers community/premium/enterprise plan rendering and external URL navigation.
*/
import { cleanup, screen, waitFor } from '@testing-library/react'
import { cleanup, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import {
@@ -20,20 +18,9 @@ import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-host
import { SelfHostedPlan } from '@/app/components/billing/type'
import { render } from '@/test/console/render'
let mockConsoleState: Record<string, unknown> = {}
const originalLocation = window.location
let assignedHref = ''
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
useTheme: () => ({ theme: 'light' }),
@@ -52,29 +39,14 @@ vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', ()
),
}))
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.manage'],
...overrides,
}
}
const renderSelfHostedPlanItem = (plan: SelfHostedPlan) => {
return render(
<>
<ToastHost timeout={0} />
<SelfHostedPlanItem plan={plan} />
</>,
)
return render(<SelfHostedPlanItem plan={plan} />)
}
describe('Self-Hosted Plan Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
toast.dismiss()
setupConsoleState()
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
assignedHref = ''
@@ -187,64 +159,4 @@ describe('Self-Hosted Plan Flow', () => {
expect(assignedHref).toBe(contactSalesUrl)
})
})
// ─── 3. Permission Check ────────────────────────────────────────────────
describe('Permission check', () => {
it('should redirect when billing manage permission is granted without manager role', async () => {
setupConsoleState({
isCurrentWorkspaceManager: false,
workspacePermissionKeys: ['billing.manage'],
})
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
expect(assignedHref).toBe(getStartedWithCommunityUrl)
})
it('should show error toast when billing manage permission is missing for community button', async () => {
setupConsoleState({ workspacePermissionKeys: [] })
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
// Should NOT redirect
expect(assignedHref).toBe('')
})
it('should show error toast when billing manage permission is missing for premium button', async () => {
setupConsoleState({ workspacePermissionKeys: [] })
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.premium)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})
it('should show error toast when billing manage permission is missing for enterprise button', async () => {
setupConsoleState({ workspacePermissionKeys: [] })
const user = userEvent.setup()
renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})
})
})
@@ -1,7 +1,6 @@
import type { App } from '@/types/app'
import { screen, waitFor } from '@testing-library/react'
import { useStore } from '@/app/components/app/store'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
@@ -18,6 +17,10 @@ const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}))
const mockNavigation = vi.hoisted(() => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
@@ -26,10 +29,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
},
})
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
vi.mock('@/next/navigation', () => mockNavigation)
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: vi.fn(),
@@ -52,8 +52,8 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockUsePathname = vi.mocked(usePathname)
const mockUseRouter = vi.mocked(useRouter)
const mockUsePathname = mockNavigation.usePathname
const mockUseRouter = mockNavigation.useRouter
const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect)
const createAppDetail = (overrides: Partial<App> = {}) =>
@@ -83,12 +83,7 @@ describe('AppDetailLayout', () => {
mockConsoleState.workspacePermissionKeys = []
mockUsePathname.mockImplementation(() => mockPathname)
mockUseRouter.mockReturnValue({
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
push: vi.fn(),
replace: mockReplace,
prefetch: vi.fn(),
})
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail())
useStore.getState().setAppDetail()
@@ -1,5 +1,4 @@
import { screen, waitFor } from '@testing-library/react'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { DatasetACLPermission } from '@/utils/permission'
@@ -7,6 +6,10 @@ import DatasetDetailLayout from '../layout-main'
const mockReplace = vi.fn()
let mockIsRbacEnabled = true
const mockNavigation = vi.hoisted(() => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
@@ -15,10 +18,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
},
})
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
}))
vi.mock('@/next/navigation', () => mockNavigation)
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetDetail: vi.fn(),
@@ -61,8 +61,8 @@ vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
const mockUseRouter = vi.mocked(useRouter)
const mockUsePathname = vi.mocked(usePathname)
const mockUseRouter = mockNavigation.useRouter
const mockUsePathname = mockNavigation.usePathname
const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
describe('DatasetDetailLayout', () => {
@@ -71,12 +71,7 @@ describe('DatasetDetailLayout', () => {
mockIsRbacEnabled = true
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents')
mockUseRouter.mockReturnValue({
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
push: vi.fn(),
replace: mockReplace,
prefetch: vi.fn(),
})
})
@@ -47,4 +47,25 @@ describe('AccessControlItem', () => {
expect(anyone).toBeChecked()
})
it('should not select a disabled option', async () => {
const user = userEvent.setup()
render(
<RadioGroup<AccessMode> aria-label="Access" defaultValue={AccessMode.PUBLIC}>
<AccessControlItem type={AccessMode.ORGANIZATION} disabled>
Organization Only
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>Anyone</AccessControlItem>
</RadioGroup>,
)
const organization = screen.getByRole('radio', { name: 'Organization Only' })
expect(organization).toHaveAttribute('aria-disabled', 'true')
expect(organization).toHaveClass('cursor-not-allowed')
await user.click(organization)
expect(organization).not.toBeChecked()
expect(screen.getByRole('radio', { name: 'Anyone' })).toBeChecked()
})
})
@@ -13,6 +13,7 @@ let mockWebappAuth = {
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: true,
}
const render = (ui: ReactElement) =>
@@ -53,6 +54,7 @@ describe('AccessControl', () => {
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: true,
}
useAccessControlStore.setState({
appId: '',
@@ -113,6 +115,7 @@ describe('AccessControl', () => {
allow_sso: false,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: true,
}
render(
@@ -141,4 +144,48 @@ describe('AccessControl', () => {
expect(organization).toBeChecked()
})
describe('public access control', () => {
it('should render the public option enabled without a tooltip when public access is allowed', () => {
render(
<AccessControl
app={{ id: 'app-id-4', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
onClose={vi.fn()}
/>,
)
const publicOption = screen.getByRole('radio', {
name: /app\.accessControlDialog\.accessItems\.anyone/,
})
expect(publicOption).not.toHaveAttribute('data-disabled')
expect(
screen.queryByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'),
).not.toBeInTheDocument()
})
it('should render the public option disabled with a tooltip when public access is disabled', () => {
mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
allow_public_access: false,
}
render(
<AccessControl
app={{ id: 'app-id-5', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
onClose={vi.fn()}
/>,
)
const publicOption = screen.getByRole('radio', {
name: /app\.accessControlDialog\.accessItems\.anyone/,
})
expect(publicOption).toHaveAttribute('aria-disabled', 'true')
expect(
screen.getByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'),
).toBeInTheDocument()
})
})
})
@@ -6,18 +6,22 @@ import { RadioItem } from '@langgenius/dify-ui/radio'
type AccessControlItemProps = PropsWithChildren<{
type: AccessMode
disabled?: boolean
}>
export default function AccessControlItem({ type, children }: AccessControlItemProps) {
export default function AccessControlItem({ type, children, disabled }: AccessControlItemProps) {
return (
<RadioItem<AccessMode>
value={type}
disabled={disabled}
render={<div />}
className={cn(
'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
'rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
)}
>
{children}
@@ -13,6 +13,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode, SubjectType } from '@/models/access-control'
import { consoleQuery } from '@/service/client'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
@@ -39,6 +40,7 @@ export default function AccessControl(props: AccessControlProps) {
(systemFeatures.webapp_auth.allow_sso ||
systemFeatures.webapp_auth.allow_email_password_login ||
systemFeatures.webapp_auth.allow_email_code_login)
const publicAccessDisabled = !systemFeatures.webapp_auth.allow_public_access
useEffect(() => {
setAppId(appId)
@@ -48,7 +50,9 @@ export default function AccessControl(props: AccessControlProps) {
const { isPending, mutateAsync: updateAccessMode } = useMutation(
consoleQuery.enterprise.webAppAuth.updateWebAppWhitelistSubjects.mutationOptions(),
)
const confirmDisabled = isPending || (currentMenu === AccessMode.PUBLIC && publicAccessDisabled)
const handleConfirm = useCallback(async () => {
if (confirmDisabled) return
const submitData: {
appId: string
accessMode: AccessMode
@@ -70,7 +74,16 @@ export default function AccessControl(props: AccessControlProps) {
await updateAccessMode({ body: submitData })
toast.success(t(($) => $['accessControlDialog.updateSuccess'], { ns: 'app' }))
onConfirm?.()
}, [updateAccessMode, appId, specificGroups, specificMembers, t, onConfirm, currentMenu])
}, [
updateAccessMode,
appId,
specificGroups,
specificMembers,
t,
onConfirm,
currentMenu,
confirmDisabled,
])
return (
<AccessControlDialog show onClose={onClose}>
<div className="flex flex-col gap-y-3">
@@ -117,19 +130,29 @@ export default function AccessControl(props: AccessControlProps) {
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>
<AccessControlItem type={AccessMode.PUBLIC} disabled={publicAccessDisabled}>
<div className="flex items-center gap-x-2 p-3">
<RiGlobalLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">
{t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })}
</p>
{publicAccessDisabled && (
<Infotip
aria-label={t(($) => $['accessControlDialog.webAppPublicAccessDisabledTip'], {
ns: 'app',
})}
className="h-4 w-4 shrink-0 text-text-warning-secondary hover:text-text-warning-secondary"
>
{t(($) => $['accessControlDialog.webAppPublicAccessDisabledTip'], { ns: 'app' })}
</Infotip>
)}
</div>
</AccessControlItem>
</RadioGroup>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button>
<Button
disabled={isPending}
disabled={confirmDisabled}
loading={isPending}
variant="primary"
onClick={handleConfirm}
@@ -1,38 +0,0 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine, RiEditLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type IOperationBtnProps = {
className?: string
type: 'add' | 'edit'
actionName?: string
onClick?: () => void
}
const iconMap = {
add: <RiAddLine className="size-3.5" />,
edit: <RiEditLine className="size-3.5" />,
}
const OperationBtn: FC<IOperationBtnProps> = ({ className, type, actionName, onClick = noop }) => {
const { t } = useTranslation()
return (
<div
className={cn(
'flex h-7 cursor-pointer items-center space-x-1 rounded-md px-3 text-text-secondary select-none hover:bg-state-base-hover',
className,
)}
onClick={onClick}
>
<div>{iconMap[type]}</div>
<div className="text-xs font-medium">
{actionName || t(($) => $[`operation.${type}`], { ns: 'common' })}
</div>
</div>
)
}
export default React.memo(OperationBtn)
@@ -0,0 +1,35 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type OperationButtonProps = Omit<ButtonProps, 'children' | 'size' | 'variant'> & {
operation: 'add' | 'edit'
actionName?: string
}
export function OperationButton({
operation,
actionName,
className,
...buttonProps
}: OperationButtonProps) {
const { t } = useTranslation()
return (
<Button
{...buttonProps}
variant="ghost"
size="small"
className={cn('h-7 gap-1 px-3 text-text-secondary', className)}
>
<span
aria-hidden
className={cn('size-3.5', operation === 'add' ? 'i-ri-add-line' : 'i-ri-edit-line')}
/>
<span className="text-xs font-medium">
{actionName || t(($) => $[`operation.${operation}`], { ns: 'common' })}
</span>
</Button>
)
}
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { OperationButton } from '@/app/components/app/configuration/base/operation-button'
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
type Props = Readonly<{
@@ -32,7 +32,7 @@ const HistoryPanel: FC<Props> = ({ showWarning, onShowEditModal }) => {
{t(($) => $['feature.conversationHistory.description'], { ns: 'appDebug' })}
</div>
<div className="ml-3 h-[14px] w-px bg-divider-regular"></div>
<OperationBtn type="edit" onClick={onShowEditModal} />
<OperationButton operation="edit" onClick={onShowEditModal} />
</div>
}
noBodySpacing
@@ -9,7 +9,7 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { OperationButton } from '@/app/components/app/configuration/base/operation-button'
import { ApiConnection } from '@/app/components/base/icons/src/vender/solid/development'
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
import { InputVarType } from '@/app/components/workflow/types'
@@ -50,9 +50,7 @@ const SelectVarType: FC<Props> = ({ onChange }) => {
}
return (
<DropdownMenu>
<DropdownMenuTrigger nativeButton={false} render={<div className="block" />}>
<OperationBtn type="add" />
</DropdownMenuTrigger>
<DropdownMenuTrigger render={<OperationButton operation="add" />} />
<DropdownMenuContent
placement="bottom-end"
sideOffset={8}
@@ -17,7 +17,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { OperationButton } from '@/app/components/app/configuration/base/operation-button'
import AppIcon from '@/app/components/base/app-icon'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
@@ -190,7 +190,7 @@ const AgentTools: FC = () => {
<>
<div className="mr-1 ml-3 h-3.5 w-px bg-divider-regular"></div>
<ToolPicker
trigger={<OperationBtn type="add" />}
trigger={<OperationButton operation="add" />}
isShow={isShowChooseTool}
onShowChange={setIsShowChooseTool}
disabled={false}
@@ -35,7 +35,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { AppModeEnum } from '@/types/app'
import { getDatasetACLCapabilities } from '@/utils/permission'
import FeaturePanel from '../base/feature-panel'
import OperationBtn from '../base/operation-btn'
import { OperationButton } from '../base/operation-button'
import { useFormattingChangedDispatcher } from '../debug/hooks'
import CardItem from './card-item'
import ContextVar from './context-var'
@@ -294,7 +294,7 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => {
!readonly && (
<div className="flex items-center gap-1">
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
<OperationBtn type="add" onClick={showSelectDataSet} />
<OperationButton operation="add" onClick={showSelectDataSet} />
</div>
)
}
@@ -6,7 +6,6 @@ let currentBillingUrl: string | null = 'https://billing'
let fetching = false
let isManager = true
let enableBilling = true
let workspacePermissionKeys: string[] = ['billing.subscription.manage']
let billingUrlEnabled = false
const refetchMock = vi.fn()
@@ -39,14 +38,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
isCurrentWorkspaceManager: isManager,
workspacePermissionKeys,
}))
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
isCurrentWorkspaceManager: isManager,
workspacePermissionKeys,
}))
})
@@ -68,11 +59,10 @@ describe('Billing', () => {
isManager = true
enableBilling = true
billingUrlEnabled = false
workspacePermissionKeys = ['billing.subscription.manage']
refetchMock.mockResolvedValue({ data: 'https://billing' })
})
it('hides the billing action when subscription management permission is granted without manager role', () => {
it('hides the billing action from non-manager members', () => {
isManager = false
render(<Billing />)
@@ -83,16 +73,14 @@ describe('Billing', () => {
expect(billingUrlEnabled).toBe(false)
})
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
workspacePermissionKeys = []
it('shows the billing action to managers without billing permission keys', () => {
render(<Billing />)
expect(
screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }),
).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
vi.clearAllMocks()
workspacePermissionKeys = ['billing.subscription.manage']
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
expect(billingUrlEnabled).toBe(true)
})
it('hides the billing action when billing is disabled', () => {
enableBilling = false
render(<Billing />)
expect(
@@ -3,27 +3,21 @@ import type { FC } from 'react'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useBillingUrl } from '@/service/use-billing'
import { BillingPermission, hasPermission } from '@/utils/permission'
import PlanComp from '../plan'
const Billing: FC = () => {
const { t } = useTranslation()
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { enableBilling } = useProviderContext()
const canManageBillingSubscription =
isCurrentWorkspaceManager &&
hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
const {
data: billingUrl,
isFetching,
refetch,
} = useBillingUrl(enableBilling && canManageBillingSubscription)
} = useBillingUrl(enableBilling && isCurrentWorkspaceManager)
const openAsyncWindow = useAsyncWindowOpen()
const handleOpenBilling = async () => {
@@ -46,7 +40,7 @@ const Billing: FC = () => {
return (
<div>
<PlanComp loc="billing-page" />
{enableBilling && canManageBillingSubscription && (
{enableBilling && isCurrentWorkspaceManager && (
<button
type="button"
className="mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3"
@@ -3,21 +3,19 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { fetchSubscriptionUrls } from '@/service/billing'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { Plan } from '../type'
export const useEducationDiscount = () => {
const { t } = useTranslation()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const [isEducationDiscountLoading, setIsEducationDiscountLoading] = useState(false)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const handleEducationDiscount = useCallback(async () => {
if (isEducationDiscountLoading) return
if (!canManageBilling) {
if (!isCurrentWorkspaceManager) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}
@@ -29,7 +27,7 @@ export const useEducationDiscount = () => {
} finally {
setIsEducationDiscountLoading(false)
}
}, [canManageBilling, isEducationDiscountLoading, t])
}, [isCurrentWorkspaceManager, isEducationDiscountLoading, t])
return {
handleEducationDiscount,
+3 -5
View File
@@ -14,11 +14,10 @@ import VerifyStateModal from '@/app/education-apply/verify-state-modal'
import { IS_CLOUD_EDITION } from '@/config'
import { userProfileEmailAtom } from '@/context/account-state'
import { useModalContextSelector } from '@/context/modal-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { usePathname, useRouter } from '@/next/navigation'
import { useEducationVerify } from '@/service/use-education'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import Loading from '../../base/icons/src/public/thought/Loading'
import { NUM_INFINITE } from '../config'
@@ -38,7 +37,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
const router = useRouter()
const path = usePathname()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } =
useProviderContext()
const isAboutToExpire = allowRefreshEducationVerify
@@ -59,7 +58,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
const [showModal, setShowModal] = React.useState(false)
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const { mutateAsync, isPending } = useEducationVerify()
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const setEducationVerifying = useSetEducationVerifying()
@@ -112,7 +110,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
enableEducationPlan &&
isEducationAccount &&
type === Plan.sandbox &&
canManageBilling && (
isCurrentWorkspaceManager && (
<Button
variant="ghost"
onClick={handleEducationDiscount}
@@ -50,10 +50,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
@@ -79,7 +75,6 @@ describe('Pricing dialog lifecycle', () => {
latestOnOpenChange = undefined
mockConsoleState = {
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.manage'],
}
;(useProviderContext as Mock).mockReturnValue({
plan: {
+5 -7
View File
@@ -14,9 +14,8 @@ import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useState } from 'react'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { NoiseBottom, NoiseTop } from './assets'
import Footer from './footer'
import Header from './header'
@@ -31,14 +30,13 @@ type PricingProps = {
const Pricing: FC<PricingProps> = ({ onCancel }) => {
const { plan, enableEducationPlan, isEducationAccount } = useProviderContext()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const shouldDefaultToYearly = canManageBilling && enableEducationPlan && isEducationAccount
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const shouldDefaultToYearly =
isCurrentWorkspaceManager && enableEducationPlan && isEducationAccount
const [selectedPlanRange, setSelectedPlanRange] = React.useState<PlanRange>()
const planRange =
selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly)
const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD)
const canPay = canManageBilling
const pricingPageLanguage = useGetPricingPageLanguage()
const pricingPageURL = pricingPageLanguage
@@ -71,7 +69,7 @@ const Pricing: FC<PricingProps> = ({ onCancel }) => {
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
canPay={isCurrentWorkspaceManager}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute inset-x-0 -bottom-12 -z-10">
@@ -10,16 +10,13 @@ import {
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { ALL_PLANS } from '../../../config'
import { useEducationDiscount } from '../../../hooks/use-education-discount'
import { Plan } from '../../../type'
@@ -52,12 +49,6 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
const isCurrent = plan === currentPlan
const isCurrentPaidPlan = isCurrent && !isFreePlan
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const canManageBillingSubscription = hasPermission(
workspacePermissionKeys,
BillingPermission.SubscriptionManage,
)
const { enableEducationPlan, isEducationAccount } = useProviderContext()
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
@@ -90,7 +81,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
setLoading(true)
try {
if (isCurrentPaidPlan) {
if (!canManageBillingSubscription) {
if (!canPay) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}
@@ -112,7 +103,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
if (isFreePlan) return
if (!canManageBilling) {
if (!canPay) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}
@@ -1,14 +1,10 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Azure, GoogleCloud } from '@/app/components/base/icons/src/public/billing'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import { SelfHostedPlan } from '../../../type'
import { Community, Enterprise, EnterpriseNoise, Premium, PremiumNoise } from '../../assets'
@@ -51,14 +47,8 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ plan }) => {
const isFreePlan = plan === SelfHostedPlan.community
const isPremiumPlan = plan === SelfHostedPlan.premium
const isEnterprisePlan = plan === SelfHostedPlan.enterprise
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
const handleGetPayUrl = useCallback(() => {
if (!canManageBilling) {
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
return
}
if (isFreePlan) {
window.location.href = getStartedWithCommunityUrl
return
@@ -69,7 +59,7 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ plan }) => {
}
if (isEnterprisePlan) window.location.href = contactSalesUrl
}, [canManageBilling, isFreePlan, isPremiumPlan, isEnterprisePlan, t])
}, [isFreePlan, isPremiumPlan, isEnterprisePlan])
return (
<div className="relative flex flex-1 flex-col overflow-hidden">
@@ -10,7 +10,6 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
import AccountSection from '@/app/components/main-nav/components/account-section'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import { createAccountProfileQueryClient } from '@/test/console/account-profile'
import { renderWithConsoleQuery } from '@/test/console/query-data'
@@ -55,6 +54,7 @@ const mockConsoleState = vi.hoisted(() => ({
current: undefined as ConsoleStateFixture | undefined,
}))
const mockConsoleStateReader = vi.hoisted(() => vi.fn())
const mockUseRouter = vi.hoisted(() => vi.fn())
vi.mock('next-themes', () => ({
useTheme: () => ({
@@ -97,7 +97,7 @@ vi.mock('@/next/navigation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/next/navigation')>()
return {
...actual,
useRouter: vi.fn(),
useRouter: mockUseRouter,
}
})
@@ -223,13 +223,8 @@ describe('AccountDropdown', () => {
vi.mocked(useLogout).mockReturnValue({
mutateAsync: mockLogout,
} as unknown as ReturnType<typeof useLogout>)
vi.mocked(useRouter).mockReturnValue({
mockUseRouter.mockReturnValue({
push: mockPush,
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
})
})
@@ -199,9 +199,6 @@ const baseConsoleState: ConsoleStateFixture = {
'data_source.manage',
'api_extension.manage',
'customization.manage',
'billing.view',
'billing.manage',
'billing.subscription.manage',
],
}
@@ -381,7 +378,7 @@ describe('AccountSetting', () => {
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
})
it('should not hide workspace menu items solely for dataset operators', () => {
it('should hide billing from dataset operators', () => {
// Arrange
const datasetOperatorContext = {
...baseConsoleState,
@@ -400,7 +397,9 @@ describe('AccountSetting', () => {
expect(
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.settings.billing' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
expect(
@@ -512,23 +511,26 @@ describe('AccountSetting', () => {
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
})
it('should hide billing entry when billing view permission is missing', () => {
it('should show billing to regular members without billing permission keys', () => {
// Arrange
const contextWithoutBillingViewPermission = {
const regularMemberContext = {
...baseConsoleState,
workspacePermissionKeys: baseConsoleState.workspacePermissionKeys!.filter(
(key) => key !== 'billing.view',
),
currentWorkspace: {
...baseConsoleState.currentWorkspace,
role: 'normal' as const,
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceDatasetOperator: false,
workspacePermissionKeys: [],
}
mockConsoleState.current = contextWithoutBillingViewPermission
mockConsoleState.current = regularMemberContext
// Act
renderAccountSetting()
// Assert
expect(
screen.queryByRole('button', { name: 'common.settings.billing' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
})
it('should hide workflow log archives outside cloud edition', () => {
@@ -582,20 +584,36 @@ describe('AccountSetting', () => {
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
})
it('should not render billing page when active billing tab lacks billing view permission', () => {
it('should render a direct billing entry for regular members without billing permission keys', () => {
// Arrange
const contextWithoutBillingViewPermission = {
const regularMemberContext = {
...baseConsoleState,
workspacePermissionKeys: baseConsoleState.workspacePermissionKeys!.filter(
(key) => key !== 'billing.view',
),
currentWorkspace: {
...baseConsoleState.currentWorkspace,
role: 'normal' as const,
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceDatasetOperator: false,
workspacePermissionKeys: [],
}
mockConsoleState.current = contextWithoutBillingViewPermission
mockConsoleState.current = regularMemberContext
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.BILLING })
// Assert
expect(screen.getByTestId('billing-page')).toBeInTheDocument()
})
it('should not render a direct billing entry for dataset operators', () => {
mockConsoleState.current = {
...baseConsoleState,
isCurrentWorkspaceDatasetOperator: true,
}
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.BILLING })
expect(screen.queryByTestId('billing-page')).not.toBeInTheDocument()
})
})
@@ -1,6 +1,10 @@
import type { PermissionGroup } from '@/models/access-control'
import type { PermissionCatalogResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/client'
import { createTestQueryClient } from '@/test/query-client'
import PermissionSetModal from '../index'
const expectedAppACLPermissionKeys = [
@@ -19,59 +23,71 @@ const expectedAppACLPermissionKeys = [
const getPermissionKeyMatcher = (permissionKey: string) =>
new RegExp(permissionKey.replaceAll('.', '\\.'))
const mockCatalogs = vi.hoisted(() => ({
app: {
groups: [] as PermissionGroup[],
},
dataset: {
groups: [] as PermissionGroup[],
},
}))
vi.mock('@/service/access-control/use-permission-catalog', () => ({
useAppPermissionCatalog: () => ({
data: { groups: mockCatalogs.app.groups },
}),
useDatasetPermissionCatalog: () => ({
data: { groups: mockCatalogs.dataset.groups },
}),
}))
const createPermissionGroup = (overrides: Partial<PermissionGroup> = {}): PermissionGroup => ({
group_key: 'app_management',
group_name: 'App management',
description: '',
permissions: expectedAppACLPermissionKeys.map((permissionKey) => ({
key: permissionKey,
name: permissionKey,
description: '',
})),
...overrides,
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'permission.group.app_acl': 'Translated app permissions',
'permission.group.dataset_acl': 'Translated dataset permissions',
})
})
const appPermissionCatalog = {
groups: [
{
group_key: 'app_management',
group_name: 'App management',
description: '',
permissions: expectedAppACLPermissionKeys.map((permissionKey) => ({
key: permissionKey,
name: permissionKey,
description: '',
})),
},
],
} satisfies PermissionCatalogResponse
const datasetPermissionCatalog = {
groups: [
{
group_key: 'dataset_management',
group_name: 'Dataset management',
description: '',
permissions: [
{
key: 'dataset.acl.edit',
name: 'Edit dataset',
description: '',
},
],
},
],
} satisfies PermissionCatalogResponse
const renderModal = (modal: ReactNode) => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(
consoleQuery.workspaces.current.rbac.rolePermissions.catalog.app.get.queryKey({ input: {} }),
appPermissionCatalog,
)
queryClient.setQueryData(
consoleQuery.workspaces.current.rbac.rolePermissions.catalog.dataset.get.queryKey({
input: {},
}),
datasetPermissionCatalog,
)
return render(<QueryClientProvider client={queryClient}>{modal}</QueryClientProvider>)
}
describe('PermissionSetModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCatalogs.app.groups = [createPermissionGroup()]
mockCatalogs.dataset.groups = [
createPermissionGroup({
group_key: 'dataset_management',
group_name: 'Dataset management',
permissions: [
{
key: 'dataset.acl.edit',
name: 'Edit dataset',
description: '',
},
],
}),
]
})
// Rendering keeps the form fields and permission picker available inside the modal.
describe('Rendering', () => {
it('should render create mode with app permission catalog', () => {
render(
renderModal(
<PermissionSetModal
open
mode="create"
@@ -86,7 +102,7 @@ describe('PermissionSetModal', () => {
).toBeInTheDocument()
expect(screen.getByLabelText(/permission\.permissionSet\.nameLabel/)).toBeInTheDocument()
expect(screen.getByLabelText('permission.permissionSet.descriptionLabel')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /App management/ })).toHaveAttribute(
expect(screen.getByRole('button', { name: /Translated app permissions/ })).toHaveAttribute(
'aria-expanded',
'true',
)
@@ -94,7 +110,7 @@ describe('PermissionSetModal', () => {
})
it('should render the complete app ACL permission catalog', async () => {
render(
renderModal(
<PermissionSetModal
open
mode="create"
@@ -109,7 +125,7 @@ describe('PermissionSetModal', () => {
})
it('should render dataset permission catalog when resource type is dataset', () => {
render(
renderModal(
<PermissionSetModal
open
mode="create"
@@ -122,7 +138,9 @@ describe('PermissionSetModal', () => {
expect(
screen.getByText('permission.permissionSet.modal.create.dataset.title'),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Dataset management/ })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Translated dataset permissions/ }),
).toBeInTheDocument()
expect(screen.getByText(/dataset\.acl\.edit/)).toBeInTheDocument()
})
})
@@ -134,7 +152,7 @@ describe('PermissionSetModal', () => {
const handleClose = vi.fn()
const handleSubmit = vi.fn()
render(
renderModal(
<PermissionSetModal
open
mode="create"
@@ -168,7 +186,7 @@ describe('PermissionSetModal', () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
render(
renderModal(
<PermissionSetModal
open
mode="edit"
@@ -200,7 +218,7 @@ describe('PermissionSetModal', () => {
// View mode is read-only and uses close-only footer actions.
describe('Read-only Mode', () => {
it('should disable editing and hide confirm action in view mode', () => {
render(
renderModal(
<PermissionSetModal
open
mode="view"
@@ -1,18 +1,21 @@
import type { SelectorKey } from 'i18next'
import type { AccessPolicyResourceType } from '@/models/access-control'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
useAppPermissionCatalog,
useDatasetPermissionCatalog,
} from '@/service/access-control/use-permission-catalog'
import { consoleQuery } from '@/service/client'
export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => {
const { t } = useTranslation()
const { data: appPermissionCatalog } = useAppPermissionCatalog(resourceType === 'app')
const { data: datasetPermissionCatalog } = useDatasetPermissionCatalog(resourceType === 'dataset')
const permissionCatalog = resourceType === 'app' ? appPermissionCatalog : datasetPermissionCatalog
const permissionCatalogQueryOptions =
resourceType === 'app'
? consoleQuery.workspaces.current.rbac.rolePermissions.catalog.app.get.queryOptions({
input: {},
})
: consoleQuery.workspaces.current.rbac.rolePermissions.catalog.dataset.get.queryOptions({
input: {},
})
const { data: permissionCatalog } = useQuery(permissionCatalogQueryOptions)
const groups = useMemo(() => {
// Permission keys come from the catalog API, so this is a reviewed open-key boundary with a server-provided fallback.
@@ -22,20 +25,20 @@ export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) =>
defaultValue,
})
return (permissionCatalog?.groups || []).map((group) => ({
return (permissionCatalog?.groups ?? []).map((group) => ({
...group,
group_name: t(($) => $[`group.${resourceType}_acl`], {
ns: 'permission',
defaultValue: group.group_name,
}),
permissions: group.permissions.map((permission) => ({
permissions: (group.permissions ?? []).map((permission) => ({
...permission,
name: translatePermissionName(permission.key, permission.name),
})),
}))
}, [permissionCatalog?.groups, resourceType, t])
const allPermissions = groups.flatMap((g) => g.permissions) || []
const allPermissions = groups.flatMap((g) => g.permissions)
const permissionMap = Object.fromEntries(allPermissions.map((p) => [p.key, p]))
@@ -14,10 +14,13 @@ import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import { IS_CLOUD_EDITION } from '@/config'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import {
isCurrentWorkspaceDatasetOperatorAtom,
isCurrentWorkspaceManagerAtom,
} from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { BillingPermission, hasPermission } from '@/utils/permission'
import { hasPermission } from '@/utils/permission'
import AccessRulesPage from './access-rules-page'
import { ApiBasedExtensionPage } from './api-based-extension-page'
import DataSourcePage from './data-source-page-new'
@@ -58,11 +61,11 @@ export default function AccountSetting({
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const canManageWorkspaceRoles =
isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const canViewBilling =
enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)
const canViewBilling = enableBilling && !isCurrentWorkspaceDatasetOperator
const canViewWorkflowLogArchives = IS_CLOUD_EDITION && isCurrentWorkspaceManager
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
const normalizedActiveTab =
@@ -1,31 +1,47 @@
import type { PermissionGroup, Role } from '@/models/access-control'
import type { PermissionCatalogResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { Role } from '@/models/access-control'
import { QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/client'
import { createTestQueryClient } from '@/test/query-client'
import RoleModal from '../index'
const mockWorkspacePermissionCatalog = vi.hoisted(() => ({
groups: [] as PermissionGroup[],
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'permission.group.workspace_management': 'Translated workspace permissions',
'permissionKeys.workspace.member.manage': 'Manage workspace members',
})
})
vi.mock('@/service/access-control/use-permission-catalog', () => ({
useWorkspacePermissionCatalog: () => ({
data: { groups: mockWorkspacePermissionCatalog.groups },
}),
}))
const createPermissionGroup = (overrides: Partial<PermissionGroup> = {}): PermissionGroup => ({
group_key: 'workspace_management',
group_name: 'Workspace management',
description: '',
permissions: [
const workspacePermissionCatalog = {
groups: [
{
key: 'workspace.member.manage',
name: 'Manage members',
group_key: 'workspace_management',
group_name: 'Workspace management',
description: '',
permissions: [
{
key: 'workspace.member.manage',
name: 'Manage members',
description: '',
},
],
},
],
...overrides,
})
} satisfies PermissionCatalogResponse
const renderModal = (modal: ReactNode) => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(
consoleQuery.workspaces.current.rbac.rolePermissions.catalog.get.queryKey({ input: {} }),
workspacePermissionCatalog,
)
return render(<QueryClientProvider client={queryClient}>{modal}</QueryClientProvider>)
}
const createRole = (overrides: Partial<Role> = {}): Role => ({
id: 'role-1',
@@ -43,13 +59,12 @@ const createRole = (overrides: Partial<Role> = {}): Role => ({
describe('RoleModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkspacePermissionCatalog.groups = [createPermissionGroup()]
})
// Rendering keeps role fields and workspace permissions in one modal form.
describe('Rendering', () => {
it('should render edit mode with role values and selected permissions', () => {
render(
renderModal(
<RoleModal open mode="edit" role={createRole()} onClose={vi.fn()} onSubmit={vi.fn()} />,
)
@@ -58,15 +73,14 @@ describe('RoleModal', () => {
expect(screen.getByLabelText('permission.role.modal.descriptionLabel')).toHaveValue(
'Can operate workspace',
)
expect(screen.getByRole('button', { name: /Workspace management/ })).toHaveAttribute(
'aria-expanded',
'true',
)
expect(screen.getByText(/workspace\.member\.manage/)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Translated workspace permissions/ }),
).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Manage workspace members')).toBeInTheDocument()
})
it('should disable confirm action when role name is empty', () => {
render(<RoleModal open mode="create" onClose={vi.fn()} onSubmit={vi.fn()} />)
renderModal(<RoleModal open mode="create" onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled()
})
@@ -79,14 +93,14 @@ describe('RoleModal', () => {
const handleClose = vi.fn()
const handleSubmit = vi.fn()
render(<RoleModal open mode="create" onClose={handleClose} onSubmit={handleSubmit} />)
renderModal(<RoleModal open mode="create" onClose={handleClose} onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('permission.role.modal.nameLabel'), ' Support role ')
await user.type(
screen.getByLabelText('permission.role.modal.descriptionLabel'),
' Helps members ',
)
await user.click(screen.getByText(/workspace\.member\.manage/))
await user.click(screen.getByText('Manage workspace members'))
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleSubmit).toHaveBeenCalledTimes(1)
@@ -102,7 +116,7 @@ describe('RoleModal', () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
render(
renderModal(
<RoleModal
open
mode="edit"
@@ -132,7 +146,7 @@ describe('RoleModal', () => {
// View mode preserves the permission display but blocks edits and confirmation.
describe('Read-only Mode', () => {
it('should render role details as read-only in view mode', () => {
render(
renderModal(
<RoleModal open mode="view" role={createRole()} onClose={vi.fn()} onSubmit={vi.fn()} />,
)
@@ -1,11 +1,16 @@
import type { SelectorKey } from 'i18next'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useWorkspacePermissionCatalog } from '@/service/access-control/use-permission-catalog'
import { consoleQuery } from '@/service/client'
export const useWorkspacePermissionGroups = () => {
const { t } = useTranslation()
const { data: workspacePermissionCatalog } = useWorkspacePermissionCatalog()
const { data: workspacePermissionCatalog } = useQuery(
consoleQuery.workspaces.current.rbac.rolePermissions.catalog.get.queryOptions({
input: {},
}),
)
const groups = useMemo(() => {
// Permission keys come from the catalog API, so these are reviewed open-key boundaries with server-provided fallbacks.
@@ -20,17 +25,17 @@ export const useWorkspacePermissionGroups = () => {
defaultValue,
})
return (workspacePermissionCatalog?.groups || []).map((group) => ({
return (workspacePermissionCatalog?.groups ?? []).map((group) => ({
...group,
group_name: translatePermissionGroupName(group.group_key, group.group_name),
permissions: group.permissions.map((permission) => ({
permissions: (group.permissions ?? []).map((permission) => ({
...permission,
name: translatePermissionName(permission.key, permission.name),
})),
}))
}, [t, workspacePermissionCatalog?.groups])
const allPermissions = groups.flatMap((g) => g.permissions) || []
const allPermissions = groups.flatMap((g) => g.permissions)
const permissionMap = Object.fromEntries(allPermissions.map((p) => [p.key, p]))
@@ -122,6 +122,22 @@ describe('getPluginLinkInMarketplace', () => {
})
})
describe('getPluginDetailLinkInMarketplace', () => {
it('should return the local detail link for a regular plugin', async () => {
const { getPluginDetailLinkInMarketplace } = await import('../utils')
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
expect(getPluginDetailLinkInMarketplace(plugin)).toBe('/plugin/test-org/test-plugin')
})
it('should return the local detail link for a bundle', async () => {
const { getPluginDetailLinkInMarketplace } = await import('../utils')
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
expect(getPluginDetailLinkInMarketplace(bundle)).toBe('/bundles/test-org/test-bundle')
})
})
describe('getMarketplaceListCondition', () => {
it('should return category condition for tool', async () => {
const { getMarketplaceListCondition } = await import('../utils')
@@ -8,6 +8,7 @@ import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
type MarketplaceProps = {
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
pluginTypeSwitchClassName?: string
isMarketplacePlatform?: boolean
marketplaceNav?: React.ReactNode
@@ -19,6 +20,7 @@ type MarketplaceProps = {
const Marketplace = async ({
showInstallButton = false,
linkToMarketplaceDetail = false,
pluginTypeSwitchClassName,
isMarketplacePlatform = false,
marketplaceNav,
@@ -35,7 +37,10 @@ const Marketplace = async ({
{!isMarketplacePlatform && (
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} />
)}
<ListWrapper showInstallButton={showInstallButton} />
<ListWrapper
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>
</PluginInstallPermissionProviderGuard>
</HydrateQueryClient>
</TanstackQueryInitializer>
@@ -87,13 +87,19 @@ describe('CardWrapper', () => {
</ThemeProvider>,
)
it('renders a non-navigating card when install button is hidden', () => {
it('renders a non-navigating card by default when install button is hidden', () => {
renderCardWrapper()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent')
})
it('links the card to its marketplace detail when explicitly enabled', () => {
renderCardWrapper({ linkToMarketplaceDetail: true })
expect(screen.getByRole('link')).toHaveAttribute('href', '/detail/dify/plugin-a')
})
it('renders install and marketplace detail actions when install button is shown', () => {
renderCardWrapper({ showInstallButton: true })
@@ -11,17 +11,20 @@ import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { useTags } from '@/app/components/plugins/hooks'
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { getPluginLinkInMarketplace } from '../utils'
import Link from '@/next/link'
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
type CardWrapperProps = {
plugin: Plugin
showInstallButton?: boolean
isInstalled?: boolean
linkToMarketplaceDetail?: boolean
}
const CardWrapperComponent = ({
plugin,
showInstallButton,
isInstalled = false,
linkToMarketplaceDetail = false,
}: CardWrapperProps) => {
const { t } = useTranslation()
const { theme } = useTheme()
@@ -102,7 +105,7 @@ const CardWrapperComponent = ({
)
}
return (
const card = (
<div className="group relative rounded-xl">
<Card
key={plugin.name}
@@ -118,6 +121,17 @@ const CardWrapperComponent = ({
/>
</div>
)
if (!linkToMarketplaceDetail) return card
return (
<Link
href={getPluginDetailLinkInMarketplace(plugin)}
className="block rounded-xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
{card}
</Link>
)
}
// Memoize the component to prevent unnecessary re-renders when props haven't changed
@@ -15,6 +15,7 @@ type ListProps = {
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
plugins?: Plugin[]
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
emptyClassName?: string
@@ -25,6 +26,7 @@ const List = ({
marketplaceCollectionPluginsMap,
plugins,
showInstallButton,
linkToMarketplaceDetail,
cardContainerClassName,
cardRender,
emptyClassName,
@@ -62,6 +64,7 @@ const List = ({
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
onCollectionMoreClick={onCollectionMoreClick}
@@ -79,6 +82,7 @@ const List = ({
plugin={plugin}
showInstallButton={showInstallButton}
isInstalled={installedPluginIds.has(plugin.plugin_id)}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>
)
})}
@@ -35,6 +35,7 @@ type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void
@@ -46,13 +47,25 @@ type PluginCardProps = {
showInstallButton?: boolean
cardRender?: (plugin: Plugin) => React.JSX.Element | null
isInstalled?: boolean
linkToMarketplaceDetail?: boolean
}
const PluginCard = ({ plugin, showInstallButton, cardRender, isInstalled }: PluginCardProps) => {
const PluginCard = ({
plugin,
showInstallButton,
cardRender,
isInstalled,
linkToMarketplaceDetail,
}: PluginCardProps) => {
if (cardRender) return cardRender(plugin)
return (
<CardWrapper plugin={plugin} showInstallButton={showInstallButton} isInstalled={isInstalled} />
<CardWrapper
plugin={plugin}
showInstallButton={showInstallButton}
isInstalled={isInstalled}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>
)
}
@@ -60,6 +73,7 @@ const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
linkToMarketplaceDetail,
cardContainerClassName,
cardRender,
onCollectionMoreClick,
@@ -147,6 +161,7 @@ const ListWithCollection = ({
<PluginCard
plugin={plugin}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
@@ -163,6 +178,7 @@ const ListWithCollection = ({
key={plugin.plugin_id}
plugin={plugin}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
@@ -7,8 +7,9 @@ import List from './index'
type ListWrapperProps = {
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
}
const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapperProps) => {
const { t } = useTranslation()
const {
@@ -45,6 +46,7 @@ const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>
)}
</div>
@@ -28,12 +28,18 @@ describe('SearchBox', () => {
const user = userEvent.setup()
render(<SearchHarness usedInMarketplace={mode} />)
const input = screen.getByPlaceholderText('Search plugins')
const input = screen.getByRole('searchbox', { name: 'Search plugins' })
await user.type(input, 'agent')
expect(input).toHaveValue('agent')
await user.click(screen.getByRole('button'))
await user.tab()
const clearButton = screen.getByRole('button', {
name: /^plugin\.clearSearch/,
})
expect(clearButton).toHaveFocus()
await user.keyboard('{Enter}')
expect(input).toHaveValue('')
expect(input).toHaveFocus()
})
it('opens the custom tool flow from the add button', async () => {
@@ -52,7 +58,9 @@ describe('SearchBox', () => {
/>,
)
await user.click(screen.getByRole('button'))
const addButton = screen.getByRole('button', { name: 'tools.addToolModal.custom.tip' })
addButton.focus()
await user.keyboard('{Enter}')
expect(onShowAddCustomCollectionModal).toHaveBeenCalledOnce()
})
@@ -71,6 +79,6 @@ describe('SearchBox', () => {
/>,
)
expect(ref.current).toBe(screen.getByPlaceholderText('Search plugins'))
expect(ref.current).toBe(screen.getByRole('searchbox', { name: 'Search plugins' }))
})
})
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagsFilter from '../tags-filter'
@@ -32,51 +33,14 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
placeholder,
}: {
value: string
onChange: (event: { target: { value: string } }) => void
placeholder: string
}) => (
<input
aria-label="tags-search"
value={value}
placeholder={placeholder}
onChange={(event) => onChange({ target: { value: event.target.value } })}
/>
),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../trigger/marketplace', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="marketplace-trigger">
marketplace:
{selectedTagsLength}
</div>
),
}))
vi.mock('../trigger/tool-selector', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="tool-trigger">
tool:
{selectedTagsLength}
</div>
),
}))
describe('TagsFilter', () => {
const ensurePopoverOpen = () => {
if (!screen.queryByTestId('popover-content'))
fireEvent.click(screen.getByTestId('popover-trigger'))
const ensurePopoverOpen = async (user: ReturnType<typeof userEvent.setup>) => {
if (!screen.queryByRole('searchbox', { name: 'pluginTags.searchTags' }))
await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
return screen.getByTestId('popover-content')
return screen.getByRole('searchbox', { name: 'pluginTags.searchTags' })
}
beforeEach(() => {
@@ -86,53 +50,43 @@ describe('TagsFilter', () => {
)
})
it('renders marketplace trigger when used in marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} usedInMarketplace />)
expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1')
expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument()
})
it('renders tool selector trigger when used outside marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} />)
expect(screen.getByTestId('tool-trigger')).toHaveTextContent('tool:1')
expect(screen.queryByTestId('marketplace-trigger')).not.toBeInTheDocument()
})
it('filters tag options by search text', () => {
it('filters tag options by search text', async () => {
const user = userEvent.setup()
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
const search = await ensurePopoverOpen(user)
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.getByText('Search')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Agent' })).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Search' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } })
await user.type(search, 'ra')
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.queryByText('Search')).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: 'Agent' })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: 'Search' })).not.toBeInTheDocument()
})
it('adds and removes selected tags when options are clicked', () => {
it('adds and removes selected tags when options are clicked', async () => {
const user = userEvent.setup()
const onTagsChange = vi.fn()
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
await user.click(screen.getByRole('button', { name: 'Agent' }))
await user.click(screen.getByRole('checkbox', { name: 'Agent' }))
expect(onTagsChange).toHaveBeenCalledWith([])
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(within(ensurePopoverOpen()).getByText('RAG'))
await user.click(screen.getByRole('checkbox', { name: 'RAG' }))
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
})
it('falls back to an empty placeholder when translation is missing', () => {
it('falls back to an empty placeholder when translation is missing', async () => {
const user = userEvent.setup()
mockTranslate.mockImplementation(() => undefined as unknown as string)
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
await user.click(screen.getByRole('button'))
expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
expect(screen.getByRole('searchbox')).toHaveAttribute('placeholder', '')
})
})
@@ -1,8 +1,9 @@
'use client'
import type { Ref } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiAddLine, RiCloseLine, RiSearchLine } from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import { useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import TagsFilter from './tags-filter'
@@ -20,11 +21,10 @@ type SearchBoxProps = {
supportAddCustomTool?: boolean
usedInMarketplace?: boolean
onShowAddCustomCollectionModal?: () => void
onAddedCustomTool?: () => void
autoFocus?: boolean
showTags?: boolean
}
const SearchBox = ({
function SearchBox({
ref,
search,
onSearchChange,
@@ -40,7 +40,17 @@ const SearchBox = ({
onShowAddCustomCollectionModal,
autoFocus = false,
showTags = true,
}: SearchBoxProps) => {
}: SearchBoxProps) {
const { t } = useTranslation()
const accessibleLabel = placeholder || t(($) => $.searchTools, { ns: 'plugin' })!
const inputRef = useRef<HTMLInputElement>(null)
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, [])
const handleClear = () => {
onSearchChange('')
inputRef.current?.focus()
}
return (
<div className={cn('z-11 flex items-center', wrapperClassName)}>
<div
@@ -63,10 +73,13 @@ const SearchBox = ({
)}
<div className="flex grow items-center gap-x-2 p-1">
<input
ref={ref}
aria-label={placeholder || undefined}
ref={inputRef}
type="search"
name="query"
autoComplete="off"
aria-label={accessibleLabel}
className={cn(
'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden',
'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
inputElementClassName,
)}
value={search}
@@ -76,9 +89,18 @@ const SearchBox = ({
placeholder={placeholder}
/>
{search && (
<ActionButton onClick={() => onSearchChange('')} className="shrink-0">
<RiCloseLine className="size-4" />
</ActionButton>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSearch, {
ns: 'plugin',
label: accessibleLabel,
})}
onClick={handleClear}
className="size-6 min-h-0 shrink-0 p-0 focus-visible:ring-inset"
>
<span className="i-ri-close-line size-4" aria-hidden />
</Button>
)}
</div>
</>
@@ -86,16 +108,24 @@ const SearchBox = ({
{!usedInMarketplace && (
<>
<div className="flex h-8 min-w-0 grow items-center pr-2 pl-2">
<RiSearchLine
className={cn('size-4 text-components-input-text-placeholder', searchIconClassName)}
<span
aria-hidden
className={cn(
'i-ri-search-line',
'size-4 text-components-input-text-placeholder',
searchIconClassName,
)}
/>
<input
ref={ref}
aria-label={placeholder || undefined}
ref={inputRef}
type="search"
name="query"
autoComplete="off"
aria-label={accessibleLabel}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={cn(
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder',
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
search && 'mr-2',
inputElementClassName,
)}
@@ -106,9 +136,18 @@ const SearchBox = ({
placeholder={placeholder}
/>
{search && (
<ActionButton size="xs" onClick={() => onSearchChange('')} className="shrink-0">
<RiCloseLine className="size-4" />
</ActionButton>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSearch, {
ns: 'plugin',
label: accessibleLabel,
})}
onClick={handleClear}
className="size-6 min-h-0 shrink-0 p-0 focus-visible:ring-inset"
>
<span className="i-ri-close-line size-4" aria-hidden />
</Button>
)}
</div>
{showTags && (
@@ -122,12 +161,15 @@ const SearchBox = ({
</div>
{supportAddCustomTool && (
<div className="flex shrink-0 items-center">
<ActionButton
className="ml-2 rounded-full bg-components-button-primary-bg text-components-button-primary-text hover:bg-components-button-primary-bg hover:text-components-button-primary-text"
<Button
variant="primary"
size="small"
aria-label={t(($) => $['addToolModal.custom.tip'], { ns: 'tools' })}
className="ml-2 size-6 min-h-0 rounded-full p-0"
onClick={onShowAddCustomCollectionModal}
>
<RiAddLine className="size-4" />
</ActionButton>
<span className="i-ri-add-line size-4" aria-hidden />
</Button>
</div>
)}
</div>
@@ -2,10 +2,10 @@
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Input } from '@langgenius/dify-ui/input'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from '#i18n'
import Input from '@/app/components/base/input'
import { useTags } from '@/app/components/plugins/hooks'
import MarketplaceTrigger from './trigger/marketplace'
import ToolSelectorTrigger from './trigger/tool-selector'
@@ -15,7 +15,7 @@ type TagsFilterProps = {
onTagsChange: (tags: string[]) => void
usedInMarketplace?: boolean
}
const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) => {
function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
@@ -27,31 +27,24 @@ const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
nativeButton={false}
render={
<div className="shrink-0">
{usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
{!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
</div>
}
/>
{usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
{!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
@@ -60,12 +53,22 @@ const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
>
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<Input
showLeftIcon
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
/>
<div className="relative">
<span
aria-hidden
className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder"
/>
<Input
type="search"
name="tag-query"
autoComplete="off"
aria-label={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
className="pl-6.5"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
/>
</div>
</div>
<CheckboxGroup
aria-label={t(($) => $.allTags, { ns: 'pluginTags' })}
@@ -1,5 +1,8 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { Popover } from '@langgenius/dify-ui/popover'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import MarketplaceTrigger from '../marketplace'
@@ -11,51 +14,70 @@ const tagsMap: Record<string, Tag> = {
describe('MarketplaceTrigger', () => {
it('shows all-tags text when no tags are selected', () => {
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
render(
<Popover>
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
expect(container.querySelectorAll('svg').length).toBe(2)
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
})
it('shows selected tag labels and overflow count', () => {
render(
<MarketplaceTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
<Popover>
<MarketplaceTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument()
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
it('clears selected tags from a separate button', async () => {
const user = userEvent.setup()
function Harness() {
const [tags, setTags] = useState(['agent'])
return (
<Popover>
<MarketplaceTrigger
selectedTagsLength={tags.length}
open={false}
tags={tags}
tagsMap={tagsMap}
onTagsChange={setTags}
/>
</Popover>
)
}
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
)
render(<Harness />)
fireEvent.click(container.querySelectorAll('svg')[1]!)
const trigger = screen.getByRole('button', { name: 'Agent' })
const clearButton = screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ })
expect(trigger).not.toContainElement(clearButton)
expect(onTagsChange).toHaveBeenCalledWith([])
await user.click(clearButton)
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus()
})
})
@@ -1,5 +1,8 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import ToolSelectorTrigger from '../tool-selector'
@@ -11,51 +14,103 @@ const tagsMap: Record<string, Tag> = {
describe('ToolSelectorTrigger', () => {
it('renders only icon when no tags are selected', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
render(
<Popover>
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(container.querySelectorAll('svg')).toHaveLength(1)
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
})
it('renders selected tag labels and overflow count', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
render(
<Popover>
<ToolSelectorTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
expect(container.querySelectorAll('svg')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).toBeInTheDocument()
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
it('opens the tag filter from the keyboard', async () => {
const user = userEvent.setup()
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
render(
<Popover>
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
<PopoverContent>Tag options</PopoverContent>
</Popover>,
)
fireEvent.click(container.querySelectorAll('svg')[1]!)
const trigger = screen.getByRole('button', { name: 'pluginTags.allTags' })
await user.tab()
expect(trigger).toHaveFocus()
expect(onTagsChange).toHaveBeenCalledWith([])
await user.keyboard('{Enter}')
expect(screen.getByText('Tag options')).toBeInTheDocument()
})
it('keeps clear as a separate action from the popover trigger', async () => {
const user = userEvent.setup()
function Harness() {
const [tags, setTags] = useState(['agent'])
return (
<Popover>
<ToolSelectorTrigger
selectedTagsLength={tags.length}
open={false}
tags={tags}
tagsMap={tagsMap}
onTagsChange={setTags}
/>
</Popover>
)
}
render(<Harness />)
const trigger = screen.getByRole('button', { name: 'Agent' })
const clearButton = screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ })
expect(trigger).not.toContainElement(clearButton)
trigger.focus()
await user.tab()
expect(clearButton).toHaveFocus()
await user.keyboard('{Enter}')
expect(
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus()
})
})
@@ -1,7 +1,8 @@
import type { Tag } from '../../../hooks'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
import * as React from 'react'
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
import { memo, useEffect, useRef } from 'react'
import { useTranslation } from '#i18n'
type MarketplaceTriggerProps = {
@@ -12,54 +13,100 @@ type MarketplaceTriggerProps = {
onTagsChange: (tags: string[]) => void
}
const MarketplaceTrigger = ({
function MarketplaceTrigger({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
}: MarketplaceTriggerProps) => {
}: MarketplaceTriggerProps) {
const { t } = useTranslation()
const triggerRef = useRef<HTMLButtonElement>(null)
const shouldRestoreFocusRef = useRef(false)
const selectedTagLabels = tags.map((tag) => tagsMap[tag]?.label).filter(Boolean)
const triggerLabel = selectedTagLabels.length
? selectedTagLabels.join(', ')
: t(($) => $.allTags, { ns: 'pluginTags' })
useEffect(() => {
if (selectedTagsLength || !shouldRestoreFocusRef.current) return
shouldRestoreFocusRef.current = false
triggerRef.current?.focus()
}, [selectedTagsLength])
return (
<div
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-2 py-1 text-text-tertiary select-none',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<div className="p-0.5">
<RiFilter3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
</div>
<div className="flex items-center gap-x-1 p-1 system-sm-medium">
{!selectedTagsLength && <span>{t(($) => $.allTags, { ns: 'pluginTags' })}</span>}
{!!selectedTagsLength && (
<span className="text-text-secondary">
{tags
.map((tag) => tagsMap[tag]!.label)
.slice(0, 2)
.join(',')}
</span>
)}
{selectedTagsLength > 2 && (
<div className="system-xs-medium text-text-tertiary">+{selectedTagsLength - 2}</div>
)}
</div>
<div className="relative inline-flex h-8 shrink-0 items-center">
<PopoverTrigger
render={
<Button
ref={triggerRef}
variant="ghost"
size="medium"
aria-label={triggerLabel}
className={cn(
'h-8 justify-start px-2 py-1 text-text-tertiary focus-visible:ring-inset',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg pr-8 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<span className="p-0.5">
<span
aria-hidden
className={cn(
'i-ri-filter-3-line block size-4',
!!selectedTagsLength && 'text-text-secondary',
)}
/>
</span>
<span className="flex items-center gap-x-1 p-1 system-sm-medium">
{!selectedTagsLength && <span>{t(($) => $.allTags, { ns: 'pluginTags' })}</span>}
{!!selectedTagsLength && (
<span className="text-text-secondary">
{tags
.map((tag) => tagsMap[tag]?.label)
.filter(Boolean)
.slice(0, 2)
.join(',')}
</span>
)}
{selectedTagsLength > 2 && (
<span className="system-xs-medium text-text-tertiary">
+{selectedTagsLength - 2}
</span>
)}
</span>
{!selectedTagsLength && (
<span className="p-0.5">
<span
aria-hidden
className="i-ri-arrow-down-s-line block size-4 text-text-tertiary"
/>
</span>
)}
</Button>
}
/>
{!!selectedTagsLength && (
<RiCloseCircleFill
className="size-4 text-text-quaternary"
onClick={() => onTagsChange([])}
/>
)}
{!selectedTagsLength && (
<div className="p-0.5">
<RiArrowDownSLine className="size-4 text-text-tertiary" />
</div>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSelectedTags, {
ns: 'pluginTags',
tags: triggerLabel,
})}
className="absolute right-1 size-6 min-h-0 p-0 focus-visible:ring-inset"
onClick={() => {
shouldRestoreFocusRef.current = true
onTagsChange([])
}}
>
<span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" />
</Button>
)}
</div>
)
}
export default React.memo(MarketplaceTrigger)
export default memo(MarketplaceTrigger)
@@ -1,7 +1,9 @@
import type { Tag } from '../../../hooks'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
import * as React from 'react'
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
import { memo, useEffect, useRef } from 'react'
import { useTranslation } from '#i18n'
type ToolSelectorTriggerProps = {
selectedTagsLength: number
@@ -11,50 +13,92 @@ type ToolSelectorTriggerProps = {
onTagsChange: (tags: string[]) => void
}
const ToolSelectorTrigger = ({
function ToolSelectorTrigger({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
}: ToolSelectorTriggerProps) => {
}: ToolSelectorTriggerProps) {
const { t } = useTranslation()
const triggerRef = useRef<HTMLButtonElement>(null)
const shouldRestoreFocusRef = useRef(false)
const selectedTagLabels = tags.map((tag) => tagsMap[tag]?.label).filter(Boolean)
const triggerLabel = selectedTagLabels.length
? selectedTagLabels.join(', ')
: t(($) => $.allTags, { ns: 'pluginTags' })
useEffect(() => {
if (selectedTagsLength || !shouldRestoreFocusRef.current) return
shouldRestoreFocusRef.current = false
triggerRef.current?.focus()
}, [selectedTagsLength])
return (
<div
className={cn(
'flex h-7 cursor-pointer items-center rounded-md p-0.5 text-text-tertiary select-none',
!selectedTagsLength && 'py-1 pr-2 pl-1.5',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<div className="p-0.5">
<RiPriceTag3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
</div>
<div className="relative mr-1 inline-flex h-7 max-w-32 shrink-0 items-center">
<PopoverTrigger
render={
<Button
ref={triggerRef}
variant="ghost"
size="small"
aria-label={triggerLabel}
className={cn(
'h-7 max-w-32 justify-start text-text-tertiary focus-visible:ring-inset',
!selectedTagsLength && 'size-7 min-h-0 justify-center p-0',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-7 pl-1 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<span className={cn('shrink-0', !!selectedTagsLength && 'p-0.5')}>
<span
aria-hidden
className={cn(
'i-ri-price-tag-3-line block size-4',
!!selectedTagsLength && 'text-text-secondary',
)}
/>
</span>
{!!selectedTagsLength && (
<span className="flex min-w-0 items-center gap-x-0.5 px-0.5 py-1 system-sm-medium">
<span className="truncate text-text-secondary">
{tags
.map((tag) => tagsMap[tag]?.label)
.filter(Boolean)
.slice(0, 2)
.join(',')}
</span>
{selectedTagsLength > 2 && (
<span className="shrink-0 system-xs-medium text-text-tertiary">
+{selectedTagsLength - 2}
</span>
)}
</span>
)}
</Button>
}
/>
{!!selectedTagsLength && (
<div className="flex items-center gap-x-0.5 px-0.5 py-1 system-sm-medium">
<span className="text-text-secondary">
{tags
.map((tag) => tagsMap[tag]!.label)
.slice(0, 2)
.join(',')}
</span>
{selectedTagsLength > 2 && (
<div className="system-xs-medium text-text-tertiary">+{selectedTagsLength - 2}</div>
)}
</div>
)}
{!!selectedTagsLength && (
<RiCloseCircleFill
className="size-4 text-text-quaternary"
onClick={(e) => {
e.stopPropagation()
<Button
variant="ghost"
size="small"
aria-label={t(($) => $.clearSelectedTags, {
ns: 'pluginTags',
tags: triggerLabel,
})}
className="absolute right-0.5 size-6 min-h-0 p-0 focus-visible:ring-inset"
onClick={() => {
shouldRestoreFocusRef.current = true
onTagsChange([])
}}
/>
>
<span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" />
</Button>
)}
</div>
)
}
export default React.memo(ToolSelectorTrigger)
export default memo(ToolSelectorTrigger)
@@ -60,6 +60,16 @@ export const getPluginLinkInMarketplace = (
return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params)
}
export const getPluginDetailLinkInMarketplace = (
plugin: Pick<MarketplacePluginPayload, 'name' | 'org' | 'type'>,
) => {
const org = encodeURIComponent(plugin.org)
const name = encodeURIComponent(plugin.name)
if (plugin.type === 'bundle') return `/bundles/${org}/${name}`
return `/plugin/${org}/${name}`
}
export const getMarketplaceCategoryUrl = (
category?: string,
params?: Record<string, string | undefined>,
@@ -0,0 +1,18 @@
# Multiple Tool Selector
`index.tsx` is the public list composition for selecting and configuring multiple tools. It owns tool identity, list ordering, add and delete updates, enabled counts, and the optional collapsed state.
After a keyboard user deletes a tool, this module restores focus after the controlled `value` update. The target order is the next tool, the previous tool, then the add-tool button. `ToolSelector` only exposes the final default trigger through `triggerRef`; it does not infer sibling order.
Tools are identified by `provider_name` and `tool_name`, matching the module's deduplication contract. Callers must replace `value` after `onChange` so the list and pending focus target can settle together.
## Internal Modules
- `index.tsx`: List state composition, collapse control, and post-delete focus ownership.
- `__tests__/focus-restoration.spec.tsx`: Integration coverage through the real tool row and trigger chain.
## External Modules
- `app/components/plugins/plugin-detail-panel/tool-selector`: Single-tool trigger, Popover, and configuration form.
- `app/components/workflow/nodes/_base/components/mcp-tool-availability`: MCP availability policy.
- `service/use-tools`: Installed MCP tool data used by the enabled count.
@@ -0,0 +1,115 @@
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
import MultipleToolSelector from '../index'
const mockTools = vi.hoisted(() => ({
builtIn: [] as ToolWithProvider[],
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockTools.builtIn, isFetched: true }),
useAllCustomTools: () => ({ data: [], isFetched: true }),
useAllMCPTools: () => ({ data: [], isFetched: true }),
useAllWorkflowTools: () => ({ data: [], isFetched: true }),
useInvalidateAllBuiltInTools: () => vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useCheckInstalled: () => ({ data: undefined }),
useInvalidateInstalledPluginList: () => vi.fn(),
usePluginManifestInfo: () => ({ data: undefined }),
}))
const createToolValue = (index: number): ToolValue => ({
provider_name: `provider-${index}`,
tool_name: `tool-${index}`,
tool_label: `Tool ${index}`,
enabled: true,
})
const createToolProvider = (value: ToolValue): ToolWithProvider => ({
id: value.provider_name,
name: value.provider_name,
author: 'Dify',
description: { en_US: '', zh_Hans: '' },
icon: 'icon.svg',
label: { en_US: value.provider_name, zh_Hans: value.provider_name },
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: true,
allow_delete: false,
labels: [],
tools: [
{
name: value.tool_name,
author: 'Dify',
label: { en_US: value.tool_label, zh_Hans: value.tool_label },
description: { en_US: '', zh_Hans: '' },
parameters: [],
labels: [],
output_schema: {},
},
],
meta: {} as ToolWithProvider['meta'],
})
function renderSelector(initialValue: ToolValue[]) {
mockTools.builtIn = initialValue.map(createToolProvider)
function Harness() {
const [value, setValue] = useState(initialValue)
return (
<MCPToolAvailabilityProvider versionSupported>
<MultipleToolSelector
value={value}
label="Tools"
onChange={setValue}
nodeOutputVars={[]}
availableNodes={[]}
/>
</MCPToolAvailabilityProvider>
)
}
return render(<Harness />)
}
describe('MultipleToolSelector focus restoration', () => {
it.each([
{
name: 'the next tool after deleting a middle item',
tools: [createToolValue(1), createToolValue(2), createToolValue(3)],
deleteIndex: 1,
expectedFocusName: 'Tool 3',
},
{
name: 'the previous tool after deleting the last item',
tools: [createToolValue(1), createToolValue(2)],
deleteIndex: 1,
expectedFocusName: 'Tool 1',
},
{
name: 'the add button after deleting the only item',
tools: [createToolValue(1)],
deleteIndex: 0,
expectedFocusName: 'plugin.detailPanel.toolSelector.title',
},
])('moves focus to $name', async ({ tools, deleteIndex, expectedFocusName }) => {
const user = userEvent.setup()
renderSelector(tools)
const deleteButton = screen.getAllByRole('button', {
name: 'common.operation.delete',
})[deleteIndex]
deleteButton?.focus()
await user.keyboard('{Enter}')
expect(screen.getByRole('button', { name: expectedFocusName })).toHaveFocus()
})
})

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