Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe676f6797 |
@@ -14,12 +14,6 @@ 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,
|
||||
|
||||
@@ -4,16 +4,20 @@ from http import HTTPStatus
|
||||
from flask import redirect
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Conflict, Forbidden, NotFound
|
||||
from werkzeug.exceptions import Conflict, 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
|
||||
@@ -21,7 +25,6 @@ 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,
|
||||
@@ -95,13 +98,11 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _current_owner_or_admin_ids() -> tuple[str, str]:
|
||||
"""Return current Cloud workspace IDs for an owner or admin, independently of enterprise RBAC."""
|
||||
def _current_ids() -> tuple[str, str]:
|
||||
"""Return current `(tenant_id, account_id)` or raise when no workspace is selected."""
|
||||
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
|
||||
|
||||
|
||||
@@ -123,8 +124,12 @@ 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_owner_or_admin_ids()
|
||||
tenant_id, _ = _current_ids()
|
||||
return dump_response(WorkflowRunArchiveListResponse, list_workflow_run_archives(db.session(), tenant_id))
|
||||
|
||||
|
||||
@@ -144,8 +149,12 @@ 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_owner_or_admin_ids()
|
||||
tenant_id, account_id = _current_ids()
|
||||
payload = WorkflowRunArchiveDownloadPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
task = create_workflow_run_archive_download_task(
|
||||
@@ -171,8 +180,12 @@ 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_owner_or_admin_ids()
|
||||
tenant_id, _ = _current_ids()
|
||||
try:
|
||||
task = get_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
|
||||
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
|
||||
@@ -196,8 +209,12 @@ 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_owner_or_admin_ids()
|
||||
tenant_id, _ = _current_ids()
|
||||
try:
|
||||
task = get_ready_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
|
||||
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
|
||||
|
||||
@@ -131,10 +131,7 @@ class WaterCrawlAPIClient(BaseAPIClient):
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
media_type = content_type.split(";", 1)[0].strip().lower()
|
||||
if media_type == "application/json":
|
||||
try:
|
||||
return response.json() or {}
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid JSON response from WaterCrawl") from exc
|
||||
return response.json() or {}
|
||||
|
||||
if media_type == "application/octet-stream":
|
||||
return response.content
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
"""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 Any, override
|
||||
|
||||
from httpx import Response
|
||||
from typing import override
|
||||
|
||||
|
||||
class WaterCrawlError(Exception):
|
||||
@@ -17,16 +7,11 @@ class WaterCrawlError(Exception):
|
||||
|
||||
|
||||
class WaterCrawlBadRequestError(WaterCrawlError):
|
||||
def __init__(self, response: Response):
|
||||
def __init__(self, response):
|
||||
self.status_code = response.status_code
|
||||
self.response = response
|
||||
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"
|
||||
data = response.json()
|
||||
self.message = data.get("message", "Unknown error occurred")
|
||||
self.errors = data.get("errors", {})
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@@ -22940,7 +22940,6 @@ 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 |
|
||||
|
||||
@@ -1643,7 +1643,6 @@ 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,35 +0,0 @@
|
||||
"""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()
|
||||
@@ -9,13 +9,12 @@ 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 CreatorUserRole, MessageStatus
|
||||
from models.enums import MessageStatus
|
||||
from models.model import App, Conversation, Message
|
||||
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun, WorkflowType
|
||||
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -581,26 +580,9 @@ 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_webapp_message_scope_sql(source_filter)
|
||||
message_scope = self._statistics_message_scope_sql(source_filter)
|
||||
sql_query = f"""SELECT
|
||||
{converted_created_at} AS date,
|
||||
COUNT(m.id) AS message_count,
|
||||
@@ -620,9 +602,20 @@ 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 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:
|
||||
sql_query += " AND m.created_at >= :start"
|
||||
args["start"] = params.start
|
||||
@@ -634,160 +627,10 @@ WHERE
|
||||
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:
|
||||
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"
|
||||
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:
|
||||
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:
|
||||
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_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")
|
||||
@@ -797,12 +640,8 @@ WHERE
|
||||
workflow_binding_filters.append("wanb.workflow_version = :workflow_version")
|
||||
if source_filter.node_id:
|
||||
workflow_binding_filters.append("wanb.node_id = :node_id")
|
||||
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
|
||||
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
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM workflow_runs wr
|
||||
@@ -812,65 +651,17 @@ WHERE
|
||||
AND wanb.app_id = wr.app_id
|
||||
AND wanb.workflow_id = wr.workflow_id
|
||||
AND wanb.workflow_version = wr.version
|
||||
{binding_filters}
|
||||
{extra_workflow_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
|
||||
)"""
|
||||
|
||||
@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"]))
|
||||
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 _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
|
||||
@@ -317,6 +317,9 @@ _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",
|
||||
@@ -346,6 +349,9 @@ _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",
|
||||
@@ -371,6 +377,9 @@ _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] = [
|
||||
@@ -378,6 +387,9 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"credential.use",
|
||||
"app_library.access",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
"billing.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
@@ -793,6 +805,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -100,7 +100,6 @@ 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):
|
||||
@@ -287,7 +286,6 @@ 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
|
||||
|
||||
@@ -13,7 +13,6 @@ 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,
|
||||
@@ -85,6 +84,9 @@ 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:
|
||||
@@ -95,38 +97,6 @@ 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,
|
||||
@@ -205,68 +175,19 @@ def build_workflow_event_stream(
|
||||
return _generate()
|
||||
|
||||
|
||||
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``.
|
||||
"""
|
||||
def _get_message_context(session_maker: sessionmaker[Session], workflow_run_id: str) -> MessageContext | None:
|
||||
with session_maker() as session:
|
||||
stmt = (
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation_id,
|
||||
Message.workflow_run_id == workflow_run_id,
|
||||
)
|
||||
.order_by(desc(Message.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
stmt = select(Message).where(Message.workflow_run_id == workflow_run_id).order_by(desc(Message.created_at))
|
||||
message = session.scalar(stmt)
|
||||
if message is None:
|
||||
return None
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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:
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
"""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"] == []
|
||||
+8
-22
@@ -1,38 +1,22 @@
|
||||
"""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, api_token_db: Session) -> None:
|
||||
def test_should_return_api_token_and_cache_when_token_exists(
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
app_id = str(uuid4())
|
||||
token_value = f"app-test-{uuid4()}"
|
||||
@@ -43,8 +27,8 @@ class TestQueryTokenFromDb:
|
||||
api_token.tenant_id = tenant_id
|
||||
api_token.type = "app"
|
||||
api_token.token = token_value
|
||||
api_token_db.add(api_token)
|
||||
api_token_db.commit()
|
||||
db_session_with_containers.add(api_token)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with (
|
||||
patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set,
|
||||
@@ -57,7 +41,9 @@ 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, api_token_db: Session) -> None:
|
||||
def test_should_cache_null_and_raise_unauthorized_when_token_not_found(
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers
|
||||
):
|
||||
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,
|
||||
+44
-49
@@ -1,7 +1,6 @@
|
||||
"""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
|
||||
|
||||
@@ -20,9 +19,7 @@ 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,
|
||||
@@ -93,14 +90,8 @@ class TestDeliveryTestRegistry:
|
||||
with pytest.raises(DeliveryTestUnsupportedError, match="Delivery method does not support test send."):
|
||||
registry.dispatch(context=context, method=method)
|
||||
|
||||
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()
|
||||
|
||||
def test_default(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
registry = DeliveryTestRegistry.default()
|
||||
assert len(registry._handlers) == 1
|
||||
assert isinstance(registry._handlers[0], EmailDeliveryTestHandler)
|
||||
|
||||
@@ -121,24 +112,24 @@ class TestEmailDeliveryTestHandler:
|
||||
handler = EmailDeliveryTestHandler(session_factory=engine)
|
||||
assert handler._session_factory.kw["bind"] == engine
|
||||
|
||||
def test_supports(self, sqlite_engine: Engine) -> None:
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
def test_supports(self):
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
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, sqlite_engine: Engine) -> None:
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
def test_send_test_unsupported_method(self):
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
with pytest.raises(DeliveryTestUnsupportedError):
|
||||
handler.send_test(context=MagicMock(), method=MagicMock())
|
||||
|
||||
def test_send_test_feature_disabled(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None:
|
||||
def test_send_test_feature_disabled(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _tenant_id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=False),
|
||||
lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=False),
|
||||
)
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
context = DeliveryTestContext(
|
||||
tenant_id="t1", app_id="a1", node_id="n1", node_title="title", rendered_content="content"
|
||||
)
|
||||
@@ -147,15 +138,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, sqlite_engine: Engine) -> None:
|
||||
def test_send_test_mail_not_inited(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
|
||||
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(service_module.mail, "is_inited", lambda: False)
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
context = DeliveryTestContext(
|
||||
tenant_id="t1", app_id="a1", node_id="n1", node_title="title", rendered_content="content"
|
||||
)
|
||||
@@ -164,15 +155,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, sqlite_engine: Engine) -> None:
|
||||
def test_send_test_no_recipients(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
|
||||
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
handler._resolve_recipients = MagicMock(return_value=[])
|
||||
|
||||
context = DeliveryTestContext(
|
||||
@@ -183,18 +174,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, sqlite_engine: Engine) -> None:
|
||||
def test_send_test_success(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
|
||||
lambda _id, **_kwargs: SimpleNamespace(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=sqlite_engine)
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
|
||||
|
||||
variable_pool = VariablePool()
|
||||
@@ -219,11 +210,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, sqlite_engine: Engine) -> None:
|
||||
def test_send_test_sanitizes_subject(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
service_module.FeatureService,
|
||||
"get_features",
|
||||
lambda _id, **_kwargs: FeatureModel(human_input_email_delivery_enabled=True),
|
||||
lambda _id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True),
|
||||
)
|
||||
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
|
||||
mock_mail_send = MagicMock()
|
||||
@@ -234,7 +225,7 @@ class TestEmailDeliveryTestHandler:
|
||||
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
|
||||
)
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
|
||||
|
||||
context = DeliveryTestContext(
|
||||
@@ -258,8 +249,8 @@ class TestEmailDeliveryTestHandler:
|
||||
_, kwargs = mock_mail_send.call_args
|
||||
assert kwargs["subject"] == "Notice BCC:test@example.com"
|
||||
|
||||
def test_resolve_recipients_external(self, sqlite_engine: Engine) -> None:
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
def test_resolve_recipients_external(self):
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
method = EmailDeliveryMethod(
|
||||
config=EmailDeliveryConfig(
|
||||
recipients=EmailRecipients(
|
||||
@@ -271,18 +262,19 @@ class TestEmailDeliveryTestHandler:
|
||||
)
|
||||
assert handler._resolve_recipients(tenant_id="t1", method=method) == ["ext@example.com"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
|
||||
def test_resolve_recipients_member(self, sqlite_engine: Engine, sqlite_session: Session) -> None:
|
||||
def test_resolve_recipients_member(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
tenant_id = str(uuid4())
|
||||
account = Account(name="Test User", email="member@example.com")
|
||||
sqlite_session.add(account)
|
||||
sqlite_session.commit()
|
||||
db_session_with_containers.add(account)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
join = TenantAccountJoin(tenant_id=tenant_id, account_id=account.id)
|
||||
sqlite_session.add(join)
|
||||
sqlite_session.commit()
|
||||
db_session_with_containers.add(join)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
from extensions.ext_database import db
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=db.engine)
|
||||
method = EmailDeliveryMethod(
|
||||
config=EmailDeliveryConfig(
|
||||
recipients=EmailRecipients(items=[MemberRecipient(reference_id=account.id)], include_bound_group=False),
|
||||
@@ -292,20 +284,23 @@ class TestEmailDeliveryTestHandler:
|
||||
)
|
||||
assert handler._resolve_recipients(tenant_id=tenant_id, method=method) == ["member@example.com"]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
|
||||
def test_resolve_recipients_whole_workspace(self, sqlite_engine: Engine, sqlite_session: Session) -> None:
|
||||
def test_resolve_recipients_whole_workspace(
|
||||
self, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
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")
|
||||
sqlite_session.add_all([account1, account2])
|
||||
sqlite_session.commit()
|
||||
db_session_with_containers.add_all([account1, account2])
|
||||
db_session_with_containers.commit()
|
||||
|
||||
for acc in [account1, account2]:
|
||||
join = TenantAccountJoin(tenant_id=tenant_id, account_id=acc.id)
|
||||
sqlite_session.add(join)
|
||||
sqlite_session.commit()
|
||||
db_session_with_containers.add(join)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
from extensions.ext_database import db
|
||||
|
||||
handler = EmailDeliveryTestHandler(session_factory=db.engine)
|
||||
method = EmailDeliveryMethod(
|
||||
config=EmailDeliveryConfig(
|
||||
recipients=EmailRecipients(items=[], include_bound_group=True),
|
||||
@@ -316,8 +311,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, sqlite_engine: Engine) -> None:
|
||||
handler = EmailDeliveryTestHandler(session_factory=sqlite_engine)
|
||||
def test_query_workspace_member_emails_empty_ids(self):
|
||||
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
|
||||
assert handler._query_workspace_member_emails(tenant_id="t1", user_ids=[]) == {}
|
||||
|
||||
def test_build_substitutions(self):
|
||||
@@ -1,82 +1,23 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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 _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):
|
||||
def _app_model(*, bound_agent_id: str | None, app_model_config=None):
|
||||
return SimpleNamespace(
|
||||
id=_stable_uuid(f"app:{tenant_id}"),
|
||||
tenant_id=tenant_id,
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id=bound_agent_id,
|
||||
app_model_config_with_session=lambda *, session: app_model_config,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
def test_published_agent_app_parameters_use_soul_file_upload():
|
||||
app_model_config = SimpleNamespace(
|
||||
to_dict=lambda **_kwargs: {
|
||||
"opening_statement": "Hi from legacy presentation config",
|
||||
@@ -86,24 +27,14 @@ def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Ses
|
||||
},
|
||||
}
|
||||
)
|
||||
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,
|
||||
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",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
_persist_snapshot(
|
||||
sqlite_session,
|
||||
snapshot_id=snapshot_id,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
config_snapshot={
|
||||
snapshot = SimpleNamespace(
|
||||
config_snapshot_dict={
|
||||
"app_features": {
|
||||
"file_upload": {
|
||||
"enabled": True,
|
||||
@@ -115,12 +46,14 @@ def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Ses
|
||||
}
|
||||
},
|
||||
"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=sqlite_session,
|
||||
session=session,
|
||||
)
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
|
||||
@@ -136,30 +69,20 @@ def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Ses
|
||||
assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}]
|
||||
|
||||
|
||||
@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)
|
||||
def test_published_agent_app_parameters_requires_bound_agent():
|
||||
app_model = _app_model(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=sqlite_session)
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=MagicMock())
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
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
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -169,96 +92,68 @@ def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_se
|
||||
False,
|
||||
],
|
||||
)
|
||||
@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,
|
||||
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",
|
||||
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=sqlite_session)
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
|
||||
|
||||
|
||||
@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,
|
||||
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",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
_persist_snapshot(
|
||||
sqlite_session,
|
||||
snapshot_id=snapshot_id,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
config_snapshot={},
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict={})
|
||||
session = MagicMock()
|
||||
session.scalar.side_effect = [agent, snapshot]
|
||||
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
|
||||
app_model,
|
||||
session=sqlite_session,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert features_dict["file_upload"]["enabled"] is True
|
||||
assert user_input_form == []
|
||||
|
||||
|
||||
@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"),
|
||||
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",
|
||||
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=sqlite_session)
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session)
|
||||
|
||||
|
||||
@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,
|
||||
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",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
_persist_snapshot(
|
||||
sqlite_session,
|
||||
snapshot_id=snapshot_id,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
config_snapshot={},
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict={})
|
||||
session = MagicMock()
|
||||
session.scalar.side_effect = [agent, snapshot]
|
||||
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(
|
||||
app_model,
|
||||
session=sqlite_session,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert features_dict["file_upload"] == {
|
||||
|
||||
@@ -2,23 +2,15 @@
|
||||
|
||||
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):
|
||||
@@ -46,58 +38,17 @@ class _Result:
|
||||
|
||||
|
||||
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
|
||||
features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled))
|
||||
features = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=enabled))
|
||||
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
class TestAppImportApi:
|
||||
@@ -106,39 +57,33 @@ class TestAppImportApi:
|
||||
return app_import_module.AppImportApi()
|
||||
|
||||
def test_import_post_returns_failed_status_and_rolls_back(
|
||||
self,
|
||||
api,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module.AppDslService,
|
||||
"import_app",
|
||||
lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service, app_id=None),
|
||||
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, _make_account())
|
||||
response, status = method(api, SimpleNamespace(id="u1"))
|
||||
|
||||
assert transaction_events.rollbacks == 1
|
||||
assert transaction_events.commits == 0
|
||||
session.rollback.assert_called_once_with()
|
||||
session.commit.assert_not_called()
|
||||
assert status == 400
|
||||
assert response["status"] == ImportStatus.FAILED
|
||||
|
||||
def test_import_post_returns_pending_status_and_commits(
|
||||
self,
|
||||
api,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module.AppDslService,
|
||||
"import_app",
|
||||
@@ -146,23 +91,20 @@ class TestAppImportApi:
|
||||
)
|
||||
|
||||
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
|
||||
response, status = method(api, _make_account())
|
||||
response, status = method(api, SimpleNamespace(id="u1"))
|
||||
|
||||
assert transaction_events.commits == 1
|
||||
assert transaction_events.rollbacks == 0
|
||||
session.commit.assert_called_once_with()
|
||||
session.rollback.assert_not_called()
|
||||
assert status == 202
|
||||
assert response["status"] == ImportStatus.PENDING
|
||||
|
||||
def test_import_post_updates_webapp_auth_when_enabled(
|
||||
self,
|
||||
api,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=True)
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module.AppDslService,
|
||||
"import_app",
|
||||
@@ -172,28 +114,25 @@ 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, _make_account())
|
||||
response, status = method(api, SimpleNamespace(id="u1"))
|
||||
|
||||
assert transaction_events.commits == 1
|
||||
assert transaction_events.rollbacks == 0
|
||||
session.commit.assert_called_once_with()
|
||||
session.rollback.assert_not_called()
|
||||
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,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module,
|
||||
"current_account_with_tenant",
|
||||
lambda: (_make_account(), "tenant-1"),
|
||||
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
@@ -210,24 +149,21 @@ class TestAppImportApi:
|
||||
with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}):
|
||||
response, status = method()
|
||||
|
||||
assert transaction_events.commits == 1
|
||||
session.commit.assert_called_once_with()
|
||||
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,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_install_features(monkeypatch, enabled=False)
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module,
|
||||
"current_account_with_tenant",
|
||||
lambda: (_make_account(), "tenant-1"),
|
||||
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
@@ -248,7 +184,7 @@ class TestAppImportApi:
|
||||
):
|
||||
response, status = method()
|
||||
|
||||
assert transaction_events.commits == 1
|
||||
session.commit.assert_called_once_with()
|
||||
assert status == 200
|
||||
assert response["permission_keys"] == []
|
||||
|
||||
@@ -259,41 +195,35 @@ class TestAppImportConfirmApi:
|
||||
return app_import_module.AppImportConfirmApi()
|
||||
|
||||
def test_import_confirm_returns_failed_status_and_rolls_back(
|
||||
self,
|
||||
api,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = unwrap(api.post)
|
||||
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module.AppDslService,
|
||||
"confirm_import",
|
||||
lambda service, *_args, **_kwargs: _failed_result_after_starting_transaction(service),
|
||||
lambda *_args, **_kwargs: _Result(ImportStatus.FAILED),
|
||||
)
|
||||
|
||||
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
|
||||
response, status = method(api, _make_account(), import_id="import-1")
|
||||
response, status = method(api, SimpleNamespace(id="u1"), import_id="import-1")
|
||||
|
||||
assert transaction_events.rollbacks == 1
|
||||
assert transaction_events.commits == 0
|
||||
session.rollback.assert_called_once_with()
|
||||
session.commit.assert_not_called()
|
||||
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,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module,
|
||||
"current_account_with_tenant",
|
||||
lambda: (_make_account(), "tenant-1"),
|
||||
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_import_module.redis_client,
|
||||
@@ -318,23 +248,20 @@ class TestAppImportConfirmApi:
|
||||
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
|
||||
response, status = method(import_id="import-1")
|
||||
|
||||
assert transaction_events.commits == 1
|
||||
session.commit.assert_called_once_with()
|
||||
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,
|
||||
transaction_events: TransactionEvents,
|
||||
self, api, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
method = _unwrap(api.post)
|
||||
|
||||
session = _mock_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
app_import_module,
|
||||
"current_account_with_tenant",
|
||||
lambda: (_make_account(), "tenant-1"),
|
||||
lambda: (SimpleNamespace(id="u1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_import_module.redis_client,
|
||||
@@ -359,27 +286,6 @@ class TestAppImportConfirmApi:
|
||||
with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"):
|
||||
response, status = method(import_id="import-1")
|
||||
|
||||
assert transaction_events.commits == 1
|
||||
session.commit.assert_called_once_with()
|
||||
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,28 +7,10 @@ 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():
|
||||
@@ -84,11 +66,12 @@ def test_rule_code_generate_maps_token_error(app: Flask, monkeypatch: pytest.Mon
|
||||
method(api, "t1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_instruction_generate_app_not_found(app: Flask, sqlite_session: Session) -> None:
|
||||
def test_instruction_generate_app_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
_persist_app(sqlite_session, tenant_id="other-tenant")
|
||||
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@@ -100,21 +83,25 @@ def test_instruction_generate_app_not_found(app: Flask, sqlite_session: Session)
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(api, sqlite_session, "t1")
|
||||
response, status = method(api, session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "app app-1 not found"
|
||||
assert sqlite_session.get(App, "app-1") is not None
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_instruction_generate_workflow_not_found(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
def test_instruction_generate_workflow_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
app_model = _persist_app(sqlite_session)
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
|
||||
_install_workflow_service(monkeypatch, workflow=None)
|
||||
|
||||
with app.test_request_context(
|
||||
@@ -127,20 +114,18 @@ def test_instruction_generate_workflow_not_found(
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(api, sqlite_session, "t1")
|
||||
response, status = method(api, session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "workflow app-1 not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_instruction_generate_node_missing(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
def test_instruction_generate_node_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
app_model = _persist_app(sqlite_session)
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
|
||||
|
||||
workflow = SimpleNamespace(graph_dict={"nodes": []})
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
@@ -155,18 +140,18 @@ def test_instruction_generate_node_missing(
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(api, sqlite_session, "t1")
|
||||
response, status = method(api, session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "node node-1 not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
app_model = _persist_app(sqlite_session)
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model)
|
||||
|
||||
workflow = SimpleNamespace(
|
||||
graph_dict={
|
||||
@@ -188,19 +173,18 @@ def test_instruction_generate_code_node(app: Flask, monkeypatch: pytest.MonkeyPa
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method(api, sqlite_session, "t1")
|
||||
response = method(api, session, "t1")
|
||||
|
||||
assert response == {"code": "x"}
|
||||
assert workflow_service.app_model is app_model
|
||||
assert workflow_service.session is sqlite_session
|
||||
assert workflow_service.session is session
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_instruction_generate_legacy_modify(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
def test_instruction_generate_legacy_modify(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(
|
||||
generator_module.LLMGenerator,
|
||||
"instruction_modify_legacy",
|
||||
@@ -218,15 +202,16 @@ def test_instruction_generate_legacy_modify(
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method(api, sqlite_session, "t1")
|
||||
response = method(api, session, "t1")
|
||||
|
||||
assert response == {"instruction": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
|
||||
def test_instruction_generate_incompatible_params(app: Flask, sqlite_session: Session) -> None:
|
||||
def test_instruction_generate_incompatible_params(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
method="POST",
|
||||
@@ -238,7 +223,7 @@ def test_instruction_generate_incompatible_params(app: Flask, sqlite_session: Se
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(api, sqlite_session, "t1")
|
||||
response, status = method(api, session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "incompatible parameters"
|
||||
|
||||
@@ -838,29 +838,6 @@ 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,77 +1,11 @@
|
||||
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(
|
||||
@@ -94,4 +28,3 @@ 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,12 +1,9 @@
|
||||
import inspect
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
|
||||
from controllers.console.workspace.account import (
|
||||
AccountDeleteUpdateFeedbackApi,
|
||||
@@ -15,8 +12,7 @@ from controllers.console.workspace.account import (
|
||||
ChangeEmailSendEmailApi,
|
||||
CheckEmailUnique,
|
||||
)
|
||||
from models import Account, AccountIntegrate, AccountStatus, Tenant, TenantAccountJoin
|
||||
from models.account import TenantAccountRole
|
||||
from models import Account, AccountStatus, Tenant
|
||||
from services.account_service import AccountService
|
||||
from services.entities.auth_entities import (
|
||||
ChangeEmailNewEmailToken,
|
||||
@@ -49,38 +45,6 @@ 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,
|
||||
*,
|
||||
@@ -423,64 +387,47 @@ 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 _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",
|
||||
)
|
||||
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 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")
|
||||
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")
|
||||
|
||||
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.update_account_email")
|
||||
@@ -716,47 +663,36 @@ class TestAccountDeletionFeedback:
|
||||
|
||||
|
||||
class TestCheckEmailUnique:
|
||||
@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)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_should_normalize_email(
|
||||
self,
|
||||
mock_is_freeze: MagicMock,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
def test_should_normalize_email(self, mock_is_freeze: MagicMock, mock_check_unique: MagicMock, app: Flask):
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
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]
|
||||
|
||||
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=sqlite_session)
|
||||
result = AccountService.get_account_by_email_with_case_fallback("Mixed@Test.com", session=mock_session)
|
||||
|
||||
assert result is expected_account
|
||||
assert mock_session.execute.call_count == 2
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
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
|
||||
@@ -35,17 +32,15 @@ from controllers.console.workspace.error import (
|
||||
CurrentPasswordIncorrectError,
|
||||
InvalidAccountDeletionCodeError,
|
||||
)
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models import Account, AccountIntegrate, InvitationCode, Tenant, TenantAccountJoin
|
||||
from models.account import AccountStatus, InvitationCodeStatus, TenantAccountRole
|
||||
from models import Account
|
||||
from models.account import AccountStatus
|
||||
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 = str(uuid5(NAMESPACE_URL, f"account:{account_id}"))
|
||||
account.id = account_id
|
||||
account.avatar = "avatar.png"
|
||||
account.interface_language = "en-US"
|
||||
account.interface_theme = "light"
|
||||
@@ -54,62 +49,12 @@ 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:
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Account, Tenant, TenantAccountJoin, InvitationCode)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_init_success(self, app: Flask, sqlite_session: Session):
|
||||
def test_init_success(self, app: Flask):
|
||||
api = AccountInitApi()
|
||||
method = inspect.unwrap(api.post)
|
||||
|
||||
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()
|
||||
account = make_account(status=AccountStatus.UNINITIALIZED)
|
||||
payload = {
|
||||
"interface_language": "en-US",
|
||||
"timezone": "UTC",
|
||||
@@ -118,24 +63,14 @@ 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", sqlite_session),
|
||||
patch("controllers.console.workspace.account.db.session.scalar") as scalar_mock,
|
||||
):
|
||||
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()
|
||||
@@ -158,7 +93,7 @@ class TestAccountProfileApi:
|
||||
with app.test_request_context("/account/profile"):
|
||||
result = method(api, user)
|
||||
|
||||
assert result["id"] == user.id
|
||||
assert result["id"] == "u1"
|
||||
|
||||
|
||||
class TestAccountUpdateApis:
|
||||
@@ -184,33 +119,29 @@ class TestAccountUpdateApis:
|
||||
):
|
||||
result = method(api, user)
|
||||
|
||||
assert result["id"] == user.id
|
||||
assert result["id"] == "u1"
|
||||
|
||||
|
||||
class TestAccountAvatarApiGet:
|
||||
"""GET /account/avatar must not sign arbitrary upload_file IDs (IDOR)."""
|
||||
|
||||
@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):
|
||||
def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app: Flask):
|
||||
api = AccountAvatarApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
user, tenant = persist_account_with_tenant(sqlite_session, "acc-owner")
|
||||
tenant_id = tenant.id
|
||||
user = make_account("acc-owner")
|
||||
tenant_id = "tenant-1"
|
||||
file_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
upload_file = make_upload_file(tenant_id=tenant_id, created_by=user.id)
|
||||
upload_file = MagicMock()
|
||||
upload_file.id = file_id
|
||||
sqlite_session.add(upload_file)
|
||||
sqlite_session.commit()
|
||||
upload_file.tenant_id = tenant_id
|
||||
upload_file.created_by = user.id
|
||||
upload_file.created_by_role = CreatorUserRole.ACCOUNT
|
||||
|
||||
with (
|
||||
app.test_request_context(f"/account/avatar?avatar={file_id}"),
|
||||
patch("controllers.console.workspace.account.db.session", sqlite_session),
|
||||
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
|
||||
patch(
|
||||
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
|
||||
return_value="https://signed/example",
|
||||
@@ -221,35 +152,23 @@ class TestAccountAvatarApiGet:
|
||||
assert result == {"avatar_url": "https://signed/example"}
|
||||
sign_mock.assert_called_once_with(upload_file_id=file_id)
|
||||
|
||||
@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
|
||||
):
|
||||
def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(self, app: Flask):
|
||||
api = AccountAvatarApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
user, tenant = persist_account_with_tenant(sqlite_session, "acc-a")
|
||||
tenant_id = tenant.id
|
||||
user = make_account("acc-a")
|
||||
tenant_id = "tenant-1"
|
||||
file_id = "550e8400-e29b-41d4-a716-446655440001"
|
||||
|
||||
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 = MagicMock()
|
||||
upload_file.id = file_id
|
||||
sqlite_session.add_all([other_account, other_membership, upload_file])
|
||||
sqlite_session.commit()
|
||||
upload_file.tenant_id = tenant_id
|
||||
upload_file.created_by = "acc-b"
|
||||
upload_file.created_by_role = CreatorUserRole.ACCOUNT
|
||||
|
||||
with (
|
||||
app.test_request_context(f"/account/avatar?avatar={file_id}"),
|
||||
patch("controllers.console.workspace.account.db.session", sqlite_session),
|
||||
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
|
||||
patch(
|
||||
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
|
||||
return_value="https://signed/leak",
|
||||
@@ -260,29 +179,23 @@ class TestAccountAvatarApiGet:
|
||||
|
||||
sign_mock.assert_not_called()
|
||||
|
||||
@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):
|
||||
def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app: Flask):
|
||||
api = AccountAvatarApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
user, tenant = persist_account_with_tenant(sqlite_session, "acc-owner")
|
||||
tenant_id = tenant.id
|
||||
user = make_account("acc-owner")
|
||||
tenant_id = "tenant-1"
|
||||
file_id = "550e8400-e29b-41d4-a716-446655440002"
|
||||
|
||||
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 = MagicMock()
|
||||
upload_file.id = file_id
|
||||
sqlite_session.add_all([other_tenant, upload_file])
|
||||
sqlite_session.commit()
|
||||
upload_file.tenant_id = "tenant-other"
|
||||
upload_file.created_by = user.id
|
||||
upload_file.created_by_role = CreatorUserRole.ACCOUNT
|
||||
|
||||
with (
|
||||
app.test_request_context(f"/account/avatar?avatar={file_id}"),
|
||||
patch("controllers.console.workspace.account.db.session", sqlite_session),
|
||||
patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file),
|
||||
patch(
|
||||
"controllers.console.workspace.account.file_helpers.get_signed_file_url",
|
||||
return_value="https://signed/leak",
|
||||
@@ -333,7 +246,7 @@ class TestAccountPasswordApi:
|
||||
):
|
||||
result = method(api, user)
|
||||
|
||||
assert result["id"] == user.id
|
||||
assert result["id"] == "u1"
|
||||
|
||||
def test_password_wrong_current(self, app: Flask):
|
||||
api = AccountPasswordApi()
|
||||
@@ -358,38 +271,21 @@ class TestAccountPasswordApi:
|
||||
|
||||
|
||||
class TestAccountIntegrateApi:
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Account, Tenant, TenantAccountJoin, AccountIntegrate)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_get_integrates(self, app: Flask, sqlite_session: Session):
|
||||
def test_get_integrates(self, app: Flask):
|
||||
api = AccountIntegrateApi()
|
||||
method = inspect.unwrap(api.get)
|
||||
|
||||
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()
|
||||
account = make_account("acc1")
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch("controllers.console.workspace.account.db.session", sqlite_session),
|
||||
patch("controllers.console.workspace.account.db.session.scalars") as scalars_mock,
|
||||
):
|
||||
scalars_mock.return_value.all.return_value = []
|
||||
result = method(api, account)
|
||||
|
||||
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")
|
||||
assert "data" in result
|
||||
assert len(result["data"]) == 2
|
||||
|
||||
|
||||
class TestAccountDeleteApi:
|
||||
|
||||
@@ -1,52 +1,15 @@
|
||||
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 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
|
||||
from models.enums import MessageStatus
|
||||
|
||||
|
||||
class TestBasedGenerateTaskPipeline:
|
||||
@@ -95,33 +58,26 @@ class TestBasedGenerateTaskPipeline:
|
||||
assert "Knowledge retrieval failed" in str(err)
|
||||
assert "agent_run_id=run-1" in str(err)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
|
||||
def test_handle_error_updates_message_when_found(self, pipeline, sqlite_session: Session):
|
||||
def test_handle_error_updates_message_when_found(self, pipeline):
|
||||
event = QueueErrorEvent(error=ValueError("oops"))
|
||||
_persist_message(sqlite_session, message_id="msg-1")
|
||||
message = SimpleNamespace(status=MessageStatus.NORMAL, error=None)
|
||||
session = Mock()
|
||||
session.scalar.return_value = message
|
||||
|
||||
err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1")
|
||||
err = pipeline.handle_error(event=event, session=session, message_id="msg-1")
|
||||
|
||||
assert err is event.error
|
||||
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"
|
||||
assert message.status == MessageStatus.ERROR
|
||||
assert message.error == "oops"
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
|
||||
def test_handle_error_returns_err_when_message_missing(self, pipeline, sqlite_session: Session):
|
||||
def test_handle_error_returns_err_when_message_missing(self, pipeline):
|
||||
event = QueueErrorEvent(error=ValueError("oops"))
|
||||
_persist_message(sqlite_session, message_id="other-message")
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1")
|
||||
err = pipeline.handle_error(event=event, session=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,51 +1,11 @@
|
||||
"""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 = ""):
|
||||
@@ -434,11 +394,7 @@ 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,
|
||||
persisted_document: tuple[sessionmaker[Session], DocumentModel],
|
||||
):
|
||||
def test_update_last_edited_time_updates_document_and_commits(self, monkeypatch: pytest.MonkeyPatch):
|
||||
extractor = notion_extractor.NotionExtractor(
|
||||
notion_workspace_id="ws",
|
||||
notion_obj_id="obj",
|
||||
@@ -446,20 +402,40 @@ 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
|
||||
|
||||
extractor.update_last_edited_time(document)
|
||||
doc_model = SimpleNamespace(id="doc-1", data_source_info_dict={"source": "notion"})
|
||||
extractor.update_last_edited_time(doc_model)
|
||||
|
||||
# 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",
|
||||
}
|
||||
assert execute_calls
|
||||
assert fake_db.session.committed is True
|
||||
|
||||
def test_get_notion_last_edited_time_uses_page_and_database_urls(self, mocker: MockerFixture):
|
||||
extractor_page = notion_extractor.NotionExtractor(
|
||||
|
||||
@@ -176,22 +176,6 @@ 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
|
||||
@@ -201,14 +185,6 @@ 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,61 +1,27 @@
|
||||
"""Unit tests for ``ToolFileManager`` behavior.
|
||||
"""Unit tests for `ToolFileManager` behavior.
|
||||
|
||||
File metadata is persisted through real SQLite-backed sessions. Storage and
|
||||
remote HTTP remain mocked because they are external I/O boundaries.
|
||||
Covers signing, file persistence flows, and retrieval APIs with mocked
|
||||
storage/session boundaries (httpx, SimpleNamespace, Mock/patch) to avoid real
|
||||
IO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import UUID, uuid4
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
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, FileType
|
||||
from models.base import TypeBase
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import MessageFile
|
||||
from models.tools import ToolFile
|
||||
from graphon.file import FileTransferMethod
|
||||
|
||||
|
||||
@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 _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)
|
||||
|
||||
|
||||
def test_tool_file_manager_sign_file_builds_url() -> None:
|
||||
@@ -63,105 +29,120 @@ 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(sqlite_tool_file_session: Session) -> None:
|
||||
def test_create_file_by_raw_stores_file_and_persists_record() -> None:
|
||||
manager = ToolFileManager()
|
||||
user_id = str(uuid4())
|
||||
tenant_id = str(uuid4())
|
||||
conversation_id = str(uuid4())
|
||||
session = Mock()
|
||||
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-1")
|
||||
|
||||
def tool_file_factory(**kwargs):
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
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=UUID(int=0xABC)),
|
||||
patch("core.tools.tool_file_manager.uuid4", return_value=SimpleNamespace(hex="abc")),
|
||||
_patch_session_factory(session),
|
||||
):
|
||||
file_model = manager.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation_id,
|
||||
user_id="u1",
|
||||
tenant_id="t1",
|
||||
conversation_id="c1",
|
||||
file_binary=b"hello",
|
||||
mimetype="text/plain",
|
||||
filename="readme",
|
||||
)
|
||||
|
||||
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")
|
||||
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)
|
||||
|
||||
|
||||
def test_create_file_by_raw_prefers_filename_extension_over_mimetype(
|
||||
sqlite_tool_file_session: Session,
|
||||
) -> None:
|
||||
def test_create_file_by_raw_prefers_filename_extension_over_mimetype() -> None:
|
||||
manager = ToolFileManager()
|
||||
tenant_id = str(uuid4())
|
||||
session = Mock()
|
||||
session.refresh.side_effect = lambda model: setattr(model, "id", "tf-docx")
|
||||
|
||||
def tool_file_factory(**kwargs):
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
with (
|
||||
patch("core.tools.tool_file_manager.storage") as storage,
|
||||
patch("core.tools.tool_file_manager.uuid4", return_value=UUID(int=0xABC)),
|
||||
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),
|
||||
):
|
||||
file_model = manager.create_file_by_raw(
|
||||
user_id=str(uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=str(uuid4()),
|
||||
user_id="u1",
|
||||
tenant_id="t1",
|
||||
conversation_id="c1",
|
||||
file_binary=b"docx",
|
||||
mimetype="application/octet-stream",
|
||||
filename="report.docx",
|
||||
)
|
||||
|
||||
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")
|
||||
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)
|
||||
|
||||
|
||||
def test_create_file_by_url_downloads_and_persists_record(sqlite_tool_file_session: Session) -> None:
|
||||
def test_create_file_by_url_downloads_and_persists_record() -> 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.uuid4", return_value=UUID(int=0xDEF)),
|
||||
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.remote_fetcher.make_request", return_value=response),
|
||||
):
|
||||
file_model = manager.create_file_by_url(str(uuid4()), tenant_id, "https://example.com/f.bin", str(uuid4()))
|
||||
file_model = manager.create_file_by_url("u1", "t1", "https://example.com/f.bin", "c1")
|
||||
|
||||
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")
|
||||
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)
|
||||
|
||||
|
||||
def test_create_file_by_url_prefers_url_extension_over_mimetype(
|
||||
sqlite_tool_file_session: Session,
|
||||
) -> None:
|
||||
def test_create_file_by_url_prefers_url_extension_over_mimetype() -> 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.uuid4", return_value=UUID(int=0xABC)),
|
||||
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.remote_fetcher.make_request", return_value=response),
|
||||
):
|
||||
file_model = manager.create_file_by_url(
|
||||
str(uuid4()), tenant_id, "https://example.com/report.docx?download=1", str(uuid4())
|
||||
)
|
||||
file_model = manager.create_file_by_url("u1", "t1", "https://example.com/report.docx?download=1", "c1")
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
|
||||
def test_create_file_by_url_raises_on_timeout() -> None:
|
||||
@@ -175,72 +156,121 @@ 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(sqlite_tool_file_session: Session) -> None:
|
||||
assert ToolFileManager().get_file_binary(str(uuid4())) is None
|
||||
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_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()
|
||||
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
|
||||
|
||||
# Act
|
||||
with patch("core.tools.tool_file_manager.storage") as storage:
|
||||
storage.load_once.return_value = b"hello"
|
||||
result = ToolFileManager().get_file_binary(tool_file.id)
|
||||
with _patch_session_factory(session):
|
||||
result = manager.get_file_binary("id1")
|
||||
|
||||
# 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(
|
||||
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_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_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()
|
||||
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]
|
||||
|
||||
assert ToolFileManager().get_file_binary_by_message_file_id(message_file.id) is 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_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()
|
||||
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]
|
||||
|
||||
# Act
|
||||
with patch("core.tools.tool_file_manager.storage") as storage:
|
||||
storage.load_once.return_value = b"img"
|
||||
result = ToolFileManager().get_file_binary_by_message_file_id(message_file.id)
|
||||
with _patch_session_factory(session):
|
||||
result = manager.get_file_binary_by_message_file_id("mf-1")
|
||||
|
||||
# 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(sqlite_tool_file_session: Session) -> None:
|
||||
stream, tool_file = ToolFileManager().get_file_generator_by_tool_file_id(str(uuid4()))
|
||||
def test_get_file_generator_returns_none_when_toolfile_missing() -> None:
|
||||
# Arrange
|
||||
manager = ToolFileManager()
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
|
||||
# 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(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()
|
||||
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
|
||||
|
||||
# Act
|
||||
with patch("core.tools.tool_file_manager.storage") as storage:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, 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 CreatorUserRole, ToolFile, UploadFile
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
@@ -44,52 +52,39 @@ 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(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)
|
||||
@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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -127,7 +122,7 @@ def tool_file_mapping(file_type="document"):
|
||||
|
||||
|
||||
# Tests
|
||||
def test_build_from_mapping_backward_compatibility():
|
||||
def test_build_from_mapping_backward_compatibility(mock_upload_file):
|
||||
mapping = local_file_mapping(file_type="image")
|
||||
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
|
||||
assert isinstance(file, File)
|
||||
@@ -138,7 +133,7 @@ def test_build_from_mapping_backward_compatibility():
|
||||
assert file.storage_key == "test_key"
|
||||
|
||||
|
||||
def test_build_from_mapping_accepts_opaque_reference_for_local_file():
|
||||
def test_build_from_mapping_accepts_opaque_reference_for_local_file(mock_upload_file):
|
||||
mapping = {
|
||||
"transfer_method": "local_file",
|
||||
"reference": build_file_reference(record_id=TEST_UPLOAD_FILE_ID, storage_key="test_key"),
|
||||
@@ -153,7 +148,7 @@ def test_build_from_mapping_accepts_opaque_reference_for_local_file():
|
||||
assert resolve_file_record_id(file.reference) == TEST_UPLOAD_FILE_ID
|
||||
|
||||
|
||||
def test_build_from_mapping_accepts_opaque_related_id_for_tool_file():
|
||||
def test_build_from_mapping_accepts_opaque_related_id_for_tool_file(mock_tool_file):
|
||||
mapping = {
|
||||
"transfer_method": "tool_file",
|
||||
"related_id": build_file_reference(record_id=TEST_TOOL_FILE_ID, storage_key="tool_file.pdf"),
|
||||
@@ -170,11 +165,10 @@ def test_build_from_mapping_accepts_opaque_related_id_for_tool_file():
|
||||
assert file.storage_key == "tool_file.pdf"
|
||||
|
||||
|
||||
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()
|
||||
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"
|
||||
mapping = tool_file_mapping(file_type="document")
|
||||
|
||||
file = build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
|
||||
@@ -192,7 +186,7 @@ def test_build_from_mapping_prefers_tool_filename_extension_over_mimetype(file_r
|
||||
("document", False, "Detected file type does not match"),
|
||||
],
|
||||
)
|
||||
def test_build_from_local_file_strict_validation(file_type, should_pass, expected_error):
|
||||
def test_build_from_local_file_strict_validation(mock_upload_file, 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)
|
||||
@@ -209,7 +203,7 @@ def test_build_from_local_file_strict_validation(file_type, should_pass, expecte
|
||||
("image", False, "Detected file type does not match"),
|
||||
],
|
||||
)
|
||||
def test_build_from_tool_file_strict_validation(file_type, should_pass, expected_error):
|
||||
def test_build_from_tool_file_strict_validation(mock_tool_file, file_type, should_pass, expected_error):
|
||||
"""Strict type validation for tool_file."""
|
||||
mapping = tool_file_mapping(file_type=file_type)
|
||||
if should_pass:
|
||||
@@ -288,27 +282,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(file_records: FileRecords):
|
||||
def test_tool_file_not_found():
|
||||
"""Test ToolFile not found in database."""
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def test_local_file_not_found(file_records: FileRecords):
|
||||
def test_local_file_not_found():
|
||||
"""Test UploadFile not found in database."""
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
def test_build_without_type_specification():
|
||||
def test_build_without_type_specification(mock_upload_file):
|
||||
"""Test the situation where no file type is specified"""
|
||||
mapping = {
|
||||
"transfer_method": "local_file",
|
||||
@@ -327,7 +321,7 @@ def test_build_without_type_specification():
|
||||
("video", False, "File validation failed"),
|
||||
],
|
||||
)
|
||||
def test_file_validation_with_config(file_type, should_pass, expected_error):
|
||||
def test_file_validation_with_config(mock_upload_file, file_type, should_pass, expected_error):
|
||||
"""Test the validation of files and configurations"""
|
||||
mapping = local_file_mapping(file_type=file_type)
|
||||
if should_pass:
|
||||
@@ -360,65 +354,73 @@ def test_invalid_uuid_format():
|
||||
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
|
||||
|
||||
|
||||
def test_tenant_mismatch(file_records: FileRecords):
|
||||
def test_tenant_mismatch():
|
||||
"""Test that tenant mismatch raises security error."""
|
||||
file_records.upload_file.tenant_id = "different_tenant_id"
|
||||
file_records.session.commit()
|
||||
# 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"
|
||||
|
||||
mapping = local_file_mapping()
|
||||
with pytest.raises(ValueError, match="Invalid upload file"):
|
||||
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID)
|
||||
# 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)
|
||||
|
||||
|
||||
def test_build_from_mapping_scopes_upload_file_to_end_user():
|
||||
def test_build_from_mapping_scopes_upload_file_to_end_user(mock_upload_file):
|
||||
scope = FileAccessScope(
|
||||
tenant_id=TEST_TENANT_ID,
|
||||
user_id=TEST_END_USER_ID,
|
||||
user_id="end-user-id",
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
with bind_file_access_scope(scope):
|
||||
file = build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
|
||||
build_from_mapping(mapping=local_file_mapping(), tenant_id=TEST_TENANT_ID)
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
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=TEST_END_USER_ID,
|
||||
user_id="end-user-id",
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
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"):
|
||||
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):
|
||||
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():
|
||||
|
||||
def test_disallowed_file_types(mock_upload_file):
|
||||
"""Test that disallowed file types are rejected."""
|
||||
# Config that only allows image and document types
|
||||
restricted_config = FileUploadConfig(
|
||||
@@ -431,12 +433,12 @@ def test_disallowed_file_types():
|
||||
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config)
|
||||
|
||||
|
||||
def test_disallowed_extensions(file_records: FileRecords):
|
||||
def test_disallowed_extensions(mock_upload_file):
|
||||
"""Test that disallowed file extensions are rejected for custom type."""
|
||||
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()
|
||||
# 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"
|
||||
|
||||
# Config that only allows specific extensions for custom files
|
||||
restricted_config = FileUploadConfig(
|
||||
@@ -454,12 +456,11 @@ def test_disallowed_extensions(file_records: FileRecords):
|
||||
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config)
|
||||
|
||||
|
||||
def test_custom_file_type_uses_extension_validation_under_strict_mode(file_records: FileRecords):
|
||||
def test_custom_file_type_uses_extension_validation_under_strict_mode(mock_upload_file):
|
||||
"""Custom form uploads are classified by the configured extension list."""
|
||||
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()
|
||||
mock_upload_file.return_value.extension = "txt"
|
||||
mock_upload_file.return_value.name = "notes.txt"
|
||||
mock_upload_file.return_value.mime_type = "text/plain"
|
||||
|
||||
custom_config = FileUploadConfig(
|
||||
allowed_file_types=[FileType.CUSTOM],
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
|
||||
@@ -67,7 +66,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_webapp_message_scope_sql(source_filter)
|
||||
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
|
||||
|
||||
assert "m.app_id = :app_id" in scope_sql
|
||||
assert "m.invoke_from != :debugger" not in scope_sql
|
||||
@@ -76,7 +75,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_webapp_message_scope_sql(source_filter)
|
||||
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
|
||||
|
||||
assert "m.invoke_from = :source" in scope_sql
|
||||
|
||||
@@ -84,7 +83,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_workflow_binding_filters_sql(source_filter)
|
||||
scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter)
|
||||
|
||||
assert "wanb.app_id = :source_app_id" in scope_sql
|
||||
assert "wanb.workflow_id = :workflow_id" not in scope_sql
|
||||
@@ -92,144 +91,6 @@ 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 is None
|
||||
assert call.params == {"billing_enabled": svc.dify_config.BILLING_ENABLED}
|
||||
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
|
||||
|
||||
|
||||
+12
-22
@@ -1,6 +1,4 @@
|
||||
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
|
||||
@@ -12,8 +10,7 @@ def test_get_type() -> None:
|
||||
assert retrieval.get_type() == PipelineTemplateType.BUILTIN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_pipeline_templates(mocker: MockerFixture, sqlite_session: Session) -> None:
|
||||
def test_get_pipeline_templates(mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(
|
||||
BuiltInPipelineTemplateRetrieval,
|
||||
"_get_builtin_data",
|
||||
@@ -25,15 +22,14 @@ def test_get_pipeline_templates(mocker: MockerFixture, sqlite_session: Session)
|
||||
},
|
||||
)
|
||||
retrieval = BuiltInPipelineTemplateRetrieval()
|
||||
session = mocker.Mock()
|
||||
|
||||
templates = retrieval.get_pipeline_templates("en-US", session=sqlite_session)
|
||||
templates = retrieval.get_pipeline_templates("en-US", session=session)
|
||||
|
||||
assert templates == {"pipeline_templates": [{"id": "tpl-1"}]}
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_pipeline_template_detail(mocker: MockerFixture, sqlite_session: Session) -> None:
|
||||
def test_get_pipeline_template_detail(mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(
|
||||
BuiltInPipelineTemplateRetrieval,
|
||||
"_get_builtin_data",
|
||||
@@ -44,45 +40,39 @@ def test_get_pipeline_template_detail(mocker: MockerFixture, sqlite_session: Ses
|
||||
},
|
||||
)
|
||||
retrieval = BuiltInPipelineTemplateRetrieval()
|
||||
session = mocker.Mock()
|
||||
|
||||
detail = retrieval.get_pipeline_template_detail("tpl-1", session=sqlite_session)
|
||||
detail = retrieval.get_pipeline_template_detail("tpl-1", session=session)
|
||||
|
||||
assert detail == {"id": "tpl-1", "name": "Template 1"}
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_pipeline_templates_missing_language_returns_empty_dict(
|
||||
mocker: MockerFixture, sqlite_session: Session
|
||||
) -> None:
|
||||
def test_get_pipeline_templates_missing_language_returns_empty_dict(mocker: MockerFixture) -> 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=sqlite_session)
|
||||
result = retrieval.get_pipeline_templates("fr-FR", session=session)
|
||||
|
||||
assert result == {}
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_pipeline_template_detail_returns_none_for_unknown_id(
|
||||
mocker: MockerFixture, sqlite_session: Session
|
||||
) -> None:
|
||||
def test_get_pipeline_template_detail_returns_none_for_unknown_id(mocker: MockerFixture) -> 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=sqlite_session)
|
||||
result = retrieval.get_pipeline_template_detail("nonexistent-id", session=session)
|
||||
|
||||
assert result is None
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
def test_get_builtin_data_reads_from_file_and_caches(mocker: MockerFixture) -> None:
|
||||
|
||||
+29
-44
@@ -1,54 +1,35 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
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 _template(
|
||||
*,
|
||||
template_id: str = TEMPLATE_ID,
|
||||
language: str = "en-US",
|
||||
name: str = "Template 1",
|
||||
) -> PipelineBuiltInTemplate:
|
||||
template = PipelineBuiltInTemplate(
|
||||
name=name,
|
||||
def test_get_pipeline_templates(mocker: MockerFixture) -> None:
|
||||
built_in_template = SimpleNamespace(
|
||||
id="tpl-1",
|
||||
name="Template 1",
|
||||
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,
|
||||
)
|
||||
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()
|
||||
scalars_mock = mocker.Mock()
|
||||
scalars_mock.all.return_value = [built_in_template]
|
||||
session_mock = mocker.Mock()
|
||||
session_mock.scalars.return_value = scalars_mock
|
||||
retrieval = DatabasePipelineTemplateRetrieval()
|
||||
|
||||
result = retrieval.get_pipeline_templates("en-US", session=sqlite_session)
|
||||
result = retrieval.get_pipeline_templates("en-US", session=session_mock)
|
||||
|
||||
assert retrieval.get_type() == PipelineTemplateType.DATABASE
|
||||
assert result == {
|
||||
"pipeline_templates": [
|
||||
{
|
||||
"id": TEMPLATE_ID,
|
||||
"id": "tpl-1",
|
||||
"name": "Template 1",
|
||||
"description": "desc",
|
||||
"icon": {"background": "#fff"},
|
||||
@@ -59,19 +40,24 @@ def test_get_pipeline_templates(sqlite_session: Session) -> None:
|
||||
}
|
||||
]
|
||||
}
|
||||
assert sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@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()
|
||||
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: []",
|
||||
)
|
||||
retrieval = DatabasePipelineTemplateRetrieval()
|
||||
|
||||
detail = retrieval.get_pipeline_template_detail(TEMPLATE_ID, session=sqlite_session)
|
||||
detail = retrieval.get_pipeline_template_detail("tpl-1", session=session_mock)
|
||||
|
||||
assert detail == {
|
||||
"id": TEMPLATE_ID,
|
||||
"id": "tpl-1",
|
||||
"name": "Template 1",
|
||||
"icon_info": {"background": "#fff"},
|
||||
"description": "desc",
|
||||
@@ -79,14 +65,13 @@ def test_get_pipeline_template_detail_returns_detail(sqlite_session: Session) ->
|
||||
"export_data": "workflow:\n graph:\n nodes: []",
|
||||
"graph": {"nodes": []},
|
||||
}
|
||||
assert sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(PipelineBuiltInTemplate,)], indirect=True)
|
||||
def test_get_pipeline_template_detail_returns_none_when_not_found(sqlite_session: Session) -> None:
|
||||
def test_get_pipeline_template_detail_returns_none_when_not_found(mocker: MockerFixture) -> None:
|
||||
session_mock = mocker.Mock()
|
||||
session_mock.get.return_value = None
|
||||
retrieval = DatabasePipelineTemplateRetrieval()
|
||||
|
||||
result = retrieval.get_pipeline_template_detail(TEMPLATE_ID, session=sqlite_session)
|
||||
result = retrieval.get_pipeline_template_detail("missing", session=session_mock)
|
||||
|
||||
assert result is None
|
||||
assert sqlite_session.in_transaction()
|
||||
|
||||
@@ -692,41 +692,30 @@ 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",
|
||||
]
|
||||
joins = [
|
||||
TenantAccountJoin(
|
||||
tenant_id=tenant_id,
|
||||
for index, account_id in enumerate(account_ids, start=1):
|
||||
membership = TenantAccountJoin(
|
||||
tenant_id="00000000-0000-0000-0000-000000000001",
|
||||
account_id=account_id,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
current=False,
|
||||
)
|
||||
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)
|
||||
membership.id = f"00000000-0000-0000-0000-{index:012d}"
|
||||
sqlite_session.add(membership)
|
||||
sqlite_session.commit()
|
||||
|
||||
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)
|
||||
batches = list(
|
||||
TenantService.iter_member_account_id_batches(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
2,
|
||||
session=sqlite_session,
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
@@ -1227,8 +1216,7 @@ class TestTenantService:
|
||||
mock_tenant, mock_operator, mock_member, "remove", session=sqlite_session
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_rbac_workspace_owner_account_id(self, sqlite_session: Session):
|
||||
def test_get_rbac_workspace_owner_account_id(self):
|
||||
mock_roles = Paginated[MembersInRole](data=[MembersInRole(account_id="owner-account")])
|
||||
mock_rbac_roles = MagicMock()
|
||||
mock_rbac_roles.members.return_value = mock_roles
|
||||
@@ -1241,11 +1229,10 @@ 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=sqlite_session
|
||||
"tenant-1", "acct-1", session=MagicMock()
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
@@ -3,156 +3,204 @@ 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. Database paths use isolated
|
||||
in-memory SQLite sessions with persisted ORM rows.
|
||||
and both positive and negative test scenarios.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, Mock, create_autospec, 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.enums import AppStatus, ConversationFromSource, ConversationStatus
|
||||
from models.model import App, AppMode, Conversation
|
||||
from models.model import App, Conversation, EndUser, Message
|
||||
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 ORM objects.
|
||||
Factory for creating test data and mock objects.
|
||||
|
||||
Provides reusable methods to create consistent model objects for testing
|
||||
Provides reusable methods to create consistent mock objects for testing
|
||||
conversation-related operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_account(account_id: str = ACCOUNT_ID, **kwargs) -> Account:
|
||||
def create_account_mock(account_id: str = "account-123", **kwargs) -> Mock:
|
||||
"""
|
||||
Create an Account object.
|
||||
Create a mock Account object.
|
||||
|
||||
Args:
|
||||
account_id: Unique identifier for the account
|
||||
**kwargs: Additional attributes to set on the model
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Account object with specified attributes
|
||||
Mock Account object with specified attributes
|
||||
"""
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account = create_autospec(Account, instance=True)
|
||||
account.id = account_id
|
||||
for key, value in kwargs.items():
|
||||
setattr(account, key, value)
|
||||
return account
|
||||
|
||||
@staticmethod
|
||||
def create_app(app_id: str = APP_ID, tenant_id: str = TENANT_ID, **kwargs) -> App:
|
||||
def create_end_user_mock(user_id: str = "user-123", **kwargs) -> Mock:
|
||||
"""
|
||||
Create an App object.
|
||||
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.
|
||||
|
||||
Args:
|
||||
app_id: Unique identifier for the app
|
||||
tenant_id: Tenant/workspace identifier
|
||||
**kwargs: Additional attributes to set on the model
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
App object with specified attributes
|
||||
Mock App object with specified attributes
|
||||
"""
|
||||
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,
|
||||
)
|
||||
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")
|
||||
for key, value in kwargs.items():
|
||||
setattr(app, key, value)
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def create_conversation(
|
||||
conversation_id: str = CONVERSATION_ID,
|
||||
app_id: str = APP_ID,
|
||||
from_source: ConversationFromSource = ConversationFromSource.CONSOLE,
|
||||
def create_conversation_mock(
|
||||
conversation_id: str = "conv-123",
|
||||
app_id: str = "app-123",
|
||||
from_source: str = "console",
|
||||
**kwargs,
|
||||
) -> Conversation:
|
||||
) -> Mock:
|
||||
"""
|
||||
Create a Conversation object.
|
||||
Create a mock 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 model
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Conversation object with specified attributes
|
||||
Mock Conversation object with specified attributes
|
||||
"""
|
||||
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 = {}
|
||||
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())
|
||||
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, sqlite_session: Session):
|
||||
def test_pagination_with_empty_include_ids(self):
|
||||
"""
|
||||
Test that empty include_ids returns empty result.
|
||||
|
||||
@@ -160,14 +208,15 @@ class TestConversationServicePagination:
|
||||
and return empty results without querying the database.
|
||||
"""
|
||||
# Arrange - Set up test data
|
||||
app_model = ConversationServiceTestDataFactory.create_app()
|
||||
user = ConversationServiceTestDataFactory.create_account()
|
||||
mock_session = MagicMock() # Mock database session
|
||||
mock_app_model = ConversationServiceTestDataFactory.create_app_mock()
|
||||
mock_user = ConversationServiceTestDataFactory.create_account_mock()
|
||||
|
||||
# Act - Call the service method with empty include_ids
|
||||
result = ConversationService.pagination_by_last_id(
|
||||
session=sqlite_session,
|
||||
app_model=app_model,
|
||||
user=user,
|
||||
session=mock_session,
|
||||
app_model=mock_app_model,
|
||||
user=mock_user,
|
||||
last_id=None,
|
||||
limit=20,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
@@ -179,21 +228,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, sqlite_session: Session):
|
||||
def test_pagination_returns_empty_when_user_is_none(self):
|
||||
"""
|
||||
Test that pagination returns empty result when user is None.
|
||||
|
||||
This ensures proper handling of unauthenticated requests.
|
||||
"""
|
||||
# Arrange
|
||||
app_model = ConversationServiceTestDataFactory.create_app()
|
||||
mock_session = MagicMock()
|
||||
mock_app_model = ConversationServiceTestDataFactory.create_app_mock()
|
||||
|
||||
# Act
|
||||
result = ConversationService.pagination_by_last_id(
|
||||
session=sqlite_session,
|
||||
app_model=app_model,
|
||||
session=mock_session,
|
||||
app_model=mock_app_model,
|
||||
user=None, # No user provided
|
||||
last_id=None,
|
||||
limit=20,
|
||||
@@ -204,7 +253,6 @@ class TestConversationServicePagination:
|
||||
assert result.data == []
|
||||
assert result.has_more is False
|
||||
assert result.limit == 20
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
class TestConversationServiceHelpers:
|
||||
@@ -243,14 +291,14 @@ class TestConversationServiceHelpers:
|
||||
Should create a less-than filter condition.
|
||||
"""
|
||||
# Arrange
|
||||
conversation = ConversationServiceTestDataFactory.create_conversation()
|
||||
conversation.updated_at = naive_utc_now()
|
||||
mock_conversation = ConversationServiceTestDataFactory.create_conversation_mock()
|
||||
mock_conversation.updated_at = naive_utc_now()
|
||||
|
||||
# Act
|
||||
condition = ConversationService._build_filter_condition(
|
||||
sort_field="updated_at",
|
||||
sort_direction=desc,
|
||||
reference_conversation=conversation,
|
||||
reference_conversation=mock_conversation,
|
||||
)
|
||||
|
||||
# Assert
|
||||
@@ -264,14 +312,14 @@ class TestConversationServiceHelpers:
|
||||
Should create a greater-than filter condition.
|
||||
"""
|
||||
# Arrange
|
||||
conversation = ConversationServiceTestDataFactory.create_conversation()
|
||||
conversation.created_at = naive_utc_now()
|
||||
mock_conversation = ConversationServiceTestDataFactory.create_conversation_mock()
|
||||
mock_conversation.created_at = naive_utc_now()
|
||||
|
||||
# Act
|
||||
condition = ConversationService._build_filter_condition(
|
||||
sort_field="created_at",
|
||||
sort_direction=asc,
|
||||
reference_conversation=conversation,
|
||||
reference_conversation=mock_conversation,
|
||||
)
|
||||
|
||||
# Assert
|
||||
@@ -282,71 +330,36 @@ class TestConversationServiceHelpers:
|
||||
class TestConversationServiceConversationalVariable:
|
||||
"""Test conversational variable operations."""
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Conversation, ConversationVariable)], indirect=True)
|
||||
@patch("services.conversation_service.ConversationService.get_conversation")
|
||||
@patch("services.conversation_service.dify_config")
|
||||
def test_get_conversational_variable_with_name_filter_mysql(
|
||||
self,
|
||||
mock_config,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
def test_get_conversational_variable_with_name_filter_mysql(self, mock_config, mock_get_conversation):
|
||||
"""
|
||||
Test variable filtering by name for MySQL databases.
|
||||
|
||||
Should apply JSON extraction filter for variable names.
|
||||
"""
|
||||
# Arrange
|
||||
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()
|
||||
app_model = ConversationServiceTestDataFactory.create_app_mock()
|
||||
user = ConversationServiceTestDataFactory.create_account_mock()
|
||||
conversation = ConversationServiceTestDataFactory.create_conversation_mock()
|
||||
|
||||
mock_get_conversation.return_value = conversation
|
||||
mock_config.DB_TYPE = "mysql"
|
||||
|
||||
# Mock session
|
||||
mock_session = MagicMock()
|
||||
mock_session.scalars.return_value.all.return_value = []
|
||||
|
||||
# Act
|
||||
result = ConversationService.get_conversational_variable(
|
||||
ConversationService.get_conversational_variable(
|
||||
app_model=app_model,
|
||||
conversation_id=CONVERSATION_ID,
|
||||
conversation_id="conv-123",
|
||||
user=user,
|
||||
limit=10,
|
||||
last_id=None,
|
||||
variable_name="test_var",
|
||||
session=sqlite_session,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
# 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"
|
||||
# Assert - JSON filter should be applied
|
||||
assert mock_session.scalars.called
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -5,46 +5,11 @@ 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]:
|
||||
@@ -54,7 +19,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], db_session: Session
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
# Arrange
|
||||
collaboration_service, repository, socketio = service
|
||||
@@ -71,7 +36,7 @@ class TestWorkflowCollaborationService:
|
||||
patch.object(collaboration_service, "broadcast_online_users"),
|
||||
):
|
||||
# Act
|
||||
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session)
|
||||
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
|
||||
|
||||
# Assert
|
||||
assert result == ("u-1", True)
|
||||
@@ -84,25 +49,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], db_session: Session
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> 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=db_session)
|
||||
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
def test_authorize_and_join_workflow_room_returns_none_when_missing_tenant(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> 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=db_session)
|
||||
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
|
||||
|
||||
assert result is None
|
||||
repository.set_session_info.assert_not_called()
|
||||
@@ -110,7 +75,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], db_session: Session
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
collaboration_service, repository, socketio = service
|
||||
socketio.get_session.return_value = {
|
||||
@@ -121,7 +86,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=db_session)
|
||||
result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=Mock())
|
||||
|
||||
assert result is None
|
||||
repository.set_session_info.assert_not_called()
|
||||
@@ -145,23 +110,15 @@ 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], db_session: Session
|
||||
) -> None:
|
||||
def test_can_access_workflow_uses_session(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None:
|
||||
collaboration_service, _repository, _socketio = service
|
||||
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()
|
||||
session = Mock()
|
||||
session.scalar.return_value = "wf-1"
|
||||
|
||||
result = collaboration_service._can_access_workflow("wf-1", "tenant-1", session=db_session)
|
||||
result = collaboration_service._can_access_workflow("wf-1", "tenant-1", session=session)
|
||||
|
||||
assert result is True
|
||||
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
|
||||
session.scalar.assert_called_once()
|
||||
|
||||
def test_relay_collaboration_event_unauthorized(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -34,11 +35,17 @@ from services.workflow_draft_variable_service import (
|
||||
_model_to_insertion_dict,
|
||||
)
|
||||
|
||||
SQLITE_MODELS = (Workflow, WorkflowDraftVariable, WorkflowDraftVariableFile, WorkflowNodeExecutionModel)
|
||||
pytestmark = [
|
||||
pytest.mark.usefixtures("sqlite_session"),
|
||||
pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True),
|
||||
]
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class TestDraftVariableSaver:
|
||||
@@ -46,12 +53,13 @@ class TestDraftVariableSaver:
|
||||
suffix = secrets.token_hex(6)
|
||||
return f"test_app_id_{suffix}"
|
||||
|
||||
def test__should_variable_be_visible(self, sqlite_session: Session):
|
||||
def test__should_variable_be_visible(self):
|
||||
mock_session = MagicMock(spec=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=sqlite_session,
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id=test_app_id,
|
||||
node_id="test_node_id",
|
||||
@@ -62,7 +70,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, sqlite_session: Session):
|
||||
def test__normalize_variable_for_start_node(self):
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class TestCase:
|
||||
name: str
|
||||
@@ -110,10 +118,11 @@ class TestDraftVariableSaver:
|
||||
),
|
||||
]
|
||||
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_user = MagicMock()
|
||||
test_app_id = self._get_test_app_id()
|
||||
saver = DraftVariableSaver(
|
||||
session=sqlite_session,
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id=test_app_id,
|
||||
node_id=_NODE_ID,
|
||||
@@ -127,11 +136,12 @@ 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, sqlite_session: Session):
|
||||
def test_build_variables_from_start_mapping_rebuilds_system_files(self):
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_user = MagicMock(spec=Account)
|
||||
mock_user.id = str(uuid.uuid4())
|
||||
saver = DraftVariableSaver(
|
||||
session=sqlite_session,
|
||||
session=mock_session,
|
||||
tenant_id="tenant-1",
|
||||
app_id=self._get_test_app_id(),
|
||||
node_id="start",
|
||||
@@ -166,7 +176,17 @@ class TestDraftVariableSaver:
|
||||
rebuild_file.assert_called_once_with(file_mapping=raw_file, tenant_id="tenant-1")
|
||||
|
||||
@pytest.fixture
|
||||
def draft_saver(self, sqlite_session: Session):
|
||||
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):
|
||||
"""Create DraftVariableSaver instance with user context."""
|
||||
# Create a mock user
|
||||
mock_user = MagicMock(spec=Account)
|
||||
@@ -174,7 +194,7 @@ class TestDraftVariableSaver:
|
||||
mock_user.tenant_id = "test-tenant-id"
|
||||
|
||||
return DraftVariableSaver(
|
||||
session=sqlite_session,
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="test-node-id",
|
||||
@@ -183,7 +203,7 @@ class TestDraftVariableSaver:
|
||||
user=mock_user,
|
||||
)
|
||||
|
||||
def test_draft_saver_with_small_variables(self, draft_saver: DraftVariableSaver):
|
||||
def test_draft_saver_with_small_variables(self, draft_saver: DraftVariableSaver, mock_session):
|
||||
with patch(
|
||||
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
|
||||
) as _mock_try_offload:
|
||||
@@ -195,7 +215,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):
|
||||
def test_draft_saver_with_large_variables(self, draft_saver: DraftVariableSaver, mock_session):
|
||||
with patch(
|
||||
"services.workflow_draft_variable_service.DraftVariableSaver._try_offload_large_variable", autospec=True
|
||||
) as _mock_try_offload:
|
||||
@@ -217,12 +237,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, sqlite_session: Session):
|
||||
def test_try_offload_large_variable_uses_resource_tenant(self, mock_session):
|
||||
mock_user = MagicMock(spec=Account)
|
||||
mock_user.id = "test-user-id"
|
||||
mock_user.current_tenant_id = ""
|
||||
saver = DraftVariableSaver(
|
||||
session=sqlite_session,
|
||||
session=mock_session,
|
||||
tenant_id="app-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="test-node-id",
|
||||
@@ -264,16 +284,15 @@ 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, sqlite_session: Session
|
||||
):
|
||||
def test_start_node_save_persists_sys_timestamp_and_workflow_run_id(self, mock_batch_upsert):
|
||||
"""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=sqlite_session,
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="start-node-id",
|
||||
@@ -302,13 +321,14 @@ 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, sqlite_session: Session):
|
||||
def test_start_node_save_normalizes_reserved_prefix_outputs(self, mock_batch_upsert):
|
||||
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=sqlite_session,
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="start-node-id",
|
||||
@@ -362,8 +382,8 @@ class TestWorkflowDraftVariableService:
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
|
||||
def test_list_variables_without_values_excludes_node_ids(self, sqlite_session: Session):
|
||||
service = WorkflowDraftVariableService(sqlite_session)
|
||||
def test_list_variables_without_values_excludes_node_ids(self, mock_session):
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
variable = WorkflowDraftVariable.new_node_variable(
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
@@ -371,21 +391,8 @@ class TestWorkflowDraftVariableService:
|
||||
value=StringSegment(value="value"),
|
||||
node_execution_id="execution-1",
|
||||
)
|
||||
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()
|
||||
mock_session.scalar.return_value = 1
|
||||
mock_session.scalars.return_value = [variable]
|
||||
|
||||
result = service.list_variables_without_values(
|
||||
app_id="app-1",
|
||||
@@ -396,11 +403,16 @@ class TestWorkflowDraftVariableService:
|
||||
)
|
||||
|
||||
assert result.total == 1
|
||||
assert [item.id for item in result.variables] == [variable.id]
|
||||
assert result.variables == [variable]
|
||||
|
||||
def test_reset_conversation_variable(self, sqlite_session: Session):
|
||||
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):
|
||||
"""Test resetting a conversation variable"""
|
||||
service = WorkflowDraftVariableService(sqlite_session)
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
|
||||
test_app_id = self._get_test_app_id()
|
||||
workflow = self._create_test_workflow(test_app_id)
|
||||
@@ -423,9 +435,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, sqlite_session: Session):
|
||||
def test_reset_node_variable_with_no_execution_id(self, mock_session):
|
||||
"""Test resetting a node variable with no execution ID - should delete variable"""
|
||||
service = WorkflowDraftVariableService(sqlite_session)
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
|
||||
test_app_id = self._get_test_app_id()
|
||||
workflow = self._create_test_workflow(test_app_id)
|
||||
@@ -441,18 +453,33 @@ 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
|
||||
assert sqlite_session.get(WorkflowDraftVariable, variable.id) is None
|
||||
mock_session.delete.assert_called_once_with(instance=variable)
|
||||
mock_session.flush.assert_called_once()
|
||||
assert result is None
|
||||
|
||||
def test_reset_node_variable_with_missing_execution_record(self, sqlite_session: Session):
|
||||
def test_reset_node_variable_with_missing_execution_record(
|
||||
self,
|
||||
mock_engine,
|
||||
mock_session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Test resetting a node variable when execution record doesn't exist"""
|
||||
service = WorkflowDraftVariableService(sqlite_session)
|
||||
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
|
||||
|
||||
test_app_id = self._get_test_app_id()
|
||||
workflow = self._create_test_workflow(test_app_id)
|
||||
@@ -462,17 +489,32 @@ 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"
|
||||
)
|
||||
sqlite_session.add(variable)
|
||||
sqlite_session.commit()
|
||||
# Variable is editable by default from factory method
|
||||
|
||||
result = service._reset_node_var_or_sys_var(workflow, variable)
|
||||
|
||||
assert sqlite_session.get(WorkflowDraftVariable, variable.id) is None
|
||||
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 result is None
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
# Create mock execution record
|
||||
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
|
||||
@@ -490,13 +532,11 @@ 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"
|
||||
)
|
||||
sqlite_session.add(variable)
|
||||
sqlite_session.commit()
|
||||
# Variable is editable by default from factory method
|
||||
|
||||
# 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),
|
||||
):
|
||||
@@ -504,13 +544,15 @@ class TestWorkflowDraftVariableService:
|
||||
|
||||
# Verify last_edited_at was reset
|
||||
assert variable.last_edited_at is None
|
||||
flush.assert_called()
|
||||
# Verify session.flush was called
|
||||
mock_session.flush.assert_called()
|
||||
|
||||
# Should return the updated variable
|
||||
assert result == variable
|
||||
|
||||
def test_reset_non_editable_system_variable_raises_error(self, sqlite_session: Session):
|
||||
def test_reset_non_editable_system_variable_raises_error(self, mock_session):
|
||||
"""Test that resetting a non-editable system variable raises an error"""
|
||||
service = WorkflowDraftVariableService(sqlite_session)
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
|
||||
test_app_id = self._get_test_app_id()
|
||||
workflow = self._create_test_workflow(test_app_id)
|
||||
@@ -530,9 +572,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, sqlite_session: Session):
|
||||
def test_reset_editable_system_variable_succeeds(self, mock_session):
|
||||
"""Test that resetting an editable system variable succeeds"""
|
||||
service = WorkflowDraftVariableService(sqlite_session)
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
|
||||
test_app_id = self._get_test_app_id()
|
||||
workflow = self._create_test_workflow(test_app_id)
|
||||
@@ -546,8 +588,6 @@ 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)
|
||||
@@ -557,17 +597,16 @@ class TestWorkflowDraftVariableService:
|
||||
service._api_node_execution_repo = Mock()
|
||||
service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
|
||||
|
||||
with patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush:
|
||||
result = service._reset_node_var_or_sys_var(workflow, variable)
|
||||
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
|
||||
flush.assert_called()
|
||||
mock_session.flush.assert_called()
|
||||
|
||||
def test_reset_query_system_variable_succeeds(self, sqlite_session: Session):
|
||||
def test_reset_query_system_variable_succeeds(self, mock_session):
|
||||
"""Test that resetting query system variable (another editable one) succeeds"""
|
||||
service = WorkflowDraftVariableService(sqlite_session)
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
|
||||
test_app_id = self._get_test_app_id()
|
||||
workflow = self._create_test_workflow(test_app_id)
|
||||
@@ -581,8 +620,6 @@ 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)
|
||||
@@ -592,13 +629,12 @@ class TestWorkflowDraftVariableService:
|
||||
service._api_node_execution_repo = Mock()
|
||||
service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
|
||||
|
||||
with patch.object(sqlite_session, "flush", wraps=sqlite_session.flush) as flush:
|
||||
result = service._reset_node_var_or_sys_var(workflow, variable)
|
||||
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
|
||||
flush.assert_called()
|
||||
mock_session.flush.assert_called()
|
||||
|
||||
def test_system_variable_editability_check(self):
|
||||
"""Test the system variable editability function directly"""
|
||||
|
||||
@@ -13,13 +13,9 @@ import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.app_config.entities import WorkflowUIBasedAppConfig
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.entities.task_entities import StreamEvent
|
||||
from core.app.layers.pause_state_persist_layer import (
|
||||
WorkflowResumptionContext,
|
||||
_AdvancedChatAppGenerateEntityWrapper,
|
||||
_WorkflowGenerateEntityWrapper,
|
||||
)
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _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
|
||||
@@ -255,34 +251,6 @@ 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
|
||||
@@ -359,69 +327,19 @@ class _PauseEntity(WorkflowPauseEntity):
|
||||
return []
|
||||
|
||||
|
||||
def test_get_message_context_by_conversation_should_return_none_when_no_message() -> None:
|
||||
def test_get_message_context_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_by_conversation(
|
||||
cast(sessionmaker[Session], session_maker),
|
||||
conversation_id="conv-1",
|
||||
workflow_run_id="run-1",
|
||||
)
|
||||
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
|
||||
|
||||
# Assert
|
||||
assert result is 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:
|
||||
def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp() -> None:
|
||||
# Arrange
|
||||
message = SimpleNamespace(
|
||||
id="msg-1",
|
||||
@@ -433,11 +351,7 @@ def test_get_message_context_by_conversation_should_default_created_at_to_zero_w
|
||||
session_maker = _SessionMaker(session)
|
||||
|
||||
# Act
|
||||
result = service_module._get_message_context_by_conversation(
|
||||
cast(sessionmaker[Session], session_maker),
|
||||
conversation_id="conv-1",
|
||||
workflow_run_id="run-1",
|
||||
)
|
||||
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
@@ -645,14 +559,9 @@ 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.PAUSED)
|
||||
workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING)
|
||||
topic = _Topic(_StaticSubscription())
|
||||
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)
|
||||
)
|
||||
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),
|
||||
@@ -660,19 +569,12 @@ 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_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),
|
||||
"_get_message_context",
|
||||
MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)),
|
||||
)
|
||||
monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None))
|
||||
buffer_state = BufferState(
|
||||
queue=queue.Queue(),
|
||||
stop_event=Event(),
|
||||
@@ -687,7 +589,6 @@ 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(
|
||||
@@ -696,7 +597,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=session_maker,
|
||||
session_maker=MagicMock(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -708,119 +609,6 @@ 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(
|
||||
|
||||
+10
-32
@@ -13,12 +13,9 @@ import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.app_config.entities import WorkflowUIBasedAppConfig
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.entities.app_invoke_entities import 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
|
||||
@@ -150,21 +147,15 @@ class _PauseEntity(WorkflowPauseEntity):
|
||||
|
||||
|
||||
class TestWorkflowEventSnapshotHelpers:
|
||||
def test_get_message_context_by_conversation_should_return_none_when_no_message(self) -> None:
|
||||
def test_get_message_context_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_by_conversation(
|
||||
cast(sessionmaker[Session], session_maker),
|
||||
conversation_id="conv-1",
|
||||
workflow_run_id="run-1",
|
||||
)
|
||||
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_message_context_by_conversation_should_default_created_at_to_zero_when_message_has_no_timestamp(
|
||||
self,
|
||||
) -> None:
|
||||
def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp(self) -> None:
|
||||
message = SimpleNamespace(
|
||||
id="msg-1",
|
||||
conversation_id="conv-1",
|
||||
@@ -174,11 +165,7 @@ class TestWorkflowEventSnapshotHelpers:
|
||||
session = SimpleNamespace(scalar=MagicMock(return_value=message))
|
||||
session_maker = _SessionMaker(session)
|
||||
|
||||
result = service_module._get_message_context_by_conversation(
|
||||
cast(sessionmaker[Session], session_maker),
|
||||
conversation_id="conv-1",
|
||||
workflow_run_id="run-1",
|
||||
)
|
||||
result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1")
|
||||
|
||||
assert result is not None
|
||||
assert result.created_at == 0
|
||||
@@ -337,10 +324,9 @@ class TestBuildWorkflowEventStream:
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.PAUSED)
|
||||
workflow_run = _build_workflow_run(status=WorkflowExecutionStatus.RUNNING)
|
||||
topic = _Topic(_StaticSubscription())
|
||||
pause_entity = _PauseEntity(state=b"state")
|
||||
workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(return_value=pause_entity))
|
||||
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),
|
||||
@@ -350,18 +336,10 @@ class TestBuildWorkflowEventStream:
|
||||
monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic))
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"_get_message_context_by_conversation",
|
||||
"_get_message_context",
|
||||
MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)),
|
||||
)
|
||||
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),
|
||||
)
|
||||
monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None))
|
||||
buffer_state = BufferState(
|
||||
queue=queue.Queue(),
|
||||
stop_event=Event(),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""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 Session, sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.workflow.human_input_adapter import (
|
||||
EmailDeliveryConfig,
|
||||
@@ -16,42 +14,12 @@ 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(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 _make_service() -> WorkflowService:
|
||||
return WorkflowService(session_maker=sessionmaker())
|
||||
|
||||
|
||||
def _build_node_config(delivery_methods: list[EmailDeliveryMethod]) -> dict[str, object]:
|
||||
@@ -68,37 +36,6 @@ 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(),
|
||||
@@ -115,11 +52,11 @@ def _make_email_method(enabled: bool = True, debug_mode: bool = False) -> EmailD
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
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")
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not initialized"):
|
||||
service.test_human_input_delivery(
|
||||
@@ -127,19 +64,17 @@ def test_human_input_delivery_requires_draft_workflow(sqlite_session: Session):
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
delivery_method_id="delivery-1",
|
||||
session=sqlite_session,
|
||||
session=MagicMock(),
|
||||
)
|
||||
assert sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@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)
|
||||
def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyPatch):
|
||||
service = _make_service()
|
||||
delivery_method = _make_email_method(enabled=False)
|
||||
_persist_workflow(sqlite_session, [delivery_method])
|
||||
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]
|
||||
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"
|
||||
@@ -156,29 +91,27 @@ def test_human_input_delivery_allows_disabled_method(
|
||||
MagicMock(return_value=test_service_instance),
|
||||
)
|
||||
|
||||
app_model = _app()
|
||||
account = _account()
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
delivery_method_id=str(delivery_method.id),
|
||||
session=sqlite_session,
|
||||
session=MagicMock(),
|
||||
)
|
||||
|
||||
test_service_instance.send_test.assert_called_once()
|
||||
assert sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@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)
|
||||
def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.MonkeyPatch):
|
||||
service = _make_service()
|
||||
delivery_method = _make_email_method(enabled=True)
|
||||
_persist_workflow(sqlite_session, [delivery_method])
|
||||
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]
|
||||
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"
|
||||
@@ -195,8 +128,8 @@ def test_human_input_delivery_dispatches_to_test_service(
|
||||
MagicMock(return_value=test_service_instance),
|
||||
)
|
||||
|
||||
app_model = _app()
|
||||
account = _account()
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
@@ -204,23 +137,21 @@ def test_human_input_delivery_dispatches_to_test_service(
|
||||
node_id="node-1",
|
||||
delivery_method_id=str(delivery_method.id),
|
||||
inputs={"#node-1.output#": "value"},
|
||||
session=sqlite_session,
|
||||
session=MagicMock(),
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@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)
|
||||
def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytest.MonkeyPatch):
|
||||
service = _make_service()
|
||||
delivery_method = _make_email_method(enabled=True, debug_mode=True)
|
||||
_persist_workflow(sqlite_session, [delivery_method])
|
||||
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]
|
||||
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"
|
||||
@@ -237,15 +168,15 @@ def test_human_input_delivery_debug_mode_overrides_recipients(
|
||||
MagicMock(return_value=test_service_instance),
|
||||
)
|
||||
|
||||
app_model = _app()
|
||||
account = _account()
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
delivery_method_id=str(delivery_method.id),
|
||||
session=sqlite_session,
|
||||
session=MagicMock(),
|
||||
)
|
||||
|
||||
test_service_instance.send_test.assert_called_once()
|
||||
@@ -257,4 +188,3 @@ def test_human_input_delivery_debug_mode_overrides_recipients(
|
||||
recipient = sent_method.config.recipients.items[0]
|
||||
assert isinstance(recipient, MemberRecipient)
|
||||
assert recipient.reference_id == account.id
|
||||
assert sqlite_session.in_transaction()
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -23,17 +18,18 @@ class _DummyMail:
|
||||
self.sent.append({"to": to, "subject": subject, "html": html})
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
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 _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob:
|
||||
@@ -50,14 +46,9 @@ def _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob:
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: pytest.MonkeyPatch):
|
||||
mail = _DummyMail()
|
||||
form = _form()
|
||||
sqlite_session.add(form)
|
||||
sqlite_session.commit()
|
||||
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
|
||||
|
||||
monkeypatch.setattr(task_module, "mail", mail)
|
||||
monkeypatch.setattr(
|
||||
@@ -69,9 +60,9 @@ def test_dispatch_human_input_email_task_sends_to_each_recipient(
|
||||
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: jobs)
|
||||
|
||||
task_module.dispatch_human_input_email_task(
|
||||
form_id=form.id,
|
||||
form_id="form-1",
|
||||
node_title="Approve",
|
||||
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
assert len(mail.sent) == 2
|
||||
@@ -79,14 +70,9 @@ def test_dispatch_human_input_email_task_sends_to_each_recipient(
|
||||
assert all("Body for" in payload["html"] for payload in mail.sent)
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
def test_dispatch_human_input_email_task_skips_when_feature_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
mail = _DummyMail()
|
||||
form = _form()
|
||||
sqlite_session.add(form)
|
||||
sqlite_session.commit()
|
||||
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
|
||||
|
||||
monkeypatch.setattr(task_module, "mail", mail)
|
||||
monkeypatch.setattr(
|
||||
@@ -97,22 +83,17 @@ def test_dispatch_human_input_email_task_skips_when_feature_disabled(
|
||||
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [])
|
||||
|
||||
task_module.dispatch_human_input_email_task(
|
||||
form_id=form.id,
|
||||
form_id="form-1",
|
||||
node_title="Approve",
|
||||
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
assert mail.sent == []
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: pytest.MonkeyPatch):
|
||||
mail = _DummyMail()
|
||||
form = _form(workflow_run_id=str(uuid4()))
|
||||
sqlite_session.add(form)
|
||||
sqlite_session.commit()
|
||||
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id="run-1")
|
||||
job = task_module._EmailDeliveryJob(
|
||||
form_id="form-1",
|
||||
subject="Subject",
|
||||
@@ -134,26 +115,21 @@ def test_dispatch_human_input_email_task_replaces_body_variables(
|
||||
monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: variable_pool)
|
||||
|
||||
task_module.dispatch_human_input_email_task(
|
||||
form_id=form.id,
|
||||
form_id="form-1",
|
||||
node_title="Approve",
|
||||
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
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 = _form()
|
||||
sqlite_session.add(form)
|
||||
sqlite_session.commit()
|
||||
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
|
||||
job = task_module._EmailDeliveryJob(
|
||||
form_id="form-1",
|
||||
subject=f"Notice{line_break}BCC:attacker@example.com <b>Alert</b>",
|
||||
@@ -172,9 +148,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.id,
|
||||
form_id="form-1",
|
||||
node_title="Approve",
|
||||
session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False),
|
||||
session_factory=lambda: _DummySession(form),
|
||||
)
|
||||
|
||||
assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert"
|
||||
|
||||
+1
-1
@@ -8,4 +8,4 @@ cd "$SCRIPT_DIR/../api"
|
||||
uv run flask db upgrade
|
||||
|
||||
uv run \
|
||||
dotenv -f .env run --no-override -- python -m app
|
||||
flask run --host 0.0.0.0 --port=5001 --debug
|
||||
|
||||
@@ -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('searchbox', { name: 'Search integrations...' })
|
||||
world.getPage().getByRole('textbox', { name: 'Search integrations...' })
|
||||
|
||||
const jsonReplaceRuntimePrompt = [
|
||||
'You are a Dify Agent E2E JSON tool verifier.',
|
||||
|
||||
@@ -394,6 +394,14 @@
|
||||
"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
|
||||
@@ -3274,6 +3282,11 @@
|
||||
"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
|
||||
@@ -3369,6 +3382,14 @@
|
||||
"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
|
||||
@@ -3445,6 +3466,43 @@
|
||||
"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
|
||||
@@ -3912,6 +3970,14 @@
|
||||
"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
|
||||
@@ -3952,16 +4018,69 @@
|
||||
"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
|
||||
@@ -4033,6 +4152,14 @@
|
||||
"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
|
||||
@@ -5202,6 +5329,11 @@
|
||||
"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
|
||||
@@ -5240,6 +5372,11 @@
|
||||
"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
|
||||
@@ -6199,6 +6336,11 @@
|
||||
"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,7 +58,6 @@ 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,7 +87,6 @@ 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: '' }),
|
||||
@@ -145,7 +144,6 @@ 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,7 +567,6 @@ 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,7 +765,6 @@ 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: '' }),
|
||||
@@ -823,7 +822,6 @@ 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: '' },
|
||||
|
||||
@@ -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 | navigate through link | link destination preview | ❌ (visual enhancement) |
|
||||
| `Popover` | click / tap (+ hover) | **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 | has a primary click target | supplementary preview | ❌ (via click target) |
|
||||
| `Popover` | click / tap (+ hover) | **to open the popup** | anything, incl. long text | ✅ |
|
||||
|
||||
Base UI decision rule ([docs]):
|
||||
|
||||
@@ -35,11 +35,9 @@ 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` — 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.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -179,10 +179,9 @@ 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, preview card, infotip, and popover semantics
|
||||
### Tooltip, 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:
|
||||
"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.",
|
||||
'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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,16 +10,19 @@ import { parsePlacement } from '../placement'
|
||||
export type { Placement }
|
||||
|
||||
/**
|
||||
* PreviewCard follows Base UI's canonical semantics: a hover/focus-triggered
|
||||
* visual enhancement for a link that previews its destination.
|
||||
* PreviewCard is a hover/focus-triggered rich preview intended to supplement a
|
||||
* link. Base UI's canonical trigger renders an anchor.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const PreviewCard = BasePreviewCard.Root
|
||||
export const PreviewCardTrigger = BasePreviewCard.Trigger
|
||||
|
||||
@@ -41,6 +41,10 @@ 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)
|
||||
@@ -139,7 +143,7 @@ const setupProviderContext = (
|
||||
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
|
||||
mockConsoleState = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [],
|
||||
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...overrides,
|
||||
@@ -241,11 +245,11 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button to managers without billing permission keys', () => {
|
||||
it('should show billing button when manager has subscription management permission', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [],
|
||||
workspacePermissionKeys: ['billing.subscription.manage'],
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
@@ -254,10 +258,11 @@ describe('Billing Page + Plan Integration', () => {
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button from non-manager members', () => {
|
||||
it('should hide billing button when subscription management permission is granted without manager role', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: false,
|
||||
workspacePermissionKeys: ['billing.subscription.manage'],
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
@@ -265,16 +270,16 @@ describe('Billing Page + Plan Integration', () => {
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show billing button when a manager has no billing permission keys', () => {
|
||||
it('should hide billing button when subscription management permission is missing', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [],
|
||||
workspacePermissionKeys: ['billing.view', 'billing.manage'],
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when billing is disabled', () => {
|
||||
@@ -284,6 +289,17 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -350,39 +366,6 @@ 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 → payment capability check → fetch URL → redirect
|
||||
* CloudPlanItem → Button click → permission check → fetch URL → redirect
|
||||
*
|
||||
* Covers plan comparison, downgrade prevention, monthly/yearly pricing,
|
||||
* and workspace manager permission enforcement.
|
||||
@@ -30,6 +30,10 @@ 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', () => ({
|
||||
@@ -61,6 +65,7 @@ vi.mock('@/next/navigation', () => ({
|
||||
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
|
||||
mockConsoleState = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: ['billing.view', 'billing.manage', 'billing.subscription.manage'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -270,11 +275,15 @@ describe('Cloud Plan Payment Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Payment capability ──────────────────────────────────────────────
|
||||
describe('Payment capability', () => {
|
||||
it('should change plans when payment is allowed', async () => {
|
||||
// ─── 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'],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional, canPay: true })
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
const button = getPlanButton('billing.plansCommon.startBuilding')
|
||||
await user.click(button)
|
||||
@@ -284,9 +293,13 @@ describe('Cloud Plan Payment Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should block plan changes when payment is not allowed', async () => {
|
||||
it('should show error toast when billing manage permission is missing for plan changes', async () => {
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: false,
|
||||
workspacePermissionKeys: ['billing.view', 'billing.subscription.manage'],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional, canPay: false })
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
const button = getPlanButton('billing.plansCommon.startBuilding')
|
||||
await user.click(button)
|
||||
@@ -297,13 +310,13 @@ describe('Cloud Plan Payment Flow', () => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open billing portal when payment is allowed', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.professional,
|
||||
plan: Plan.professional,
|
||||
canPay: true,
|
||||
it('should open billing portal when subscription management permission is granted without manager role', async () => {
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: false,
|
||||
workspacePermissionKeys: ['billing.subscription.manage'],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = getPlanButton('billing.plansCommon.currentPlan')
|
||||
await user.click(button)
|
||||
@@ -314,13 +327,13 @@ describe('Cloud Plan Payment Flow', () => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should block billing portal access when payment is not allowed', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.professional,
|
||||
plan: Plan.professional,
|
||||
canPay: false,
|
||||
it('should show error toast when subscription management permission is missing for current paid plan', async () => {
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: false,
|
||||
workspacePermissionKeys: ['billing.view', 'billing.manage'],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = getPlanButton('billing.plansCommon.currentPlan')
|
||||
await user.click(button)
|
||||
|
||||
@@ -46,6 +46,10 @@ 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)
|
||||
@@ -153,6 +157,7 @@ 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,
|
||||
@@ -219,13 +224,9 @@ describe('Education Verification Flow', () => {
|
||||
|
||||
// ─── 2. Successful Verification Flow ────────────────────────────────────
|
||||
describe('Successful verification flow', () => {
|
||||
it('should let non-manager members start education verification', async () => {
|
||||
it('should navigate to education-apply with token on successful verification', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
|
||||
setupContexts(
|
||||
{},
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
{ isCurrentWorkspaceManager: false },
|
||||
)
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: 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, 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 { ALL_PLANS } from '@/app/components/billing/config'
|
||||
@@ -19,7 +19,6 @@ 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', () => ({
|
||||
@@ -34,6 +33,10 @@ 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)
|
||||
@@ -46,7 +49,7 @@ vi.mock('@/context/i18n', () => ({
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/billing', () => ({
|
||||
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
|
||||
fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
@@ -128,6 +131,7 @@ 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,
|
||||
@@ -141,7 +145,6 @@ describe('Pricing Modal Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com' })
|
||||
setupContexts()
|
||||
})
|
||||
|
||||
@@ -263,54 +266,6 @@ 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,12 +1,14 @@
|
||||
import { toast, ToastHost } from '@langgenius/dify-ui/toast'
|
||||
/**
|
||||
* Integration test: Self-Hosted Plan Flow
|
||||
*
|
||||
* Tests the self-hosted plan items:
|
||||
* SelfHostedPlanItem → Button click → redirect to external URL
|
||||
* SelfHostedPlanItem → Button click → permission check → redirect to external URL
|
||||
*
|
||||
* Covers community/premium/enterprise plan rendering and external URL navigation.
|
||||
* Covers community/premium/enterprise plan rendering, external URL navigation,
|
||||
* and workspace manager permission enforcement.
|
||||
*/
|
||||
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 {
|
||||
@@ -18,9 +20,20 @@ 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' }),
|
||||
@@ -39,14 +52,29 @@ 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(<SelfHostedPlanItem plan={plan} />)
|
||||
return render(
|
||||
<>
|
||||
<ToastHost timeout={0} />
|
||||
<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 = ''
|
||||
@@ -159,4 +187,64 @@ 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+12
-7
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -17,10 +18,6 @@ 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, {
|
||||
@@ -29,7 +26,10 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
|
||||
},
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => mockNavigation)
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppDetailDirect: vi.fn(),
|
||||
@@ -52,8 +52,8 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUsePathname = mockNavigation.usePathname
|
||||
const mockUseRouter = mockNavigation.useRouter
|
||||
const mockUsePathname = vi.mocked(usePathname)
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect)
|
||||
|
||||
const createAppDetail = (overrides: Partial<App> = {}) =>
|
||||
@@ -83,7 +83,12 @@ 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()
|
||||
|
||||
+12
-7
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -6,10 +7,6 @@ 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, {
|
||||
@@ -18,7 +15,10 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
|
||||
},
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => mockNavigation)
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetDetail: vi.fn(),
|
||||
@@ -61,8 +61,8 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseRouter = mockNavigation.useRouter
|
||||
const mockUsePathname = mockNavigation.usePathname
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockUsePathname = vi.mocked(usePathname)
|
||||
const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
|
||||
|
||||
describe('DatasetDetailLayout', () => {
|
||||
@@ -71,7 +71,12 @@ 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,25 +47,4 @@ 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,7 +13,6 @@ let mockWebappAuth = {
|
||||
allow_sso: true,
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
allow_public_access: true,
|
||||
}
|
||||
|
||||
const render = (ui: ReactElement) =>
|
||||
@@ -54,7 +53,6 @@ describe('AccessControl', () => {
|
||||
allow_sso: true,
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
allow_public_access: true,
|
||||
}
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
@@ -115,7 +113,6 @@ describe('AccessControl', () => {
|
||||
allow_sso: false,
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
allow_public_access: true,
|
||||
}
|
||||
|
||||
render(
|
||||
@@ -144,48 +141,4 @@ 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,22 +6,18 @@ import { RadioItem } from '@langgenius/dify-ui/radio'
|
||||
|
||||
type AccessControlItemProps = PropsWithChildren<{
|
||||
type: AccessMode
|
||||
disabled?: boolean
|
||||
}>
|
||||
|
||||
export default function AccessControlItem({ type, children, disabled }: AccessControlItemProps) {
|
||||
export default function AccessControlItem({ type, children }: AccessControlItemProps) {
|
||||
return (
|
||||
<RadioItem<AccessMode>
|
||||
value={type}
|
||||
disabled={disabled}
|
||||
render={<div />}
|
||||
className={cn(
|
||||
'rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
|
||||
'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',
|
||||
'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,7 +13,6 @@ 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'
|
||||
@@ -40,7 +39,6 @@ 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)
|
||||
@@ -50,9 +48,7 @@ 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
|
||||
@@ -74,16 +70,7 @@ 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,
|
||||
confirmDisabled,
|
||||
])
|
||||
}, [updateAccessMode, appId, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
@@ -130,29 +117,19 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC} disabled={publicAccessDisabled}>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<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={confirmDisabled}
|
||||
disabled={isPending}
|
||||
loading={isPending}
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'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)
|
||||
@@ -1,35 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -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 { OperationButton } from '@/app/components/app/configuration/base/operation-button'
|
||||
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
|
||||
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>
|
||||
<OperationButton operation="edit" onClick={onShowEditModal} />
|
||||
<OperationBtn type="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 { OperationButton } from '@/app/components/app/configuration/base/operation-button'
|
||||
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
|
||||
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,7 +50,9 @@ const SelectVarType: FC<Props> = ({ onChange }) => {
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<OperationButton operation="add" />} />
|
||||
<DropdownMenuTrigger nativeButton={false} render={<div className="block" />}>
|
||||
<OperationBtn type="add" />
|
||||
</DropdownMenuTrigger>
|
||||
<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 { OperationButton } from '@/app/components/app/configuration/base/operation-button'
|
||||
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
|
||||
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={<OperationButton operation="add" />}
|
||||
trigger={<OperationBtn type="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 { OperationButton } from '../base/operation-button'
|
||||
import OperationBtn from '../base/operation-btn'
|
||||
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} />}
|
||||
<OperationButton operation="add" onClick={showSelectDataSet} />
|
||||
<OperationBtn type="add" onClick={showSelectDataSet} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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()
|
||||
@@ -38,6 +39,14 @@ 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,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -59,10 +68,11 @@ describe('Billing', () => {
|
||||
isManager = true
|
||||
enableBilling = true
|
||||
billingUrlEnabled = false
|
||||
workspacePermissionKeys = ['billing.subscription.manage']
|
||||
refetchMock.mockResolvedValue({ data: 'https://billing' })
|
||||
})
|
||||
|
||||
it('hides the billing action from non-manager members', () => {
|
||||
it('hides the billing action when subscription management permission is granted without manager role', () => {
|
||||
isManager = false
|
||||
|
||||
render(<Billing />)
|
||||
@@ -73,14 +83,16 @@ describe('Billing', () => {
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the billing action to managers without billing permission keys', () => {
|
||||
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
|
||||
workspacePermissionKeys = []
|
||||
render(<Billing />)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /billing\.viewBillingTitle/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
|
||||
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the billing action when billing is disabled', () => {
|
||||
vi.clearAllMocks()
|
||||
workspacePermissionKeys = ['billing.subscription.manage']
|
||||
enableBilling = false
|
||||
render(<Billing />)
|
||||
expect(
|
||||
|
||||
@@ -3,21 +3,27 @@ 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 && isCurrentWorkspaceManager)
|
||||
} = useBillingUrl(enableBilling && canManageBillingSubscription)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const handleOpenBilling = async () => {
|
||||
@@ -40,7 +46,7 @@ const Billing: FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<PlanComp loc="billing-page" />
|
||||
{enableBilling && isCurrentWorkspaceManager && (
|
||||
{enableBilling && canManageBillingSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3"
|
||||
|
||||
@@ -3,19 +3,21 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { fetchSubscriptionUrls } from '@/service/billing'
|
||||
import { BillingPermission, hasPermission } from '@/utils/permission'
|
||||
import { Plan } from '../type'
|
||||
|
||||
export const useEducationDiscount = () => {
|
||||
const { t } = useTranslation()
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const [isEducationDiscountLoading, setIsEducationDiscountLoading] = useState(false)
|
||||
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
|
||||
|
||||
const handleEducationDiscount = useCallback(async () => {
|
||||
if (isEducationDiscountLoading) return
|
||||
|
||||
if (!isCurrentWorkspaceManager) {
|
||||
if (!canManageBilling) {
|
||||
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
|
||||
return
|
||||
}
|
||||
@@ -27,7 +29,7 @@ export const useEducationDiscount = () => {
|
||||
} finally {
|
||||
setIsEducationDiscountLoading(false)
|
||||
}
|
||||
}, [isCurrentWorkspaceManager, isEducationDiscountLoading, t])
|
||||
}, [canManageBilling, isEducationDiscountLoading, t])
|
||||
|
||||
return {
|
||||
handleEducationDiscount,
|
||||
|
||||
@@ -14,10 +14,11 @@ 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'
|
||||
@@ -37,7 +38,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
const router = useRouter()
|
||||
const path = usePathname()
|
||||
const userProfileEmail = useAtomValue(userProfileEmailAtom)
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } =
|
||||
useProviderContext()
|
||||
const isAboutToExpire = allowRefreshEducationVerify
|
||||
@@ -58,6 +59,7 @@ 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()
|
||||
@@ -110,7 +112,7 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
enableEducationPlan &&
|
||||
isEducationAccount &&
|
||||
type === Plan.sandbox &&
|
||||
isCurrentWorkspaceManager && (
|
||||
canManageBilling && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleEducationDiscount}
|
||||
|
||||
@@ -50,6 +50,10 @@ 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(),
|
||||
@@ -75,6 +79,7 @@ describe('Pricing dialog lifecycle', () => {
|
||||
latestOnOpenChange = undefined
|
||||
mockConsoleState = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: ['billing.manage'],
|
||||
}
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
plan: {
|
||||
|
||||
@@ -14,8 +14,9 @@ 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 { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { BillingPermission, hasPermission } from '@/utils/permission'
|
||||
import { NoiseBottom, NoiseTop } from './assets'
|
||||
import Footer from './footer'
|
||||
import Header from './header'
|
||||
@@ -30,13 +31,14 @@ type PricingProps = {
|
||||
|
||||
const Pricing: FC<PricingProps> = ({ onCancel }) => {
|
||||
const { plan, enableEducationPlan, isEducationAccount } = useProviderContext()
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const shouldDefaultToYearly =
|
||||
isCurrentWorkspaceManager && enableEducationPlan && isEducationAccount
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage)
|
||||
const shouldDefaultToYearly = canManageBilling && 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
|
||||
@@ -69,7 +71,7 @@ const Pricing: FC<PricingProps> = ({ onCancel }) => {
|
||||
plan={plan}
|
||||
currentPlan={currentCategory}
|
||||
planRange={planRange}
|
||||
canPay={isCurrentWorkspaceManager}
|
||||
canPay={canPay}
|
||||
/>
|
||||
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
|
||||
<div className="absolute inset-x-0 -bottom-12 -z-10">
|
||||
|
||||
@@ -10,13 +10,16 @@ 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'
|
||||
@@ -49,6 +52,12 @@ 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
|
||||
@@ -81,7 +90,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
|
||||
setLoading(true)
|
||||
try {
|
||||
if (isCurrentPaidPlan) {
|
||||
if (!canPay) {
|
||||
if (!canManageBillingSubscription) {
|
||||
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
|
||||
return
|
||||
}
|
||||
@@ -103,7 +112,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({ plan, currentPlan, planRange, c
|
||||
|
||||
if (isFreePlan) return
|
||||
|
||||
if (!canPay) {
|
||||
if (!canManageBilling) {
|
||||
toast.error(t(($) => $.buyPermissionDeniedTip, { ns: 'billing' }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
'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'
|
||||
@@ -47,8 +51,14 @@ 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
|
||||
@@ -59,7 +69,7 @@ const SelfHostedPlanItem: FC<SelfHostedPlanItemProps> = ({ plan }) => {
|
||||
}
|
||||
|
||||
if (isEnterprisePlan) window.location.href = contactSalesUrl
|
||||
}, [isFreePlan, isPremiumPlan, isEnterprisePlan])
|
||||
}, [canManageBilling, isFreePlan, isPremiumPlan, isEnterprisePlan, t])
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
@@ -10,6 +10,7 @@ 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'
|
||||
@@ -54,7 +55,6 @@ 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: mockUseRouter,
|
||||
useRouter: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -223,8 +223,13 @@ describe('AccountDropdown', () => {
|
||||
vi.mocked(useLogout).mockReturnValue({
|
||||
mutateAsync: mockLogout,
|
||||
} as unknown as ReturnType<typeof useLogout>)
|
||||
mockUseRouter.mockReturnValue({
|
||||
vi.mocked(useRouter).mockReturnValue({
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -199,6 +199,9 @@ const baseConsoleState: ConsoleStateFixture = {
|
||||
'data_source.manage',
|
||||
'api_extension.manage',
|
||||
'customization.manage',
|
||||
'billing.view',
|
||||
'billing.manage',
|
||||
'billing.subscription.manage',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -378,7 +381,7 @@ describe('AccountSetting', () => {
|
||||
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing from dataset operators', () => {
|
||||
it('should not hide workspace menu items solely for dataset operators', () => {
|
||||
// Arrange
|
||||
const datasetOperatorContext = {
|
||||
...baseConsoleState,
|
||||
@@ -397,9 +400,7 @@ describe('AccountSetting', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.settings.billing' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -511,26 +512,23 @@ describe('AccountSetting', () => {
|
||||
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show billing to regular members without billing permission keys', () => {
|
||||
it('should hide billing entry when billing view permission is missing', () => {
|
||||
// Arrange
|
||||
const regularMemberContext = {
|
||||
const contextWithoutBillingViewPermission = {
|
||||
...baseConsoleState,
|
||||
currentWorkspace: {
|
||||
...baseConsoleState.currentWorkspace,
|
||||
role: 'normal' as const,
|
||||
},
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
workspacePermissionKeys: [],
|
||||
workspacePermissionKeys: baseConsoleState.workspacePermissionKeys!.filter(
|
||||
(key) => key !== 'billing.view',
|
||||
),
|
||||
}
|
||||
mockConsoleState.current = regularMemberContext
|
||||
mockConsoleState.current = contextWithoutBillingViewPermission
|
||||
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.settings.billing' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide workflow log archives outside cloud edition', () => {
|
||||
@@ -584,36 +582,20 @@ describe('AccountSetting', () => {
|
||||
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render a direct billing entry for regular members without billing permission keys', () => {
|
||||
it('should not render billing page when active billing tab lacks billing view permission', () => {
|
||||
// Arrange
|
||||
const regularMemberContext = {
|
||||
const contextWithoutBillingViewPermission = {
|
||||
...baseConsoleState,
|
||||
currentWorkspace: {
|
||||
...baseConsoleState.currentWorkspace,
|
||||
role: 'normal' as const,
|
||||
},
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
workspacePermissionKeys: [],
|
||||
workspacePermissionKeys: baseConsoleState.workspacePermissionKeys!.filter(
|
||||
(key) => key !== 'billing.view',
|
||||
),
|
||||
}
|
||||
mockConsoleState.current = regularMemberContext
|
||||
mockConsoleState.current = contextWithoutBillingViewPermission
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
+51
-69
@@ -1,10 +1,6 @@
|
||||
import type { PermissionCatalogResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { PermissionGroup } from '@/models/access-control'
|
||||
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 = [
|
||||
@@ -23,71 +19,59 @@ const expectedAppACLPermissionKeys = [
|
||||
const getPermissionKeyMatcher = (permissionKey: string) =>
|
||||
new RegExp(permissionKey.replaceAll('.', '\\.'))
|
||||
|
||||
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 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,
|
||||
})
|
||||
|
||||
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', () => {
|
||||
renderModal(
|
||||
render(
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode="create"
|
||||
@@ -102,7 +86,7 @@ describe('PermissionSetModal', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/permission\.permissionSet\.nameLabel/)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('permission.permissionSet.descriptionLabel')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Translated app permissions/ })).toHaveAttribute(
|
||||
expect(screen.getByRole('button', { name: /App management/ })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
)
|
||||
@@ -110,7 +94,7 @@ describe('PermissionSetModal', () => {
|
||||
})
|
||||
|
||||
it('should render the complete app ACL permission catalog', async () => {
|
||||
renderModal(
|
||||
render(
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode="create"
|
||||
@@ -125,7 +109,7 @@ describe('PermissionSetModal', () => {
|
||||
})
|
||||
|
||||
it('should render dataset permission catalog when resource type is dataset', () => {
|
||||
renderModal(
|
||||
render(
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode="create"
|
||||
@@ -138,9 +122,7 @@ describe('PermissionSetModal', () => {
|
||||
expect(
|
||||
screen.getByText('permission.permissionSet.modal.create.dataset.title'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Translated dataset permissions/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Dataset management/ })).toBeInTheDocument()
|
||||
expect(screen.getByText(/dataset\.acl\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -152,7 +134,7 @@ describe('PermissionSetModal', () => {
|
||||
const handleClose = vi.fn()
|
||||
const handleSubmit = vi.fn()
|
||||
|
||||
renderModal(
|
||||
render(
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode="create"
|
||||
@@ -186,7 +168,7 @@ describe('PermissionSetModal', () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn()
|
||||
|
||||
renderModal(
|
||||
render(
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode="edit"
|
||||
@@ -218,7 +200,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', () => {
|
||||
renderModal(
|
||||
render(
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode="view"
|
||||
|
||||
+11
-14
@@ -1,21 +1,18 @@
|
||||
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 { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useAppPermissionCatalog,
|
||||
useDatasetPermissionCatalog,
|
||||
} from '@/service/access-control/use-permission-catalog'
|
||||
|
||||
export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => {
|
||||
const { t } = useTranslation()
|
||||
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 { data: appPermissionCatalog } = useAppPermissionCatalog(resourceType === 'app')
|
||||
const { data: datasetPermissionCatalog } = useDatasetPermissionCatalog(resourceType === 'dataset')
|
||||
|
||||
const permissionCatalog = resourceType === 'app' ? appPermissionCatalog : datasetPermissionCatalog
|
||||
|
||||
const groups = useMemo(() => {
|
||||
// Permission keys come from the catalog API, so this is a reviewed open-key boundary with a server-provided fallback.
|
||||
@@ -25,20 +22,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,13 +14,10 @@ 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 {
|
||||
isCurrentWorkspaceDatasetOperatorAtom,
|
||||
isCurrentWorkspaceManagerAtom,
|
||||
} from '@/context/workspace-state'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { BillingPermission, hasPermission } from '@/utils/permission'
|
||||
import AccessRulesPage from './access-rules-page'
|
||||
import { ApiBasedExtensionPage } from './api-based-extension-page'
|
||||
import DataSourcePage from './data-source-page-new'
|
||||
@@ -61,11 +58,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 && !isCurrentWorkspaceDatasetOperator
|
||||
const canViewBilling =
|
||||
enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)
|
||||
const canViewWorkflowLogArchives = IS_CLOUD_EDITION && isCurrentWorkspaceManager
|
||||
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
|
||||
const normalizedActiveTab =
|
||||
|
||||
+31
-45
@@ -1,47 +1,31 @@
|
||||
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 type { PermissionGroup, Role } from '@/models/access-control'
|
||||
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'
|
||||
|
||||
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',
|
||||
})
|
||||
})
|
||||
const mockWorkspacePermissionCatalog = vi.hoisted(() => ({
|
||||
groups: [] as PermissionGroup[],
|
||||
}))
|
||||
|
||||
const workspacePermissionCatalog = {
|
||||
groups: [
|
||||
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: [
|
||||
{
|
||||
group_key: 'workspace_management',
|
||||
group_name: 'Workspace management',
|
||||
key: 'workspace.member.manage',
|
||||
name: 'Manage members',
|
||||
description: '',
|
||||
permissions: [
|
||||
{
|
||||
key: 'workspace.member.manage',
|
||||
name: 'Manage members',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} 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>)
|
||||
}
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createRole = (overrides: Partial<Role> = {}): Role => ({
|
||||
id: 'role-1',
|
||||
@@ -59,12 +43,13 @@ 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', () => {
|
||||
renderModal(
|
||||
render(
|
||||
<RoleModal open mode="edit" role={createRole()} onClose={vi.fn()} onSubmit={vi.fn()} />,
|
||||
)
|
||||
|
||||
@@ -73,14 +58,15 @@ describe('RoleModal', () => {
|
||||
expect(screen.getByLabelText('permission.role.modal.descriptionLabel')).toHaveValue(
|
||||
'Can operate workspace',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Translated workspace permissions/ }),
|
||||
).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Manage workspace members')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Workspace management/ })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByText(/workspace\.member\.manage/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable confirm action when role name is empty', () => {
|
||||
renderModal(<RoleModal open mode="create" onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
render(<RoleModal open mode="create" onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled()
|
||||
})
|
||||
@@ -93,14 +79,14 @@ describe('RoleModal', () => {
|
||||
const handleClose = vi.fn()
|
||||
const handleSubmit = vi.fn()
|
||||
|
||||
renderModal(<RoleModal open mode="create" onClose={handleClose} onSubmit={handleSubmit} />)
|
||||
render(<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('Manage workspace members'))
|
||||
await user.click(screen.getByText(/workspace\.member\.manage/))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledTimes(1)
|
||||
@@ -116,7 +102,7 @@ describe('RoleModal', () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn()
|
||||
|
||||
renderModal(
|
||||
render(
|
||||
<RoleModal
|
||||
open
|
||||
mode="edit"
|
||||
@@ -146,7 +132,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', () => {
|
||||
renderModal(
|
||||
render(
|
||||
<RoleModal open mode="view" role={createRole()} onClose={vi.fn()} onSubmit={vi.fn()} />,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { SelectorKey } from 'i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useWorkspacePermissionCatalog } from '@/service/access-control/use-permission-catalog'
|
||||
|
||||
export const useWorkspacePermissionGroups = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data: workspacePermissionCatalog } = useQuery(
|
||||
consoleQuery.workspaces.current.rbac.rolePermissions.catalog.get.queryOptions({
|
||||
input: {},
|
||||
}),
|
||||
)
|
||||
const { data: workspacePermissionCatalog } = useWorkspacePermissionCatalog()
|
||||
|
||||
const groups = useMemo(() => {
|
||||
// Permission keys come from the catalog API, so these are reviewed open-key boundaries with server-provided fallbacks.
|
||||
@@ -25,17 +20,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,22 +122,6 @@ 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,7 +8,6 @@ import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
|
||||
type MarketplaceProps = {
|
||||
showInstallButton?: boolean
|
||||
linkToMarketplaceDetail?: boolean
|
||||
pluginTypeSwitchClassName?: string
|
||||
isMarketplacePlatform?: boolean
|
||||
marketplaceNav?: React.ReactNode
|
||||
@@ -20,7 +19,6 @@ type MarketplaceProps = {
|
||||
|
||||
const Marketplace = async ({
|
||||
showInstallButton = false,
|
||||
linkToMarketplaceDetail = false,
|
||||
pluginTypeSwitchClassName,
|
||||
isMarketplacePlatform = false,
|
||||
marketplaceNav,
|
||||
@@ -37,10 +35,7 @@ const Marketplace = async ({
|
||||
{!isMarketplacePlatform && (
|
||||
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} />
|
||||
)}
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
<ListWrapper showInstallButton={showInstallButton} />
|
||||
</PluginInstallPermissionProviderGuard>
|
||||
</HydrateQueryClient>
|
||||
</TanstackQueryInitializer>
|
||||
|
||||
@@ -87,19 +87,13 @@ describe('CardWrapper', () => {
|
||||
</ThemeProvider>,
|
||||
)
|
||||
|
||||
it('renders a non-navigating card by default when install button is hidden', () => {
|
||||
it('renders a non-navigating card 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,20 +11,17 @@ 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 Link from '@/next/link'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
import { 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()
|
||||
@@ -105,7 +102,7 @@ const CardWrapperComponent = ({
|
||||
)
|
||||
}
|
||||
|
||||
const card = (
|
||||
return (
|
||||
<div className="group relative rounded-xl">
|
||||
<Card
|
||||
key={plugin.name}
|
||||
@@ -121,17 +118,6 @@ 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,7 +15,6 @@ type ListProps = {
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
plugins?: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
linkToMarketplaceDetail?: boolean
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
emptyClassName?: string
|
||||
@@ -26,7 +25,6 @@ const List = ({
|
||||
marketplaceCollectionPluginsMap,
|
||||
plugins,
|
||||
showInstallButton,
|
||||
linkToMarketplaceDetail,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
emptyClassName,
|
||||
@@ -64,7 +62,6 @@ const List = ({
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
cardRender={cardRender}
|
||||
onCollectionMoreClick={onCollectionMoreClick}
|
||||
@@ -82,7 +79,6 @@ const List = ({
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
isInstalled={installedPluginIds.has(plugin.plugin_id)}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
@@ -47,25 +46,13 @@ type PluginCardProps = {
|
||||
showInstallButton?: boolean
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
isInstalled?: boolean
|
||||
linkToMarketplaceDetail?: boolean
|
||||
}
|
||||
|
||||
const PluginCard = ({
|
||||
plugin,
|
||||
showInstallButton,
|
||||
cardRender,
|
||||
isInstalled,
|
||||
linkToMarketplaceDetail,
|
||||
}: PluginCardProps) => {
|
||||
const PluginCard = ({ plugin, showInstallButton, cardRender, isInstalled }: PluginCardProps) => {
|
||||
if (cardRender) return cardRender(plugin)
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
isInstalled={isInstalled}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
<CardWrapper plugin={plugin} showInstallButton={showInstallButton} isInstalled={isInstalled} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,7 +60,6 @@ const ListWithCollection = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
linkToMarketplaceDetail,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
onCollectionMoreClick,
|
||||
@@ -161,7 +147,6 @@ const ListWithCollection = ({
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
cardRender={cardRender}
|
||||
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
|
||||
/>
|
||||
@@ -178,7 +163,6 @@ const ListWithCollection = ({
|
||||
key={plugin.plugin_id}
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
cardRender={cardRender}
|
||||
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
|
||||
/>
|
||||
|
||||
@@ -7,9 +7,8 @@ import List from './index'
|
||||
|
||||
type ListWrapperProps = {
|
||||
showInstallButton?: boolean
|
||||
linkToMarketplaceDetail?: boolean
|
||||
}
|
||||
const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapperProps) => {
|
||||
const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
@@ -46,7 +45,6 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,18 +28,12 @@ describe('SearchBox', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SearchHarness usedInMarketplace={mode} />)
|
||||
|
||||
const input = screen.getByRole('searchbox', { name: 'Search plugins' })
|
||||
const input = screen.getByPlaceholderText('Search plugins')
|
||||
await user.type(input, 'agent')
|
||||
expect(input).toHaveValue('agent')
|
||||
|
||||
await user.tab()
|
||||
const clearButton = screen.getByRole('button', {
|
||||
name: /^plugin\.clearSearch/,
|
||||
})
|
||||
expect(clearButton).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(input).toHaveValue('')
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
|
||||
it('opens the custom tool flow from the add button', async () => {
|
||||
@@ -58,9 +52,7 @@ describe('SearchBox', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const addButton = screen.getByRole('button', { name: 'tools.addToolModal.custom.tip' })
|
||||
addButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(onShowAddCustomCollectionModal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -79,6 +71,6 @@ describe('SearchBox', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(ref.current).toBe(screen.getByRole('searchbox', { name: 'Search plugins' }))
|
||||
expect(ref.current).toBe(screen.getByPlaceholderText('Search plugins'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TagsFilter from '../tags-filter'
|
||||
|
||||
@@ -33,14 +32,51 @@ 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'))
|
||||
|
||||
describe('TagsFilter', () => {
|
||||
const ensurePopoverOpen = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
if (!screen.queryByRole('searchbox', { name: 'pluginTags.searchTags' }))
|
||||
await user.click(screen.getByRole('button', { name: 'pluginTags.allTags' }))
|
||||
vi.mock('../trigger/marketplace', () => ({
|
||||
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
|
||||
<div data-testid="marketplace-trigger">
|
||||
marketplace:
|
||||
{selectedTagsLength}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
return screen.getByRole('searchbox', { name: 'pluginTags.searchTags' })
|
||||
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'))
|
||||
|
||||
return screen.getByTestId('popover-content')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -50,43 +86,53 @@ describe('TagsFilter', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('filters tag options by search text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||
const search = await ensurePopoverOpen(user)
|
||||
it('renders marketplace trigger when used in marketplace', () => {
|
||||
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} usedInMarketplace />)
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'Search' })).toBeInTheDocument()
|
||||
|
||||
await user.type(search, 'ra')
|
||||
|
||||
expect(screen.queryByRole('checkbox', { name: 'Agent' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'RAG' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox', { name: 'Search' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1')
|
||||
expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds and removes selected tags when options are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
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', () => {
|
||||
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('RAG')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } })
|
||||
|
||||
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('RAG')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Search')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds and removes selected tags when options are clicked', () => {
|
||||
const onTagsChange = vi.fn()
|
||||
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Agent' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Agent' }))
|
||||
fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
|
||||
expect(onTagsChange).toHaveBeenCalledWith([])
|
||||
|
||||
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||
await user.click(screen.getByRole('checkbox', { name: 'RAG' }))
|
||||
fireEvent.click(within(ensurePopoverOpen()).getByText('RAG'))
|
||||
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
|
||||
})
|
||||
|
||||
it('falls back to an empty placeholder when translation is missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('falls back to an empty placeholder when translation is missing', () => {
|
||||
mockTranslate.mockImplementation(() => undefined as unknown as string)
|
||||
|
||||
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByRole('searchbox')).toHaveAttribute('placeholder', '')
|
||||
expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
import type { Ref } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useImperativeHandle, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAddLine, RiCloseLine, RiSearchLine } from '@remixicon/react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import TagsFilter from './tags-filter'
|
||||
|
||||
@@ -21,10 +20,11 @@ type SearchBoxProps = {
|
||||
supportAddCustomTool?: boolean
|
||||
usedInMarketplace?: boolean
|
||||
onShowAddCustomCollectionModal?: () => void
|
||||
onAddedCustomTool?: () => void
|
||||
autoFocus?: boolean
|
||||
showTags?: boolean
|
||||
}
|
||||
function SearchBox({
|
||||
const SearchBox = ({
|
||||
ref,
|
||||
search,
|
||||
onSearchChange,
|
||||
@@ -40,17 +40,7 @@ function SearchBox({
|
||||
onShowAddCustomCollectionModal,
|
||||
autoFocus = false,
|
||||
showTags = true,
|
||||
}: 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()
|
||||
}
|
||||
|
||||
}: SearchBoxProps) => {
|
||||
return (
|
||||
<div className={cn('z-11 flex items-center', wrapperClassName)}>
|
||||
<div
|
||||
@@ -73,13 +63,10 @@ function SearchBox({
|
||||
)}
|
||||
<div className="flex grow items-center gap-x-2 p-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
name="query"
|
||||
autoComplete="off"
|
||||
aria-label={accessibleLabel}
|
||||
ref={ref}
|
||||
aria-label={placeholder || undefined}
|
||||
className={cn(
|
||||
'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',
|
||||
'inline-block grow appearance-none bg-transparent body-md-medium text-text-secondary outline-hidden',
|
||||
inputElementClassName,
|
||||
)}
|
||||
value={search}
|
||||
@@ -89,18 +76,9 @@ function SearchBox({
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{search && (
|
||||
<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>
|
||||
<ActionButton onClick={() => onSearchChange('')} className="shrink-0">
|
||||
<RiCloseLine className="size-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -108,24 +86,16 @@ function SearchBox({
|
||||
{!usedInMarketplace && (
|
||||
<>
|
||||
<div className="flex h-8 min-w-0 grow items-center pr-2 pl-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-search-line',
|
||||
'size-4 text-components-input-text-placeholder',
|
||||
searchIconClassName,
|
||||
)}
|
||||
<RiSearchLine
|
||||
className={cn('size-4 text-components-input-text-placeholder', searchIconClassName)}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
name="query"
|
||||
autoComplete="off"
|
||||
aria-label={accessibleLabel}
|
||||
ref={ref}
|
||||
aria-label={placeholder || undefined}
|
||||
// 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 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
|
||||
'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',
|
||||
search && 'mr-2',
|
||||
inputElementClassName,
|
||||
)}
|
||||
@@ -136,18 +106,9 @@ function SearchBox({
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{search && (
|
||||
<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>
|
||||
<ActionButton size="xs" onClick={() => onSearchChange('')} className="shrink-0">
|
||||
<RiCloseLine className="size-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
{showTags && (
|
||||
@@ -161,15 +122,12 @@ function SearchBox({
|
||||
</div>
|
||||
{supportAddCustomTool && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<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"
|
||||
<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"
|
||||
onClick={onShowAddCustomCollectionModal}
|
||||
>
|
||||
<span className="i-ri-add-line size-4" aria-hidden />
|
||||
</Button>
|
||||
<RiAddLine className="size-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
|
||||
import { Popover, PopoverContent, PopoverTrigger } 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
|
||||
}
|
||||
function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) {
|
||||
const TagsFilter = ({ tags, onTagsChange, usedInMarketplace = false }: TagsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
@@ -27,24 +27,31 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{usedInMarketplace && (
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)}
|
||||
{!usedInMarketplace && (
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
@@ -53,22 +60,12 @@ function 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">
|
||||
<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>
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder={t(($) => $.searchTags, { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
<CheckboxGroup
|
||||
aria-label={t(($) => $.allTags, { ns: 'pluginTags' })}
|
||||
|
||||
+32
-54
@@ -1,8 +1,5 @@
|
||||
import type { Tag } from '../../../../hooks'
|
||||
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 { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MarketplaceTrigger from '../marketplace'
|
||||
|
||||
@@ -14,70 +11,51 @@ const tagsMap: Record<string, Tag> = {
|
||||
|
||||
describe('MarketplaceTrigger', () => {
|
||||
it('shows all-tags text when no tags are selected', () => {
|
||||
render(
|
||||
<Popover>
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={0}
|
||||
open={false}
|
||||
tags={[]}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
const { container } = render(
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={0}
|
||||
open={false}
|
||||
tags={[]}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
|
||||
expect(container.querySelectorAll('svg').length).toBe(2)
|
||||
})
|
||||
|
||||
it('shows selected tag labels and overflow count', () => {
|
||||
render(
|
||||
<Popover>
|
||||
<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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
it('clears selected tags when clear icon is clicked', () => {
|
||||
const onTagsChange = vi.fn()
|
||||
|
||||
render(<Harness />)
|
||||
const { container } = render(
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={1}
|
||||
open={false}
|
||||
tags={['agent']}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Agent' })
|
||||
const clearButton = screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ })
|
||||
expect(trigger).not.toContainElement(clearButton)
|
||||
fireEvent.click(container.querySelectorAll('svg')[1]!)
|
||||
|
||||
await user.click(clearButton)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus()
|
||||
expect(onTagsChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
+31
-86
@@ -1,8 +1,5 @@
|
||||
import type { Tag } from '../../../../hooks'
|
||||
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 { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ToolSelectorTrigger from '../tool-selector'
|
||||
|
||||
@@ -14,103 +11,51 @@ const tagsMap: Record<string, Tag> = {
|
||||
|
||||
describe('ToolSelectorTrigger', () => {
|
||||
it('renders only icon when no tags are selected', () => {
|
||||
render(
|
||||
<Popover>
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={0}
|
||||
open={false}
|
||||
tags={[]}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
const { container } = render(
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={0}
|
||||
open={false}
|
||||
tags={[]}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('svg')).toHaveLength(1)
|
||||
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders selected tag labels and overflow count', () => {
|
||||
render(
|
||||
<Popover>
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={3}
|
||||
open
|
||||
tags={['agent', 'rag', 'search']}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
const { container } = render(
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={3}
|
||||
open
|
||||
tags={['agent', 'rag', 'search']}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Agent, RAG, Search' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('svg')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens the tag filter from the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('clears selected tags when clear icon is clicked', () => {
|
||||
const onTagsChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Popover>
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={0}
|
||||
open={false}
|
||||
tags={[]}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={vi.fn()}
|
||||
/>
|
||||
<PopoverContent>Tag options</PopoverContent>
|
||||
</Popover>,
|
||||
const { container } = render(
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={1}
|
||||
open={false}
|
||||
tags={['agent']}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'pluginTags.allTags' })
|
||||
await user.tab()
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(container.querySelectorAll('svg')[1]!)
|
||||
|
||||
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()
|
||||
expect(onTagsChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Tag } from '../../../hooks'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
|
||||
type MarketplaceTriggerProps = {
|
||||
@@ -13,100 +12,54 @@ type MarketplaceTriggerProps = {
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
function MarketplaceTrigger({
|
||||
const 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="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>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
{!!selectedTagsLength && (
|
||||
<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>
|
||||
<RiCloseCircleFill
|
||||
className="size-4 text-text-quaternary"
|
||||
onClick={() => onTagsChange([])}
|
||||
/>
|
||||
)}
|
||||
{!selectedTagsLength && (
|
||||
<div className="p-0.5">
|
||||
<RiArrowDownSLine className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MarketplaceTrigger)
|
||||
export default React.memo(MarketplaceTrigger)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Tag } from '../../../hooks'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type ToolSelectorTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
@@ -13,92 +11,50 @@ type ToolSelectorTriggerProps = {
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
function ToolSelectorTrigger({
|
||||
const ToolSelectorTrigger = ({
|
||||
selectedTagsLength,
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
onTagsChange,
|
||||
}: 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])
|
||||
|
||||
}: ToolSelectorTriggerProps) => {
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
{!!selectedTagsLength && (
|
||||
<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
|
||||
<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()
|
||||
onTagsChange([])
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" />
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ToolSelectorTrigger)
|
||||
export default React.memo(ToolSelectorTrigger)
|
||||
|
||||
@@ -60,16 +60,6 @@ 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>,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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.
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
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
Reference in New Issue
Block a user