Compare commits

..

5 Commits

150 changed files with 4909 additions and 11639 deletions

View File

@ -26,20 +26,13 @@ import userEvent from '@testing-library/user-event'
// WHY: Mocks must be hoisted to top of file (Jest requirement).
// They run BEFORE imports, so keep them before component imports.
// i18n (automatically mocked)
// WHY: Shared mock at web/__mocks__/react-i18next.ts is auto-loaded by Jest
// No explicit mock needed - it returns translation keys as-is
// Override only if custom translations are required:
// jest.mock('react-i18next', () => ({
// useTranslation: () => ({
// t: (key: string) => {
// const customTranslations: Record<string, string> = {
// 'my.custom.key': 'Custom Translation',
// }
// return customTranslations[key] || key
// },
// }),
// }))
// i18n (always required in Dify)
// WHY: Returns key instead of translation so tests don't depend on i18n files
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Router (if component uses useRouter, usePathname, useSearchParams)
// WHY: Isolates tests from Next.js routing, enables testing navigation behavior

View File

@ -93,12 +93,4 @@ jobs:
# Create a detailed coverage summary
echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY
echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY
{
echo ""
echo "<details><summary>File-level coverage (click to expand)</summary>"
echo ""
echo '```'
uv run --project api coverage report -m
echo '```'
echo "</details>"
} >> $GITHUB_STEP_SUMMARY
uv run --project api coverage report --format=markdown >> $GITHUB_STEP_SUMMARY

View File

@ -670,4 +670,4 @@ ANNOTATION_IMPORT_MIN_RECORDS=1
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
# Maximum number of concurrent annotation import tasks per tenant
ANNOTATION_IMPORT_MAX_CONCURRENT=5
ANNOTATION_IMPORT_MAX_CONCURRENT=5

View File

@ -54,7 +54,6 @@ from .app import (
completion,
conversation,
conversation_variables,
enduser_auth,
generator,
mcp_server,
message,
@ -163,7 +162,6 @@ __all__ = [
"datasource_content_preview",
"email_register",
"endpoint",
"enduser_auth",
"extension",
"external",
"feature",

View File

@ -1,230 +0,0 @@
"""
End-user authentication API controllers.
Provides API endpoints for managing end-user credentials for tool authentication.
"""
from flask import request
from flask_restx import Resource
from werkzeug.exceptions import BadRequest
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from core.model_runtime.utils.encoders import jsonable_encoder
from libs.login import current_account_with_tenant, login_required
from models import AppMode
from services.tools.app_auth_requirement_service import AppAuthRequirementService
from services.tools.enduser_auth_service import EndUserAuthService
from services.tools.enduser_oauth_service import EndUserOAuthService
@console_ns.route("/apps/<uuid:app_id>/auth/providers")
class AppAuthProvidersApi(Resource):
"""
Get list of authentication providers required for an app.
Returns providers that require end-user authentication based on app configuration.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model):
"""Get authentication providers required for the app."""
_, tenant_id = current_account_with_tenant()
providers = AppAuthRequirementService.get_required_providers(
tenant_id=tenant_id,
app_id=str(app_model.id),
)
return jsonable_encoder(providers)
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials")
class AppAuthProviderCredentialsApi(Resource):
"""
Manage end-user credentials for a specific provider.
Allows listing, creating, and deleting end-user credentials.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model, provider_id: str):
"""List end-user's credentials for this provider."""
user, tenant_id = current_account_with_tenant()
# For console API, use the current account user as end_user_id
# In production, this would be the actual end-user ID from the chat/completion request
end_user_id = str(user.id)
credentials = EndUserAuthService.list_credentials(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider_id=provider_id,
)
return jsonable_encoder(credentials)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def post(self, app_model, provider_id: str):
"""Create a new credential (API key only)."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
payload = request.get_json()
if not payload:
raise BadRequest("Request body is required")
credential_type = payload.get("credential_type")
credentials = payload.get("credentials")
if not credential_type or not credentials:
raise BadRequest("credential_type and credentials are required")
if credential_type != "api-key":
raise BadRequest(
"Only 'api-key' credential type can be created via this endpoint. "
"Use OAuth flow for OAuth credentials."
)
credential = EndUserAuthService.create_api_key_credential(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider_id=provider_id,
credentials=credentials,
)
return jsonable_encoder(credential)
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials/<string:credential_id>")
class AppAuthProviderCredentialApi(Resource):
"""
Manage a specific end-user credential.
Allows getting, updating, or deleting a credential.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def delete(self, app_model, provider_id: str, credential_id: str):
"""Delete a credential."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
EndUserAuthService.delete_credential(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider_id=provider_id,
credential_id=credential_id,
)
return {"result": "success"}
@console_ns.route("/apps/<uuid:app_id>/auth/oauth/<path:provider_id>/authorization-url")
class AppAuthOAuthAuthorizationUrlApi(Resource):
"""
Get OAuth authorization URL for end-user authentication.
Returns the URL where the user should be redirected to authenticate with the provider.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model, provider_id: str):
"""Get OAuth authorization URL."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
result = EndUserOAuthService.get_authorization_url(
end_user_id=end_user_id,
tenant_id=tenant_id,
app_id=str(app_model.id),
provider=provider_id,
)
# Set OAuth context cookie for callback
response = jsonable_encoder({
"authorization_url": result["authorization_url"],
})
# Store context_id in response for frontend to set as cookie
response["context_id"] = result["context_id"]
return response
@console_ns.route("/apps/<uuid:app_id>/auth/oauth/<path:provider_id>/callback")
class AppAuthOAuthCallbackApi(Resource):
"""
Handle OAuth callback for end-user authentication.
This endpoint is called by the OAuth provider after user authorization.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def get(self, app_model, provider_id: str):
"""Handle OAuth callback and store credentials."""
# Get OAuth context ID from cookie
context_id = request.cookies.get("oauth_context_id")
if not context_id:
raise BadRequest("Missing OAuth context")
# Get OAuth error if any
error = request.args.get("error")
error_description = request.args.get("error_description")
if error:
raise BadRequest(f"OAuth error: {error} - {error_description}")
# Handle callback and create credential
result = EndUserOAuthService.handle_oauth_callback(
context_id=context_id,
request=request,
)
return jsonable_encoder(result)
@console_ns.route("/apps/<uuid:app_id>/auth/providers/<path:provider_id>/credentials/<string:credential_id>/refresh")
class AppAuthProviderRefreshApi(Resource):
"""
Manually refresh OAuth token for a credential.
This endpoint allows refreshing an expired OAuth token.
"""
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW])
def post(self, app_model, provider_id: str, credential_id: str):
"""Refresh OAuth token."""
user, tenant_id = current_account_with_tenant()
end_user_id = str(user.id)
result = EndUserOAuthService.refresh_oauth_token(
credential_id=credential_id,
end_user_id=end_user_id,
tenant_id=tenant_id,
)
return jsonable_encoder(result)

View File

@ -22,12 +22,7 @@ from controllers.console.error import (
NotAllowedCreateWorkspace,
WorkspacesLimitExceeded,
)
from controllers.console.wraps import (
decrypt_code_field,
decrypt_password_field,
email_password_login_enabled,
setup_required,
)
from controllers.console.wraps import email_password_login_enabled, setup_required
from events.tenant_event import tenant_was_created
from libs.helper import EmailStr, extract_remote_ip
from libs.login import current_account_with_tenant
@ -84,7 +79,6 @@ class LoginApi(Resource):
@setup_required
@email_password_login_enabled
@console_ns.expect(console_ns.models[LoginPayload.__name__])
@decrypt_password_field
def post(self):
"""Authenticate user and login."""
args = LoginPayload.model_validate(console_ns.payload)
@ -224,7 +218,6 @@ class EmailCodeLoginSendEmailApi(Resource):
class EmailCodeLoginApi(Resource):
@setup_required
@console_ns.expect(console_ns.models[EmailCodeLoginPayload.__name__])
@decrypt_code_field
def post(self):
args = EmailCodeLoginPayload.model_validate(console_ns.payload)

View File

@ -140,18 +140,6 @@ class DataSourceNotionListApi(Resource):
credential_id = request.args.get("credential_id", default=None, type=str)
if not credential_id:
raise ValueError("Credential id is required.")
# Get datasource_parameters from query string (optional, for GitHub and other datasources)
datasource_parameters_str = request.args.get("datasource_parameters", default=None, type=str)
datasource_parameters = {}
if datasource_parameters_str:
try:
datasource_parameters = json.loads(datasource_parameters_str)
if not isinstance(datasource_parameters, dict):
raise ValueError("datasource_parameters must be a JSON object.")
except json.JSONDecodeError:
raise ValueError("Invalid datasource_parameters JSON format.")
datasource_provider_service = DatasourceProviderService()
credential = datasource_provider_service.get_datasource_credentials(
tenant_id=current_tenant_id,
@ -199,7 +187,7 @@ class DataSourceNotionListApi(Resource):
online_document_result: Generator[OnlineDocumentPagesMessage, None, None] = (
datasource_runtime.get_online_document_pages(
user_id=current_user.id,
datasource_parameters=datasource_parameters,
datasource_parameters={},
provider_type=datasource_runtime.datasource_provider_type(),
)
)
@ -230,14 +218,14 @@ class DataSourceNotionListApi(Resource):
@console_ns.route(
"/notion/pages/<uuid:page_id>/<string:page_type>/preview",
"/notion/workspaces/<uuid:workspace_id>/pages/<uuid:page_id>/<string:page_type>/preview",
"/datasets/notion-indexing-estimate",
)
class DataSourceNotionApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, page_id, page_type):
def get(self, workspace_id, page_id, page_type):
_, current_tenant_id = current_account_with_tenant()
credential_id = request.args.get("credential_id", default=None, type=str)
@ -251,10 +239,11 @@ class DataSourceNotionApi(Resource):
plugin_id="langgenius/notion_datasource",
)
workspace_id = str(workspace_id)
page_id = str(page_id)
extractor = NotionExtractor(
notion_workspace_id="",
notion_workspace_id=workspace_id,
notion_obj_id=page_id,
notion_page_type=page_type,
notion_access_token=credential.get("integration_secret"),

View File

@ -4,7 +4,7 @@ from typing import Any, Literal, cast
from uuid import UUID
from flask import abort, request
from flask_restx import Resource, marshal_with, reqparse # type: ignore
from flask_restx import Resource, marshal_with # type: ignore
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
@ -975,11 +975,6 @@ class RagPipelineRecommendedPluginApi(Resource):
@login_required
@account_initialization_required
def get(self):
parser = reqparse.RequestParser()
parser.add_argument("type", type=str, location="args", required=False, default="all")
args = parser.parse_args()
type = args["type"]
rag_pipeline_service = RagPipelineService()
recommended_plugins = rag_pipeline_service.get_recommended_plugins(type)
recommended_plugins = rag_pipeline_service.get_recommended_plugins()
return recommended_plugins

View File

@ -9,12 +9,10 @@ from typing import ParamSpec, TypeVar
from flask import abort, request
from configs import dify_config
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
from controllers.console.workspace.error import AccountNotInitializedError
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.encryption import FieldEncryption
from libs.login import current_account_with_tenant
from models.account import AccountStatus
from models.dataset import RateLimitLog
@ -27,14 +25,6 @@ from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogo
P = ParamSpec("P")
R = TypeVar("R")
# Field names for decryption
FIELD_NAME_PASSWORD = "password"
FIELD_NAME_CODE = "code"
# Error messages for decryption failures
ERROR_MSG_INVALID_ENCRYPTED_DATA = "Invalid encrypted data"
ERROR_MSG_INVALID_ENCRYPTED_CODE = "Invalid encrypted code"
def account_initialization_required(view: Callable[P, R]):
@wraps(view)
@ -429,75 +419,3 @@ def annotation_import_concurrency_limit(view: Callable[P, R]):
return view(*args, **kwargs)
return decorated
def _decrypt_field(field_name: str, error_class: type[Exception], error_message: str) -> None:
"""
Helper to decode a Base64 encoded field in the request payload.
Args:
field_name: Name of the field to decode
error_class: Exception class to raise on decoding failure
error_message: Error message to include in the exception
"""
if not request or not request.is_json:
return
# Get the payload dict - it's cached and mutable
payload = request.get_json()
if not payload or field_name not in payload:
return
encoded_value = payload[field_name]
decoded_value = FieldEncryption.decrypt_field(encoded_value)
# If decoding failed, raise error immediately
if decoded_value is None:
raise error_class(error_message)
# Update payload dict in-place with decoded value
# Since payload is a mutable dict and get_json() returns the cached reference,
# modifying it will affect all subsequent accesses including console_ns.payload
payload[field_name] = decoded_value
def decrypt_password_field(view: Callable[P, R]):
"""
Decorator to decrypt password field in request payload.
Automatically decrypts the 'password' field if encryption is enabled.
If decryption fails, raises AuthenticationFailedError.
Usage:
@decrypt_password_field
def post(self):
args = LoginPayload.model_validate(console_ns.payload)
# args.password is now decrypted
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_decrypt_field(FIELD_NAME_PASSWORD, AuthenticationFailedError, ERROR_MSG_INVALID_ENCRYPTED_DATA)
return view(*args, **kwargs)
return decorated
def decrypt_code_field(view: Callable[P, R]):
"""
Decorator to decrypt verification code field in request payload.
Automatically decrypts the 'code' field if encryption is enabled.
If decryption fails, raises EmailCodeError.
Usage:
@decrypt_code_field
def post(self):
args = EmailCodeLoginPayload.model_validate(console_ns.payload)
# args.code is now decrypted
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_decrypt_field(FIELD_NAME_CODE, EmailCodeError, ERROR_MSG_INVALID_ENCRYPTED_CODE)
return view(*args, **kwargs)
return decorated

View File

@ -3,7 +3,7 @@ from typing import Any, Union
from pydantic import BaseModel, Field
from core.tools.entities.tool_entities import ToolAuthType, ToolInvokeMessage, ToolProviderType
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType
class AgentToolEntity(BaseModel):
@ -17,7 +17,6 @@ class AgentToolEntity(BaseModel):
tool_parameters: dict[str, Any] = Field(default_factory=dict)
plugin_unique_identifier: str | None = None
credential_id: str | None = None
auth_type: ToolAuthType = ToolAuthType.WORKSPACE
class AgentPromptEntity(BaseModel):

View File

@ -10,7 +10,7 @@ class NotionInfo(BaseModel):
"""
credential_id: str | None = None
notion_workspace_id: str | None = ""
notion_workspace_id: str
notion_obj_id: str
notion_page_type: str
document: Document | None = None

View File

@ -166,7 +166,7 @@ class ExtractProcessor:
elif extract_setting.datasource_type == DatasourceType.NOTION:
assert extract_setting.notion_info is not None, "notion_info is required"
extractor = NotionExtractor(
notion_workspace_id=extract_setting.notion_info.notion_workspace_id or "",
notion_workspace_id=extract_setting.notion_info.notion_workspace_id,
notion_obj_id=extract_setting.notion_info.notion_obj_id,
notion_page_type=extract_setting.notion_info.notion_page_type,
document_model=extract_setting.notion_info.document,

View File

@ -124,7 +124,6 @@ class ToolProviderCredentialApiEntity(BaseModel):
default=False, description="Whether the credential is the default credential for the provider in the workspace"
)
credentials: Mapping[str, object] = Field(description="The credentials of the provider", default_factory=dict)
expires_at: int = Field(default=-1, description="Unix timestamp when credential expires (-1 for no expiry)")
class ToolProviderCredentialInfoApiEntity(BaseModel):

View File

@ -123,16 +123,6 @@ class ApiProviderAuthType(StrEnum):
raise ValueError(f"invalid mode value '{value}', expected one of: {valid}")
class ToolAuthType(StrEnum):
"""
Enum class for tool authentication type.
Determines whether OAuth credentials are workspace-level or end-user-level.
"""
WORKSPACE = "workspace"
END_USER = "end_user"
class ToolInvokeMessage(BaseModel):
class TextMessage(BaseModel):
text: str

View File

@ -49,7 +49,6 @@ from core.tools.entities.api_entities import ToolProviderApiEntity, ToolProvider
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import (
ApiProviderAuthType,
ToolAuthType,
ToolInvokeFrom,
ToolParameter,
ToolProviderType,
@ -59,7 +58,7 @@ from core.tools.tool_label_manager import ToolLabelManager
from core.tools.utils.configuration import ToolParameterConfigurationManager
from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter
from core.tools.workflow_as_tool.tool import WorkflowTool
from models.tools import ApiToolProvider, BuiltinToolProvider, EndUserAuthenticationProvider, WorkflowToolProvider
from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider
from services.tools.tools_transform_service import ToolTransformService
if TYPE_CHECKING:
@ -79,54 +78,6 @@ class ToolManager:
_builtin_providers_loaded = False
_builtin_tools_labels: dict[str, Union[I18nObject, None]] = {}
@classmethod
def _refresh_oauth_credentials(
cls,
tenant_id: str,
provider_id: str,
user_id: str,
decrypted_credentials: Mapping[str, Any],
) -> tuple[dict[str, Any], int]:
"""
Refresh OAuth credentials for a provider.
This is a helper method to centralize the OAuth token refresh logic
used by both end-user and workspace authentication flows.
:param tenant_id: the tenant id
:param provider_id: the provider id
:param user_id: the user id (end_user_id or workspace user_id)
:param decrypted_credentials: the current decrypted credentials
:return: tuple of (refreshed credentials dict, expires_at timestamp)
"""
from core.plugin.impl.oauth import OAuthHandler
# Local import to avoid circular dependency at module level
# This import is necessary but creates a cycle: tool_manager -> builtin_tools_manage_service -> tool_manager
# TODO: Break the circular dependency by refactoring service layer
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
# Parse provider ID and build OAuth configuration
tool_provider = ToolProviderID(provider_id)
provider_name = tool_provider.provider_name
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback"
system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id)
# Refresh the credentials using OAuth handler
oauth_handler = OAuthHandler()
refreshed_credentials = oauth_handler.refresh_credentials(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=tool_provider.plugin_id,
provider=provider_name,
redirect_uri=redirect_uri,
system_credentials=system_credentials or {},
credentials=decrypted_credentials,
)
return refreshed_credentials.credentials, refreshed_credentials.expires_at
@classmethod
def get_hardcoded_provider(cls, provider: str) -> BuiltinToolProviderController:
"""
@ -214,8 +165,6 @@ class ToolManager:
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
tool_invoke_from: ToolInvokeFrom = ToolInvokeFrom.AGENT,
credential_id: str | None = None,
auth_type: ToolAuthType = ToolAuthType.WORKSPACE,
end_user_id: str | None = None,
) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool, MCPTool]:
"""
get the tool runtime
@ -227,8 +176,6 @@ class ToolManager:
:param invoke_from: invoke from
:param tool_invoke_from: the tool invoke from
:param credential_id: the credential id
:param auth_type: the authentication type (workspace or end_user)
:param end_user_id: the end user id (required when auth_type is END_USER)
:return: the tool
"""
@ -253,75 +200,6 @@ class ToolManager:
)
),
)
# Handle end-user authentication
if auth_type == ToolAuthType.END_USER:
if not end_user_id:
raise ToolProviderNotFoundError("end_user_id is required for END_USER auth_type")
# Query end-user credentials
enduser_provider = (
db.session.query(EndUserAuthenticationProvider)
.where(
EndUserAuthenticationProvider.tenant_id == tenant_id,
EndUserAuthenticationProvider.end_user_id == end_user_id,
EndUserAuthenticationProvider.provider == provider_id,
)
.order_by(EndUserAuthenticationProvider.created_at.asc())
.first()
)
if enduser_provider is None:
raise ToolProviderNotFoundError(
f"No end-user credentials found for provider {provider_id}"
)
# Decrypt end-user credentials
encrypter, cache = create_provider_encrypter(
tenant_id=tenant_id,
config=[
x.to_basic_provider_config()
for x in provider_controller.get_credentials_schema_by_type(enduser_provider.credential_type)
],
cache=ToolProviderCredentialsCache(
tenant_id=tenant_id, provider=provider_id, credential_id=enduser_provider.id
),
)
decrypted_credentials: Mapping[str, Any] = encrypter.decrypt(enduser_provider.credentials)
# Handle OAuth token refresh for end-users if expired
if enduser_provider.expires_at != -1 and (enduser_provider.expires_at - 60) < int(time.time()):
# Refresh credentials using the centralized helper method
refreshed_credentials, expires_at = cls._refresh_oauth_credentials(
tenant_id=tenant_id,
provider_id=provider_id,
user_id=end_user_id,
decrypted_credentials=decrypted_credentials,
)
# Update the provider with refreshed credentials
enduser_provider.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
enduser_provider.expires_at = expires_at
db.session.commit()
decrypted_credentials = refreshed_credentials
cache.delete()
return cast(
BuiltinTool,
builtin_tool.fork_tool_runtime(
runtime=ToolRuntime(
tenant_id=tenant_id,
credentials=dict(decrypted_credentials),
credential_type=CredentialType.of(enduser_provider.credential_type),
runtime_parameters={},
invoke_from=invoke_from,
tool_invoke_from=tool_invoke_from,
)
),
)
# Handle workspace authentication (existing logic)
builtin_provider = None
if isinstance(provider_controller, PluginToolProviderController):
provider_id_entity = ToolProviderID(provider_id)
@ -392,19 +270,34 @@ class ToolManager:
# check if the credentials is expired
if builtin_provider.expires_at != -1 and (builtin_provider.expires_at - 60) < int(time.time()):
# Refresh credentials using the centralized helper method
refreshed_credentials, expires_at = cls._refresh_oauth_credentials(
tenant_id=tenant_id,
provider_id=provider_id,
user_id=builtin_provider.user_id,
decrypted_credentials=decrypted_credentials,
)
# TODO: circular import
from core.plugin.impl.oauth import OAuthHandler
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
# Update the provider with refreshed credentials
builtin_provider.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
builtin_provider.expires_at = expires_at
# refresh the credentials
tool_provider = ToolProviderID(provider_id)
provider_name = tool_provider.provider_name
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_id}/tool/callback"
system_credentials = BuiltinToolManageService.get_oauth_client(tenant_id, provider_id)
oauth_handler = OAuthHandler()
# refresh the credentials
refreshed_credentials = oauth_handler.refresh_credentials(
tenant_id=tenant_id,
user_id=builtin_provider.user_id,
plugin_id=tool_provider.plugin_id,
provider=provider_name,
redirect_uri=redirect_uri,
system_credentials=system_credentials or {},
credentials=decrypted_credentials,
)
# update the credentials
builtin_provider.encrypted_credentials = json.dumps(
encrypter.encrypt(refreshed_credentials.credentials)
)
builtin_provider.expires_at = refreshed_credentials.expires_at
db.session.commit()
decrypted_credentials = refreshed_credentials
decrypted_credentials = refreshed_credentials.credentials
cache.delete()
return cast(
@ -475,7 +368,6 @@ class ToolManager:
agent_tool: AgentToolEntity,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Optional["VariablePool"] = None,
end_user_id: str | None = None,
) -> Tool:
"""
get the agent tool runtime
@ -488,8 +380,6 @@ class ToolManager:
invoke_from=invoke_from,
tool_invoke_from=ToolInvokeFrom.AGENT,
credential_id=agent_tool.credential_id,
auth_type=agent_tool.auth_type,
end_user_id=end_user_id,
)
runtime_parameters = {}
parameters = tool_entity.get_merged_runtime_parameters()
@ -520,7 +410,6 @@ class ToolManager:
workflow_tool: "ToolEntity",
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
variable_pool: Optional["VariablePool"] = None,
end_user_id: str | None = None,
) -> Tool:
"""
get the workflow tool runtime
@ -534,8 +423,6 @@ class ToolManager:
invoke_from=invoke_from,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
credential_id=workflow_tool.credential_id,
auth_type=workflow_tool.auth_type,
end_user_id=end_user_id,
)
parameters = tool_runtime.get_merged_runtime_parameters()

View File

@ -3,7 +3,7 @@ from typing import Any, Literal, Union
from pydantic import BaseModel, field_validator
from pydantic_core.core_schema import ValidationInfo
from core.tools.entities.tool_entities import ToolAuthType, ToolProviderType
from core.tools.entities.tool_entities import ToolProviderType
from core.workflow.nodes.base.entities import BaseNodeData
@ -16,7 +16,6 @@ class ToolEntity(BaseModel):
tool_configurations: dict[str, Any]
credential_id: str | None = None
plugin_unique_identifier: str | None = None # redundancy
auth_type: ToolAuthType = ToolAuthType.WORKSPACE # OAuth authentication level
@field_validator("tool_configurations", mode="before")
@classmethod

View File

@ -66,7 +66,6 @@ class ToolNode(Node[ToolNodeData]):
# get tool runtime
try:
from core.tools.tool_manager import ToolManager
from models.enums import UserFrom
# This is an issue that caused problems before.
# Logically, we shouldn't use the node_data.version field for judgment
@ -75,20 +74,8 @@ class ToolNode(Node[ToolNodeData]):
variable_pool: VariablePool | None = None
if self.node_data.version != "1" or self.node_data.tool_node_version is not None:
variable_pool = self.graph_runtime_state.variable_pool
# Determine end_user_id based on user_from
end_user_id = None
if self.user_from == UserFrom.END_USER:
end_user_id = self.user_id
tool_runtime = ToolManager.get_workflow_tool_runtime(
self.tenant_id,
self.app_id,
self._node_id,
self.node_data,
self.invoke_from,
variable_pool,
end_user_id,
self.tenant_id, self.app_id, self._node_id, self.node_data, self.invoke_from, variable_pool
)
except ToolNodeError as e:
yield StreamCompletedEvent(

View File

@ -1,66 +0,0 @@
"""
Field Encoding/Decoding Utilities
Provides Base64 decoding for sensitive fields (password, verification code)
received from the frontend.
Note: This uses Base64 encoding for obfuscation, not cryptographic encryption.
Real security relies on HTTPS for transport layer encryption.
"""
import base64
import logging
logger = logging.getLogger(__name__)
class FieldEncryption:
"""Handle decoding of sensitive fields during transmission"""
@classmethod
def decrypt_field(cls, encoded_text: str) -> str | None:
"""
Decode Base64 encoded field from frontend.
Args:
encoded_text: Base64 encoded text from frontend
Returns:
Decoded plaintext, or None if decoding fails
"""
try:
# Decode base64
decoded_bytes = base64.b64decode(encoded_text)
decoded_text = decoded_bytes.decode("utf-8")
logger.debug("Field decoding successful")
return decoded_text
except Exception:
# Decoding failed - return None to trigger error in caller
return None
@classmethod
def decrypt_password(cls, encrypted_password: str) -> str | None:
"""
Decrypt password field
Args:
encrypted_password: Encrypted password from frontend
Returns:
Decrypted password or None if decryption fails
"""
return cls.decrypt_field(encrypted_password)
@classmethod
def decrypt_verification_code(cls, encrypted_code: str) -> str | None:
"""
Decrypt verification code field
Args:
encrypted_code: Encrypted code from frontend
Returns:
Decrypted code or None if decryption fails
"""
return cls.decrypt_field(encrypted_code)

View File

@ -1,71 +0,0 @@
"""add enduser authentication provider
Revision ID: a7b4e8f2c9d1
Revises: fecff1c3da27
Create Date: 2025-11-18 14:00:00.000000
"""
import models as models
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a7b4e8f2c9d1"
down_revision = "fecff1c3da27"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"tool_enduser_authentication_providers",
sa.Column(
"id",
models.types.StringUUID(),
nullable=False,
),
sa.Column(
"name",
sa.String(length=256),
server_default="API KEY 1",
nullable=False,
),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("end_user_id", models.types.StringUUID(), nullable=False),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("encrypted_credentials", sa.Text(), default="", nullable=False),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.current_timestamp(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(),
server_default=sa.func.current_timestamp(),
nullable=False,
),
sa.Column(
"credential_type",
sa.String(length=32),
server_default="api-key",
nullable=False,
),
sa.Column("expires_at", sa.BigInteger(), server_default=sa.text("-1"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"end_user_id",
"provider",
),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("tool_enduser_authentication_providers")
# ### end Alembic commands ###

View File

@ -1,25 +0,0 @@
"""merge_heads
Revision ID: 3134f4e0620d
Revises: d57accd375ae, a7b4e8f2c9d1
Create Date: 2025-12-14 14:18:19.393720
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3134f4e0620d'
down_revision = ('d57accd375ae', 'a7b4e8f2c9d1')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@ -1,31 +0,0 @@
"""add type column not null default tool
Revision ID: 03ea244985ce
Revises: d57accd375ae
Create Date: 2025-12-16 18:17:12.193877
"""
from alembic import op
import models as models
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '03ea244985ce'
down_revision = 'd57accd375ae'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
batch_op.add_column(sa.Column('type', sa.String(length=50), server_default=sa.text("'tool'"), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pipeline_recommended_plugins', schema=None) as batch_op:
batch_op.drop_column('type')
# ### end Alembic commands ###

View File

@ -80,7 +80,6 @@ from .task import CeleryTask, CeleryTaskSet
from .tools import (
ApiToolProvider,
BuiltinToolProvider,
EndUserAuthenticationProvider,
ToolConversationVariables,
ToolFile,
ToolLabelBinding,
@ -150,7 +149,6 @@ __all__ = [
"DocumentSegment",
"Embedding",
"EndUser",
"EndUserAuthenticationProvider",
"ExternalKnowledgeApis",
"ExternalKnowledgeBindings",
"IconType",

View File

@ -1532,7 +1532,6 @@ class PipelineRecommendedPlugin(TypeBase):
)
plugin_id: Mapped[str] = mapped_column(LongText, nullable=False)
provider_name: Mapped[str] = mapped_column(LongText, nullable=False)
type: Mapped[str] = mapped_column(sa.String(50), nullable=False, server_default=sa.text("'tool'"))
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(

View File

@ -9,11 +9,9 @@ from deprecated import deprecated
from sqlalchemy import ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from core.plugin.entities.plugin_daemon import CredentialType
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_bundle import ApiToolBundle
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
from libs.uuid_utils import uuidv7
from .base import TypeBase
from .engine import db
@ -117,59 +115,6 @@ class BuiltinToolProvider(TypeBase):
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
class EndUserAuthenticationProvider(TypeBase):
"""
This table stores the authentication credentials for end users in tools.
Mimics the BuiltinToolProvider structure but for end users instead of tenants.
"""
__tablename__ = "tool_enduser_authentication_providers"
__table_args__ = (
sa.UniqueConstraint("end_user_id", "provider"),
)
# id of the authentication provider
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7()), init=False)
# id of the tenant
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# id of the end user
end_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# name of the tool provider
provider: Mapped[str] = mapped_column(LongText, nullable=False)
name: Mapped[str] = mapped_column(
String(256),
nullable=False,
default="API KEY 1",
)
# encrypted credentials for the end user
encrypted_credentials: Mapped[str] = mapped_column(LongText, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
)
# credential type, e.g., "api-key", "oauth2"
credential_type: Mapped[CredentialType] = mapped_column(
String(32), nullable=False, default=CredentialType.API_KEY
)
# Unix timestamp in seconds since epoch (1970-01-01 UTC); -1 indicates no expiration
expires_at: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=-1)
@property
def credentials(self) -> dict[str, Any]:
if not self.encrypted_credentials:
return {}
try:
return cast(dict[str, Any], json.loads(self.encrypted_credentials))
except json.JSONDecodeError:
return {}
class ApiToolProvider(TypeBase):
"""
The table stores the api providers.

View File

@ -1248,13 +1248,14 @@ class RagPipelineService:
session.commit()
return workflow_node_execution_db_model
def get_recommended_plugins(self, type: str) -> dict:
def get_recommended_plugins(self) -> dict:
# Query active recommended plugins
query = db.session.query(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
if type and type != "all":
query = query.where(PipelineRecommendedPlugin.type == type)
pipeline_recommended_plugins = query.order_by(PipelineRecommendedPlugin.position.asc()).all()
pipeline_recommended_plugins = (
db.session.query(PipelineRecommendedPlugin)
.where(PipelineRecommendedPlugin.active == True)
.order_by(PipelineRecommendedPlugin.position.asc())
.all()
)
if not pipeline_recommended_plugins:
return {

View File

@ -1,227 +0,0 @@
import logging
from typing import Any
from sqlalchemy.orm import Session
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from models.workflow import Workflow
from services.tools.enduser_auth_service import EndUserAuthService
logger = logging.getLogger(__name__)
class AppAuthRequirementService:
"""
Service for analyzing authentication requirements in apps.
Examines workflow DSL to identify which providers need end-user authentication.
"""
@staticmethod
def get_tool_auth_requirements(
app_id: str,
tenant_id: str,
provider_type: str | None = None,
) -> list[dict[str, Any]]:
"""
Get all authentication requirements for tools in an app.
:param app_id: The application ID
:param tenant_id: The tenant ID
:param provider_type: Optional filter by provider type (e.g., "tool")
:return: List of provider requirements
"""
try:
# Get latest published workflow for the app
with Session(db.engine, autoflush=False) as session:
workflow = (
session.query(Workflow)
.filter_by(app_id=app_id, tenant_id=tenant_id)
.order_by(Workflow.created_at.desc())
.first()
)
if not workflow:
return []
# Parse workflow graph to find tool nodes
graph = workflow.graph_dict
if not graph or "nodes" not in graph:
return []
providers = []
seen_providers = set()
# Iterate through workflow nodes
for node in graph.get("nodes", []):
node_data = node.get("data", {})
node_type = node_data.get("type")
# Check if it's a tool node
if node_type == "tool":
provider_id = node_data.get("provider_id")
provider_name = node_data.get("provider_name")
tool_name = node_data.get("tool_name")
if not provider_id:
continue
# Avoid duplicates
if provider_id in seen_providers:
continue
seen_providers.add(provider_id)
# Get provider controller to check authentication requirements
try:
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
# Check if provider needs credentials
if not provider_controller.need_credentials:
continue
# Get supported credential types
supported_types = provider_controller.get_supported_credential_types()
# Determine required credential type (prefer OAuth if supported)
required_type = None
if supported_types:
# Prefer OAuth2, then API key
from core.plugin.entities.plugin_daemon import CredentialType
if CredentialType.OAUTH2 in supported_types:
required_type = "oauth2"
elif CredentialType.API_KEY in supported_types:
required_type = "api-key"
else:
required_type = supported_types[0].value
providers.append(
{
"provider_id": provider_id,
"provider_name": provider_name or provider_id,
"supported_credential_types": [ct.value for ct in supported_types],
"required_credential_type": required_type,
"provider_type": "tool",
"feature_context": {
"node_ids": [node.get("id")],
"tool_names": [tool_name] if tool_name else [],
},
}
)
except Exception as e:
logger.warning("Error getting provider info for %s: %s", provider_id, e)
continue
# Filter by provider_type if specified
if provider_type:
providers = [p for p in providers if p.get("provider_type") == provider_type]
return providers
except Exception:
logger.exception("Error getting auth requirements for app %s", app_id)
return []
@staticmethod
def get_required_providers(
tenant_id: str,
app_id: str,
) -> list[dict[str, Any]]:
"""
Get list of providers that require end-user authentication for an app.
Simplified version of get_tool_auth_requirements for API use.
:param tenant_id: The tenant ID
:param app_id: The application ID
:return: List of provider information dictionaries
"""
requirements = AppAuthRequirementService.get_tool_auth_requirements(
app_id=app_id,
tenant_id=tenant_id,
)
# Transform to simpler format for API response
return [
{
"provider_id": req["provider_id"],
"provider_name": req["provider_name"],
"credential_type": req["required_credential_type"],
"is_required": True,
"oauth_config": None if req["required_credential_type"] != "oauth2" else {
"supported_types": req["supported_credential_types"],
},
}
for req in requirements
]
@staticmethod
def get_auth_status(
app_id: str,
end_user_id: str,
tenant_id: str,
) -> dict[str, Any]:
"""
Get overall authentication status for an app and end user.
Shows which providers are authenticated and which need authentication.
:param app_id: The application ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: Dict with authentication status for all providers
"""
try:
# Get required providers for this app
required_providers = AppAuthRequirementService.get_tool_auth_requirements(app_id, tenant_id)
# Check authentication status for each provider
provider_statuses = []
for provider_info in required_providers:
provider_id = provider_info["provider_id"]
# Get credentials for this provider
credentials = EndUserAuthService.list_credentials(tenant_id, end_user_id, provider_id)
# Build status
provider_status = {
"provider_id": provider_id,
"provider_name": provider_info["provider_name"],
"provider_type": provider_info["provider_type"],
"authenticated": len(credentials) > 0,
"credentials": [
{
"credential_id": cred.id,
"name": cred.name,
"type": cred.credential_type.value,
"is_default": cred.is_default,
"expires_at": cred.expires_at,
}
for cred in credentials
],
}
provider_statuses.append(provider_status)
return {"providers": provider_statuses}
except Exception:
logger.exception("Error getting auth status for app %s", app_id)
return {"providers": []}
@staticmethod
def is_provider_authenticated(
provider_id: str,
end_user_id: str,
tenant_id: str,
) -> bool:
"""
Check if a specific provider is authenticated for an end user.
:param provider_id: The provider identifier
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: True if authenticated, False otherwise
"""
try:
credentials = EndUserAuthService.list_credentials(tenant_id, end_user_id, provider_id)
return len(credentials) > 0
except Exception:
return False

View File

@ -1,558 +0,0 @@
import json
import logging
from sqlalchemy import exists, select
from sqlalchemy.orm import Session
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
from core.helper.name_generator import generate_incremental_name
from core.helper.provider_cache import NoOpProviderCredentialCache
from core.plugin.entities.plugin_daemon import CredentialType
from core.tools.entities.api_entities import ToolProviderCredentialApiEntity
from core.tools.tool_manager import ToolManager
from core.tools.utils.encryption import create_provider_encrypter
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.tools import EndUserAuthenticationProvider
from services.tools.tools_transform_service import ToolTransformService
logger = logging.getLogger(__name__)
class EndUserAuthService:
"""
Service for managing end-user authentication credentials.
Follows similar patterns to BuiltinToolManageService but for end users.
"""
__MAX_CREDENTIALS_PER_PROVIDER__ = 100
@staticmethod
def list_credentials(
tenant_id: str, end_user_id: str, provider_id: str
) -> list[ToolProviderCredentialApiEntity]:
"""
List all credentials for a specific provider and end user.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider_id: The provider identifier
:return: List of credential entities
"""
with Session(db.engine, autoflush=False) as session:
credentials = (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider_id)
.order_by(EndUserAuthenticationProvider.created_at.asc())
.all()
)
if not credentials:
return []
# Get provider controller to access credential schema
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
result: list[ToolProviderCredentialApiEntity] = []
for credential in credentials:
try:
# Create encrypter for masking credentials
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential.credential_type
)
# Decrypt and mask credentials
decrypted = encrypter.decrypt(credential.credentials)
masked_credentials = encrypter.mask_plugin_credentials(decrypted)
# Convert to API entity
credential_entity = ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=credential,
credentials=dict(masked_credentials),
)
result.append(credential_entity)
except Exception:
logger.exception("Error processing credential %s", credential.id)
continue
return result
@staticmethod
def get_credential(
credential_id: str, end_user_id: str, tenant_id: str, mask_credentials: bool = True
) -> ToolProviderCredentialApiEntity | None:
"""
Get a specific credential by ID.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param mask_credentials: Whether to mask secret fields
:return: Credential entity or None
"""
with Session(db.engine, autoflush=False) as session:
credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not credential:
return None
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(credential.provider, tenant_id)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential.credential_type
)
# Decrypt credentials
decrypted = encrypter.decrypt(credential.credentials)
# Mask if requested
if mask_credentials:
decrypted = encrypter.mask_plugin_credentials(decrypted)
# Convert to API entity
return ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=credential,
credentials=dict(decrypted),
)
@staticmethod
def create_api_key_credential(
tenant_id: str,
end_user_id: str,
provider_id: str,
credentials: dict,
name: str | None = None,
) -> ToolProviderCredentialApiEntity:
"""
Create a new API key credential for an end user.
:param tenant_id: The tenant ID
:param end_user_id: The end user ID
:param provider_id: The provider identifier
:param credentials: The credential data
:param name: Optional custom name
:return: Created credential entity
"""
with Session(db.engine) as session:
try:
lock = f"enduser_credential_create_lock:{end_user_id}_{provider_id}"
with redis_client.lock(lock, timeout=20):
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(provider_id, tenant_id)
if not provider_controller.need_credentials:
raise ValueError(f"Provider {provider_id} does not need credentials")
# Check credential count
credential_count = (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider_id)
.count()
)
if credential_count >= EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__:
raise ValueError(
f"Maximum number of credentials ({EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__}) "
f"reached for provider {provider_id}"
)
# Validate credentials
credential_type = CredentialType.API_KEY
if CredentialType.of(credential_type).is_validate_allowed():
provider_controller.validate_credentials(end_user_id, credentials)
# Generate name if not provided
if name is None or name == "":
name = EndUserAuthService._generate_credential_name(
session=session,
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=provider_id,
credential_type=credential_type,
)
else:
# Validate name length
if len(name) > 30:
raise ValueError("Credential name must be 30 characters or less")
# Check if name is already used
if session.scalar(
select(
exists().where(
EndUserAuthenticationProvider.end_user_id == end_user_id,
EndUserAuthenticationProvider.tenant_id == tenant_id,
EndUserAuthenticationProvider.provider == provider_id,
EndUserAuthenticationProvider.name == name,
)
)
):
raise ValueError(f"The credential name '{name}' is already used")
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential_type
)
# Create credential record
db_credential = EndUserAuthenticationProvider(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider=provider_id,
encrypted_credentials=json.dumps(encrypter.encrypt(credentials)),
credential_type=credential_type,
name=name,
expires_at=-1, # API keys don't expire
)
session.add(db_credential)
session.commit()
session.refresh(db_credential)
# Return masked credentials
masked_credentials = encrypter.mask_plugin_credentials(credentials)
return ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=db_credential,
credentials=dict(masked_credentials),
)
except Exception as e:
session.rollback()
logger.exception("Error creating API key credential")
raise ValueError(str(e))
@staticmethod
def create_oauth_credential(
end_user_id: str,
tenant_id: str,
provider: str,
credentials: dict,
expires_at: int = -1,
name: str | None = None,
) -> EndUserAuthenticationProvider:
"""
Create a new OAuth credential for an end user.
Used internally by OAuth callback handler.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider: The provider identifier
:param credentials: The OAuth credentials (access_token, refresh_token, etc.)
:param expires_at: Unix timestamp when token expires (-1 for no expiry)
:param name: Optional custom name
:return: Created credential record
"""
with Session(db.engine) as session:
try:
lock = f"enduser_credential_create_lock:{end_user_id}_{provider}"
with redis_client.lock(lock, timeout=20):
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
# Check credential count
credential_count = (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider)
.count()
)
if credential_count >= EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__:
raise ValueError(
f"Maximum number of credentials ({EndUserAuthService.__MAX_CREDENTIALS_PER_PROVIDER__}) "
f"reached for provider {provider}"
)
# Generate name if not provided
credential_type = CredentialType.OAUTH2
if name is None or name == "":
name = EndUserAuthService._generate_credential_name(
session=session,
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=provider,
credential_type=credential_type,
)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential_type
)
# Create credential record
db_credential = EndUserAuthenticationProvider(
tenant_id=tenant_id,
end_user_id=end_user_id,
provider=provider,
encrypted_credentials=json.dumps(encrypter.encrypt(credentials)),
credential_type=credential_type,
name=name,
expires_at=expires_at,
)
session.add(db_credential)
session.commit()
session.refresh(db_credential)
return db_credential
except Exception as e:
session.rollback()
logger.exception("Error creating OAuth credential")
raise ValueError(str(e))
@staticmethod
def update_credential(
credential_id: str,
end_user_id: str,
tenant_id: str,
credentials: dict | None = None,
name: str | None = None,
) -> ToolProviderCredentialApiEntity:
"""
Update an existing credential (API key only).
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param credentials: Updated credentials (optional)
:param name: Updated name (optional)
:return: Updated credential entity
"""
with Session(db.engine) as session:
try:
# Get credential
db_credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not db_credential:
raise ValueError(f"Credential {credential_id} not found")
# Only API key credentials can be updated
if not CredentialType.of(db_credential.credential_type).is_editable():
raise ValueError("Only API key credentials can be updated via this endpoint")
# At least one field must be provided
if credentials is None and name is None:
raise ValueError("At least one field (credentials or name) must be provided")
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(db_credential.provider, tenant_id)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, db_credential.credential_type
)
# Update credentials if provided
if credentials:
# Decrypt original credentials
original_credentials = encrypter.decrypt(db_credential.credentials)
# Merge with new credentials, keeping hidden values
new_credentials: dict = {
key: value if value != HIDDEN_VALUE else original_credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
# Validate new credentials
if CredentialType.of(db_credential.credential_type).is_validate_allowed():
provider_controller.validate_credentials(end_user_id, new_credentials)
# Encrypt and save
db_credential.encrypted_credentials = json.dumps(encrypter.encrypt(new_credentials))
# Update name if provided
if name and name != db_credential.name:
# Validate name length
if len(name) > 30:
raise ValueError("Credential name must be 30 characters or less")
# Check if name is already used
if session.scalar(
select(
exists().where(
EndUserAuthenticationProvider.end_user_id == end_user_id,
EndUserAuthenticationProvider.tenant_id == tenant_id,
EndUserAuthenticationProvider.provider == db_credential.provider,
EndUserAuthenticationProvider.name == name,
)
)
):
raise ValueError(f"The credential name '{name}' is already used")
db_credential.name = name
session.commit()
session.refresh(db_credential)
# Return masked credentials
decrypted = encrypter.decrypt(db_credential.credentials)
masked_credentials = encrypter.mask_plugin_credentials(decrypted)
return ToolTransformService.convert_enduser_provider_to_credential_entity(
provider=db_credential,
credentials=dict(masked_credentials),
)
except Exception as e:
session.rollback()
logger.exception("Error updating credential")
raise ValueError(str(e))
@staticmethod
def delete_credential(
tenant_id: str, end_user_id: str, provider_id: str, credential_id: str
) -> bool:
"""
Delete a credential.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: True if deleted successfully
"""
with Session(db.engine) as session:
credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not credential:
raise ValueError(f"Credential {credential_id} not found")
session.delete(credential)
session.commit()
return True
@staticmethod
def refresh_oauth_token(
credential_id: str, end_user_id: str, tenant_id: str, refreshed_credentials: dict, expires_at: int
) -> EndUserAuthenticationProvider:
"""
Update OAuth credentials after token refresh.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param refreshed_credentials: New credentials from OAuth refresh
:param expires_at: New expiration timestamp
:return: Updated credential record
"""
with Session(db.engine) as session:
try:
credential = (
session.query(EndUserAuthenticationProvider)
.filter_by(id=credential_id, end_user_id=end_user_id, tenant_id=tenant_id)
.first()
)
if not credential:
raise ValueError(f"Credential {credential_id} not found")
if credential.credential_type != CredentialType.OAUTH2:
raise ValueError("Only OAuth credentials can be refreshed")
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(credential.provider, tenant_id)
# Create encrypter
encrypter, _ = EndUserAuthService._create_encrypter(
tenant_id, provider_controller, credential.credential_type
)
# Encrypt and save new credentials
credential.encrypted_credentials = json.dumps(encrypter.encrypt(refreshed_credentials))
credential.expires_at = expires_at
session.commit()
session.refresh(credential)
return credential
except Exception as e:
session.rollback()
logger.exception("Error refreshing OAuth token")
raise ValueError(str(e))
@staticmethod
def get_default_credential(
end_user_id: str, tenant_id: str, provider: str
) -> EndUserAuthenticationProvider | None:
"""
Get the default (oldest) credential for a provider.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider: The provider identifier
:return: Credential record or None
"""
with Session(db.engine, autoflush=False) as session:
return (
session.query(EndUserAuthenticationProvider)
.filter_by(end_user_id=end_user_id, tenant_id=tenant_id, provider=provider)
.order_by(EndUserAuthenticationProvider.created_at.asc())
.first()
)
@staticmethod
def _generate_credential_name(
session: Session,
end_user_id: str,
tenant_id: str,
provider: str,
credential_type: CredentialType,
) -> str:
"""
Generate a unique credential name.
:param session: Database session
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param provider: The provider identifier
:param credential_type: The credential type
:return: Generated name (e.g., "API KEY 1", "AUTH 1")
"""
existing_credentials = (
session.query(EndUserAuthenticationProvider)
.filter_by(
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=provider,
credential_type=credential_type,
)
.order_by(EndUserAuthenticationProvider.created_at.desc())
.all()
)
return generate_incremental_name(
[credential.name for credential in existing_credentials],
f"{credential_type.get_name()}",
)
@staticmethod
def _create_encrypter(
tenant_id: str, provider_controller, credential_type: CredentialType | str
) -> tuple:
"""
Create an encrypter for credential encryption/decryption.
:param tenant_id: The tenant ID
:param provider_controller: The provider controller
:param credential_type: The credential type
:return: Tuple of (encrypter, cache)
"""
if isinstance(credential_type, str):
credential_type = CredentialType.of(credential_type)
return create_provider_encrypter(
tenant_id=tenant_id,
config=[
x.to_basic_provider_config()
for x in provider_controller.get_credentials_schema_by_type(credential_type)
],
cache=NoOpProviderCredentialCache(),
)

View File

@ -1,264 +0,0 @@
import logging
from typing import Any
from werkzeug import Request
from configs import dify_config
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.impl.oauth import OAuthHandler
from core.tools.tool_manager import ToolManager
from models.provider_ids import ToolProviderID
from services.plugin.oauth_service import OAuthProxyService
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
from services.tools.enduser_auth_service import EndUserAuthService
logger = logging.getLogger(__name__)
class EndUserOAuthService:
"""
Service for managing end-user OAuth authentication flows.
Reuses existing OAuthProxyService and OAuthHandler infrastructure.
"""
@staticmethod
def get_authorization_url(
end_user_id: str,
tenant_id: str,
app_id: str,
provider: str,
) -> dict[str, str]:
"""
Initiate OAuth authorization flow for an end user.
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:param app_id: The application ID
:param provider: The provider identifier
:return: Dict with authorization_url
"""
try:
# Get OAuth client configuration (reuse workspace-level logic)
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, provider)
if not oauth_client:
raise ValueError(f"OAuth client not configured for provider {provider}")
# Get provider controller
provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
tool_provider_id = ToolProviderID(provider)
# Create OAuth context with end-user specific data
context_id = OAuthProxyService.create_proxy_context(
user_id=end_user_id, # Using end_user_id as user_id
tenant_id=tenant_id,
plugin_id=tool_provider_id.plugin_id,
provider=tool_provider_id.provider_name,
extra_data={
"app_id": app_id,
"provider_type": "tool", # For now, only tools support end-user auth
},
)
# Use the same redirect URI as workspace OAuth to reuse the same OAuth client
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{tool_provider_id}/tool/callback"
# Get authorization URL from OAuth handler
oauth_handler = OAuthHandler()
response = oauth_handler.get_authorization_url(
tenant_id=tenant_id,
user_id=end_user_id,
plugin_id=tool_provider_id.plugin_id,
provider=tool_provider_id.provider_name,
redirect_uri=redirect_uri,
system_credentials=oauth_client,
)
return {
"authorization_url": response.authorization_url,
"context_id": context_id, # Return for setting cookie
}
except Exception as e:
logger.exception("Error getting authorization URL for end user")
raise ValueError(f"Failed to initiate OAuth flow: {str(e)}")
@staticmethod
def handle_oauth_callback(
context_id: str,
request: Request,
) -> dict[str, Any]:
"""
Handle OAuth callback and create credential.
:param context_id: The OAuth context ID from cookie
:param request: The callback request with authorization code
:return: Dict with credential information
"""
try:
# Validate and retrieve context
context = OAuthProxyService.use_proxy_context(context_id)
# Extract context data
end_user_id = context.get("user_id") # user_id is actually end_user_id
tenant_id = context.get("tenant_id")
app_id = context.get("app_id")
plugin_id = context.get("plugin_id")
provider = context.get("provider")
if not all([end_user_id, tenant_id, app_id, plugin_id, provider]):
raise ValueError("Invalid OAuth context: missing required fields")
# Reconstruct full provider ID
full_provider = f"{plugin_id}/{provider}" if plugin_id != "langgenius" else provider
# Get OAuth client configuration
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, full_provider)
if not oauth_client:
raise ValueError(f"OAuth client not configured for provider {full_provider}")
# Use the same redirect URI as workspace OAuth (must match authorization request)
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{full_provider}/tool/callback"
# Exchange authorization code for credentials
oauth_handler = OAuthHandler()
credentials_response = oauth_handler.get_credentials(
tenant_id=tenant_id,
user_id=end_user_id,
plugin_id=plugin_id,
provider=provider,
redirect_uri=redirect_uri,
system_credentials=oauth_client,
request=request,
)
# Calculate expiration timestamp
expires_at = -1
if credentials_response.expires_in and credentials_response.expires_in > 0:
import time
expires_at = int(time.time()) + credentials_response.expires_in
# Create credential in database
credential = EndUserAuthService.create_oauth_credential(
end_user_id=end_user_id,
tenant_id=tenant_id,
provider=full_provider,
credentials=credentials_response.credentials,
expires_at=expires_at,
)
return {
"success": True,
"credential_id": credential.id,
"provider": full_provider,
"app_id": app_id,
}
except Exception as e:
logger.exception("Error handling OAuth callback for end user")
raise ValueError(f"Failed to complete OAuth flow: {str(e)}")
@staticmethod
def refresh_oauth_token(
credential_id: str,
end_user_id: str,
tenant_id: str,
) -> dict[str, Any]:
"""
Refresh an expired OAuth token.
:param credential_id: The credential ID
:param end_user_id: The end user ID
:param tenant_id: The tenant ID
:return: Dict with refresh status
"""
try:
# Get existing credential
credential = EndUserAuthService.get_credential(
credential_id=credential_id,
end_user_id=end_user_id,
tenant_id=tenant_id,
mask_credentials=False, # Need full credentials for refresh
)
if not credential:
raise ValueError(f"Credential {credential_id} not found")
if credential.credential_type != CredentialType.OAUTH2:
raise ValueError("Only OAuth credentials can be refreshed")
# Get OAuth client configuration
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, credential.provider)
if not oauth_client:
raise ValueError(f"OAuth client not configured for provider {credential.provider}")
# Get provider info
tool_provider_id = ToolProviderID(credential.provider)
# Use the same redirect URI as workspace OAuth to reuse the same OAuth client
redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{credential.provider}/tool/callback"
# Refresh credentials via OAuth handler
oauth_handler = OAuthHandler()
refreshed_response = oauth_handler.refresh_credentials(
tenant_id=tenant_id,
user_id=end_user_id,
plugin_id=tool_provider_id.plugin_id,
provider=tool_provider_id.provider_name,
redirect_uri=redirect_uri,
system_credentials=oauth_client,
credentials=credential.credentials,
)
# Calculate new expiration timestamp
expires_at = -1
if refreshed_response.expires_in and refreshed_response.expires_in > 0:
import time
expires_at = int(time.time()) + refreshed_response.expires_in
# Update credential in database
updated_credential = EndUserAuthService.refresh_oauth_token(
credential_id=credential_id,
end_user_id=end_user_id,
tenant_id=tenant_id,
refreshed_credentials=refreshed_response.credentials,
expires_at=expires_at,
)
return {
"success": True,
"credential_id": updated_credential.id,
"expires_at": expires_at,
"refreshed_at": int(updated_credential.updated_at.timestamp()),
}
except Exception:
logger.exception("Error refreshing OAuth token for end user")
return {
"success": False,
"error": "Failed to refresh token",
}
@staticmethod
def get_oauth_client_info(tenant_id: str, provider: str) -> dict[str, Any]:
"""
Get OAuth client information for a provider.
Used to check if OAuth is available and configured.
:param tenant_id: The tenant ID
:param provider: The provider identifier
:return: Dict with OAuth client info
"""
try:
# Check if OAuth client exists (either system or custom)
oauth_client = BuiltinToolManageService.get_oauth_client(tenant_id, provider)
return {
"configured": oauth_client is not None,
"system_configured": BuiltinToolManageService.is_oauth_system_client_exists(provider),
"custom_configured": BuiltinToolManageService.is_oauth_custom_client_enabled(tenant_id, provider),
}
except Exception:
logger.exception("Error getting OAuth client info")
return {
"configured": False,
"system_configured": False,
"custom_configured": False,
}

View File

@ -426,27 +426,6 @@ class ToolTransformService:
credentials=credentials,
)
@staticmethod
def convert_enduser_provider_to_credential_entity(
provider, credentials: dict
) -> ToolProviderCredentialApiEntity:
"""
Convert EndUserAuthenticationProvider to ToolProviderCredentialApiEntity.
:param provider: EndUserAuthenticationProvider instance
:param credentials: Decrypted/masked credentials dict
:return: ToolProviderCredentialApiEntity
"""
return ToolProviderCredentialApiEntity(
id=provider.id,
name=provider.name,
provider=provider.provider,
credential_type=CredentialType.of(provider.credential_type),
is_default=False, # End-user credentials don't have default flag (use oldest)
credentials=credentials,
expires_at=provider.expires_at,
)
@staticmethod
def convert_mcp_schema_to_parameter(schema: dict[str, Any]) -> list["ToolParameter"]:
"""

View File

@ -410,12 +410,9 @@ class VariableTruncator(BaseTruncator):
@overload
def _truncate_json_primitives(self, val: None, target_size: int) -> _PartResult[None]: ...
@overload
def _truncate_json_primitives(self, val: File, target_size: int) -> _PartResult[File]: ...
def _truncate_json_primitives(
self,
val: UpdatedVariable | File | str | list[object] | dict[str, object] | bool | int | float | None,
val: UpdatedVariable | str | list[object] | dict[str, object] | bool | int | float | None,
target_size: int,
) -> _PartResult[Any]:
"""Truncate a value within an object to fit within budget."""
@ -428,9 +425,6 @@ class VariableTruncator(BaseTruncator):
return self._truncate_array(val, target_size)
elif isinstance(val, dict):
return self._truncate_object(val, target_size)
elif isinstance(val, File):
# File objects should not be truncated, return as-is
return _PartResult(val, self.calculate_json_size(val), False)
elif val is None or isinstance(val, (bool, int, float)):
return _PartResult(val, self.calculate_json_size(val), False)
else:

View File

@ -113,31 +113,16 @@ class TestShardedRedisBroadcastChannelIntegration:
topic = broadcast_channel.topic(topic_name)
producer = topic.as_producer()
subscriptions = [topic.subscribe() for _ in range(subscriber_count)]
ready_events = [threading.Event() for _ in range(subscriber_count)]
def producer_thread():
deadline = time.time() + 5.0
for ev in ready_events:
remaining = deadline - time.time()
if remaining <= 0:
break
if not ev.wait(timeout=max(0.0, remaining)):
pytest.fail("subscriber did not become ready before publish deadline")
time.sleep(0.2) # Allow all subscribers to connect
producer.publish(message)
time.sleep(0.2)
for sub in subscriptions:
sub.close()
def consumer_thread(subscription: Subscription, ready_event: threading.Event) -> list[bytes]:
def consumer_thread(subscription: Subscription) -> list[bytes]:
received_msgs = []
# Prime subscription so the underlying Pub/Sub listener thread starts before publishing
try:
_ = subscription.receive(0.01)
except SubscriptionClosedError:
return received_msgs
finally:
ready_event.set()
while True:
try:
msg = subscription.receive(0.1)
@ -152,10 +137,7 @@ class TestShardedRedisBroadcastChannelIntegration:
with ThreadPoolExecutor(max_workers=subscriber_count + 1) as executor:
producer_future = executor.submit(producer_thread)
consumer_futures = [
executor.submit(consumer_thread, subscription, ready_events[idx])
for idx, subscription in enumerate(subscriptions)
]
consumer_futures = [executor.submit(consumer_thread, subscription) for subscription in subscriptions]
producer_future.result(timeout=10.0)
msgs_by_consumers = []

View File

@ -1,6 +1,5 @@
"""Test authentication security to prevent user enumeration."""
import base64
from unittest.mock import MagicMock, patch
import pytest
@ -12,11 +11,6 @@ from controllers.console.auth.error import AuthenticationFailedError
from controllers.console.auth.login import LoginApi
def encode_password(password: str) -> str:
"""Helper to encode password as Base64 for testing."""
return base64.b64encode(password.encode("utf-8")).decode()
class TestAuthenticationSecurity:
"""Test authentication endpoints for security against user enumeration."""
@ -48,9 +42,7 @@ class TestAuthenticationSecurity:
# Act
with self.app.test_request_context(
"/login",
method="POST",
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
):
login_api = LoginApi()
@ -80,9 +72,7 @@ class TestAuthenticationSecurity:
# Act
with self.app.test_request_context(
"/login",
method="POST",
json={"email": "existing@example.com", "password": encode_password("WrongPass123!")},
"/login", method="POST", json={"email": "existing@example.com", "password": "WrongPass123!"}
):
login_api = LoginApi()
@ -114,9 +104,7 @@ class TestAuthenticationSecurity:
# Act
with self.app.test_request_context(
"/login",
method="POST",
json={"email": "nonexistent@example.com", "password": encode_password("WrongPass123!")},
"/login", method="POST", json={"email": "nonexistent@example.com", "password": "WrongPass123!"}
):
login_api = LoginApi()

View File

@ -8,7 +8,6 @@ This module tests the email code login mechanism including:
- Workspace creation for new users
"""
import base64
from unittest.mock import MagicMock, patch
import pytest
@ -26,11 +25,6 @@ from controllers.console.error import (
from services.errors.account import AccountRegisterError
def encode_code(code: str) -> str:
"""Helper to encode verification code as Base64 for testing."""
return base64.b64encode(code.encode("utf-8")).decode()
class TestEmailCodeLoginSendEmailApi:
"""Test cases for sending email verification codes."""
@ -296,7 +290,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"},
json={"email": "test@example.com", "code": "123456", "token": "valid_token"},
):
api = EmailCodeLoginApi()
response = api.post()
@ -345,12 +339,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={
"email": "newuser@example.com",
"code": encode_code("123456"),
"token": "valid_token",
"language": "en-US",
},
json={"email": "newuser@example.com", "code": "123456", "token": "valid_token", "language": "en-US"},
):
api = EmailCodeLoginApi()
response = api.post()
@ -376,7 +365,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"},
json={"email": "test@example.com", "code": "123456", "token": "invalid_token"},
):
api = EmailCodeLoginApi()
with pytest.raises(InvalidTokenError):
@ -399,7 +388,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"},
json={"email": "different@example.com", "code": "123456", "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(InvalidEmailError):
@ -422,7 +411,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"},
json={"email": "test@example.com", "code": "wrong_code", "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(EmailCodeError):
@ -508,7 +497,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
json={"email": "test@example.com", "code": "123456", "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(WorkspacesLimitExceeded):
@ -550,7 +539,7 @@ class TestEmailCodeLoginApi:
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
json={"email": "test@example.com", "code": "123456", "token": "token"},
):
api = EmailCodeLoginApi()
with pytest.raises(NotAllowedCreateWorkspace):

View File

@ -8,7 +8,6 @@ This module tests the core authentication endpoints including:
- Account status validation
"""
import base64
from unittest.mock import MagicMock, patch
import pytest
@ -29,11 +28,6 @@ from controllers.console.error import (
from services.errors.account import AccountLoginError, AccountPasswordError
def encode_password(password: str) -> str:
"""Helper to encode password as Base64 for testing."""
return base64.b64encode(password.encode("utf-8")).decode()
class TestLoginApi:
"""Test cases for the LoginApi endpoint."""
@ -112,9 +106,7 @@ class TestLoginApi:
# Act
with app.test_request_context(
"/login",
method="POST",
json={"email": "test@example.com", "password": encode_password("ValidPass123!")},
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
):
login_api = LoginApi()
response = login_api.post()
@ -166,11 +158,7 @@ class TestLoginApi:
with app.test_request_context(
"/login",
method="POST",
json={
"email": "test@example.com",
"password": encode_password("ValidPass123!"),
"invite_token": "valid_token",
},
json={"email": "test@example.com", "password": "ValidPass123!", "invite_token": "valid_token"},
):
login_api = LoginApi()
response = login_api.post()
@ -198,7 +186,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("password")}
"/login", method="POST", json={"email": "test@example.com", "password": "password"}
):
login_api = LoginApi()
with pytest.raises(EmailPasswordLoginLimitError):
@ -221,7 +209,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "frozen@example.com", "password": encode_password("password")}
"/login", method="POST", json={"email": "frozen@example.com", "password": "password"}
):
login_api = LoginApi()
with pytest.raises(AccountInFreezeError):
@ -258,7 +246,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("WrongPass123!")}
"/login", method="POST", json={"email": "test@example.com", "password": "WrongPass123!"}
):
login_api = LoginApi()
with pytest.raises(AuthenticationFailedError):
@ -289,7 +277,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "banned@example.com", "password": encode_password("ValidPass123!")}
"/login", method="POST", json={"email": "banned@example.com", "password": "ValidPass123!"}
):
login_api = LoginApi()
with pytest.raises(AccountBannedError):
@ -334,7 +322,7 @@ class TestLoginApi:
# Act & Assert
with app.test_request_context(
"/login", method="POST", json={"email": "test@example.com", "password": encode_password("ValidPass123!")}
"/login", method="POST", json={"email": "test@example.com", "password": "ValidPass123!"}
):
login_api = LoginApi()
with pytest.raises(WorkspacesLimitExceeded):
@ -361,11 +349,7 @@ class TestLoginApi:
with app.test_request_context(
"/login",
method="POST",
json={
"email": "different@example.com",
"password": encode_password("ValidPass123!"),
"invite_token": "token",
},
json={"email": "different@example.com", "password": "ValidPass123!", "invite_token": "token"},
):
login_api = LoginApi()
with pytest.raises(InvalidEmailError):

View File

@ -1,486 +0,0 @@
"""
Unit tests for end-user tool authentication.
Tests the integration of end-user authentication with tool runtime resolution.
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.entities.tool_entities import ToolAuthType, ToolInvokeFrom, ToolProviderType
from core.tools.errors import ToolProviderNotFoundError
from core.tools.tool_manager import ToolManager
@pytest.fixture
def mock_db_session():
"""Mock database session."""
with patch("core.tools.tool_manager.db") as mock_db:
yield mock_db.session
@pytest.fixture
def mock_provider_controller():
"""Mock builtin provider controller."""
controller = MagicMock()
controller.need_credentials = True
controller.get_tool = MagicMock(return_value=MagicMock())
controller.get_credentials_schema_by_type = MagicMock(return_value=[])
return controller
class TestEndUserToolAuthentication:
"""Test suite for end-user tool authentication."""
def test_end_user_auth_requires_end_user_id(self, mock_db_session, mock_provider_controller):
"""
Test that END_USER auth_type requires end_user_id parameter.
When auth_type is END_USER but end_user_id is None, should raise error.
"""
with patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller):
with pytest.raises(ToolProviderNotFoundError, match="end_user_id is required"):
ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id=None, # Missing!
)
def test_end_user_auth_missing_credentials(self, mock_db_session, mock_provider_controller):
"""
Test that error is raised when end-user has no credentials for provider.
When auth_type is END_USER but no credentials exist, should raise error.
"""
# Mock no credentials found
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = None
with patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller):
with pytest.raises(ToolProviderNotFoundError, match="No end-user credentials found"):
ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
def test_end_user_auth_with_credentials(self, mock_db_session, mock_provider_controller):
"""
Test successful end-user credential resolution.
When auth_type is END_USER and credentials exist, should return tool runtime.
"""
# Mock end-user provider
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "api-key"
mock_enduser_provider.credentials = '{"api_key": "encrypted"}'
mock_enduser_provider.expires_at = -1 # No expiry
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "decrypted_key"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify tool runtime was created
assert tool_runtime is not None
# Verify encrypter was called with decrypted credentials
mock_encrypter.decrypt.assert_called_once()
def test_workspace_auth_backward_compatibility(self, mock_db_session, mock_provider_controller):
"""
Test that workspace authentication still works (backward compatibility).
When auth_type is WORKSPACE (default), should use workspace credentials.
"""
# Mock workspace provider
mock_workspace_provider = MagicMock()
mock_workspace_provider.id = "workspace_cred_123"
mock_workspace_provider.credential_type = "api-key"
mock_workspace_provider.credentials = '{"api_key": "workspace_encrypted"}'
mock_workspace_provider.expires_at = -1
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_workspace_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "workspace_decrypted"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch("core.helper.credential_utils.check_credential_policy_compliance"),
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="test_provider",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.WORKSPACE, # Workspace auth
end_user_id=None, # Not needed for workspace auth
)
# Verify tool runtime was created
assert tool_runtime is not None
def test_workflow_tool_runtime_passes_end_user_id(self, mock_db_session, mock_provider_controller):
"""
Test that get_workflow_tool_runtime correctly passes end_user_id to get_tool_runtime.
"""
from core.workflow.nodes.tool.entities import ToolEntity
# Create a mock ToolEntity with END_USER auth_type
workflow_tool = MagicMock(spec=ToolEntity)
workflow_tool.provider_type = ToolProviderType.BUILT_IN
workflow_tool.provider_id = "test_provider"
workflow_tool.tool_name = "test_tool"
workflow_tool.credential_id = None
workflow_tool.auth_type = ToolAuthType.END_USER
workflow_tool.tool_configurations = {}
# Mock end-user credentials
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "api-key"
mock_enduser_provider.credentials = '{"api_key": "encrypted"}'
mock_enduser_provider.expires_at = -1
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "decrypted"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
):
tool_runtime = ToolManager.get_workflow_tool_runtime(
tenant_id="test_tenant",
app_id="test_app",
node_id="test_node",
workflow_tool=workflow_tool,
invoke_from=InvokeFrom.SERVICE_API,
variable_pool=None,
end_user_id="end_user_123", # Pass end_user_id
)
# Verify tool runtime was created
assert tool_runtime is not None
class TestOAuthTokenRefresh:
"""Test suite for OAuth token refresh functionality."""
def test_enduser_oauth_token_refresh_when_expired(self, mock_db_session, mock_provider_controller):
"""
Test that end-user OAuth tokens are automatically refreshed when expired.
When an OAuth token is expired (expires_at < current_time + 60s buffer),
the system should automatically refresh it before using.
"""
# Mock end-user provider with expired OAuth token
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "oauth2"
mock_enduser_provider.credentials = '{"access_token": "old_token", "refresh_token": "refresh"}'
# Set expiry to past (token expired)
mock_enduser_provider.expires_at = int(time.time()) - 100
mock_enduser_provider.encrypted_credentials = '{"access_token": "old_token", "refresh_token": "refresh"}'
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"access_token": "old_token", "refresh_token": "refresh"}
mock_encrypter.encrypt.return_value = {"access_token": "new_token", "refresh_token": "refresh"}
mock_cache = MagicMock()
# Mock OAuth refresh response
mock_refreshed_credentials = MagicMock()
mock_refreshed_credentials.credentials = {"access_token": "new_token", "refresh_token": "refresh"}
mock_refreshed_credentials.expires_at = int(time.time()) + 3600 # New expiry 1 hour from now
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch.object(
ToolManager,
"_refresh_oauth_credentials",
return_value=(
mock_refreshed_credentials.credentials,
mock_refreshed_credentials.expires_at,
),
) as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="github",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify refresh was called
mock_refresh.assert_called_once_with(
tenant_id="test_tenant",
provider_id="github",
user_id="end_user_123",
decrypted_credentials={"access_token": "old_token", "refresh_token": "refresh"},
)
# Verify provider was updated with new credentials
assert mock_enduser_provider.expires_at == mock_refreshed_credentials.expires_at
mock_db_session.commit.assert_called_once()
mock_cache.delete.assert_called_once()
# Verify tool runtime was created
assert tool_runtime is not None
def test_enduser_oauth_token_not_refreshed_when_valid(self, mock_db_session, mock_provider_controller):
"""
Test that valid OAuth tokens are NOT refreshed.
When an OAuth token is still valid (expires_at > current_time + 60s buffer),
the system should use it without refreshing.
"""
# Mock end-user provider with valid OAuth token
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "oauth2"
mock_enduser_provider.credentials = '{"access_token": "valid_token", "refresh_token": "refresh"}'
# Set expiry to future (token still valid with buffer)
mock_enduser_provider.expires_at = int(time.time()) + 3600 # Valid for 1 hour
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"access_token": "valid_token", "refresh_token": "refresh"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch.object(ToolManager, "_refresh_oauth_credentials") as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="github",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify refresh was NOT called (token still valid)
mock_refresh.assert_not_called()
# Verify tool runtime was created with original credentials
assert tool_runtime is not None
def test_workspace_oauth_token_refresh_when_expired(self, mock_db_session, mock_provider_controller):
"""
Test that workspace OAuth tokens are automatically refreshed when expired.
This ensures the refactored _refresh_oauth_credentials helper works
for both end-user and workspace authentication flows.
"""
# Mock workspace provider with expired OAuth token
mock_workspace_provider = MagicMock()
mock_workspace_provider.id = "workspace_cred_123"
mock_workspace_provider.user_id = "workspace_user_456"
mock_workspace_provider.credential_type = "oauth2"
mock_workspace_provider.credentials = '{"access_token": "old_workspace_token"}'
mock_workspace_provider.expires_at = int(time.time()) - 100 # Expired
mock_workspace_provider.encrypted_credentials = '{"access_token": "old_workspace_token"}'
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_workspace_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"access_token": "old_workspace_token"}
mock_encrypter.encrypt.return_value = {"access_token": "new_workspace_token"}
mock_cache = MagicMock()
# Mock OAuth refresh response
refreshed_creds = {"access_token": "new_workspace_token"}
new_expires_at = int(time.time()) + 3600
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch("core.helper.credential_utils.check_credential_policy_compliance"),
patch.object(
ToolManager, "_refresh_oauth_credentials", return_value=(refreshed_creds, new_expires_at)
) as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="github",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.WORKSPACE,
)
# Verify refresh was called with workspace user_id
mock_refresh.assert_called_once_with(
tenant_id="test_tenant",
provider_id="github",
user_id="workspace_user_456",
decrypted_credentials={"access_token": "old_workspace_token"},
)
# Verify provider was updated
assert mock_workspace_provider.expires_at == new_expires_at
mock_db_session.commit.assert_called_once()
mock_cache.delete.assert_called_once()
# Verify tool runtime was created
assert tool_runtime is not None
def test_oauth_token_no_refresh_for_non_oauth_credentials(self, mock_db_session, mock_provider_controller):
"""
Test that non-OAuth credentials (API keys) are never refreshed.
API keys with expires_at = -1 should not trigger refresh logic.
"""
# Mock end-user provider with API key (no expiry)
mock_enduser_provider = MagicMock()
mock_enduser_provider.id = "cred_123"
mock_enduser_provider.credential_type = "api-key"
mock_enduser_provider.credentials = '{"api_key": "sk-1234567890"}'
mock_enduser_provider.expires_at = -1 # API keys don't expire
mock_db_session.query.return_value.where.return_value.order_by.return_value.first.return_value = (
mock_enduser_provider
)
# Mock encrypter
mock_encrypter = MagicMock()
mock_encrypter.decrypt.return_value = {"api_key": "sk-1234567890"}
mock_cache = MagicMock()
with (
patch.object(ToolManager, "get_builtin_provider", return_value=mock_provider_controller),
patch("core.tools.tool_manager.create_provider_encrypter", return_value=(mock_encrypter, mock_cache)),
patch.object(ToolManager, "_refresh_oauth_credentials") as mock_refresh,
):
tool_runtime = ToolManager.get_tool_runtime(
provider_type=ToolProviderType.BUILT_IN,
provider_id="openai",
tool_name="test_tool",
tenant_id="test_tenant",
invoke_from=InvokeFrom.SERVICE_API,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
auth_type=ToolAuthType.END_USER,
end_user_id="end_user_123",
)
# Verify refresh was NOT called (API key doesn't need refresh)
mock_refresh.assert_not_called()
# Verify tool runtime was created
assert tool_runtime is not None
def test_refresh_oauth_credentials_helper_method(self):
"""
Test the _refresh_oauth_credentials helper method directly.
This tests the centralized OAuth refresh logic that is used by both
end-user and workspace authentication flows.
"""
# Mock dependencies
mock_oauth_handler = MagicMock()
mock_refreshed = MagicMock()
mock_refreshed.credentials = {"access_token": "new_token", "refresh_token": "new_refresh"}
mock_refreshed.expires_at = int(time.time()) + 7200
mock_oauth_handler.refresh_credentials.return_value = mock_refreshed
with (
# Patch OAuthHandler where it's imported (inside the method)
patch("core.plugin.impl.oauth.OAuthHandler", return_value=mock_oauth_handler),
patch("core.tools.tool_manager.ToolProviderID") as mock_provider_id,
patch(
"services.tools.builtin_tools_manage_service.BuiltinToolManageService.get_oauth_client",
return_value={"client_id": "test"},
),
patch("core.tools.tool_manager.dify_config.CONSOLE_API_URL", "http://localhost:5001"),
):
# Setup provider ID mock
mock_provider_id.return_value.provider_name = "github"
mock_provider_id.return_value.plugin_id = "builtin"
# Call the helper method
credentials, expires_at = ToolManager._refresh_oauth_credentials(
tenant_id="test_tenant",
provider_id="langgenius/github/github",
user_id="user_123",
decrypted_credentials={"access_token": "old_token", "refresh_token": "old_refresh"},
)
# Verify OAuth handler was called correctly
mock_oauth_handler.refresh_credentials.assert_called_once_with(
tenant_id="test_tenant",
user_id="user_123",
plugin_id="builtin",
provider="github",
redirect_uri="http://localhost:5001/console/api/oauth/plugin/langgenius/github/github/tool/callback",
system_credentials={"client_id": "test"},
credentials={"access_token": "old_token", "refresh_token": "old_refresh"},
)
# Verify returned values
assert credentials == {"access_token": "new_token", "refresh_token": "new_refresh"}
assert expires_at == mock_refreshed.expires_at

View File

@ -1,150 +0,0 @@
"""
Unit tests for field encoding/decoding utilities.
These tests verify Base64 encoding/decoding functionality and
proper error handling and fallback behavior.
"""
import base64
from libs.encryption import FieldEncryption
class TestDecodeField:
"""Test cases for field decoding functionality."""
def test_decode_valid_base64(self):
"""Test decoding a valid Base64 encoded string."""
plaintext = "password123"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
def test_decode_non_base64_returns_none(self):
"""Test that non-base64 input returns None."""
non_base64 = "plain-password-!@#"
result = FieldEncryption.decrypt_field(non_base64)
# Should return None (decoding failed)
assert result is None
def test_decode_unicode_text(self):
"""Test decoding Base64 encoded Unicode text."""
plaintext = "密码Test123"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
def test_decode_empty_string(self):
"""Test decoding an empty string returns empty string."""
result = FieldEncryption.decrypt_field("")
# Empty string base64 decodes to empty string
assert result == ""
def test_decode_special_characters(self):
"""Test decoding with special characters."""
plaintext = "P@ssw0rd!#$%^&*()"
encoded = base64.b64encode(plaintext.encode("utf-8")).decode()
result = FieldEncryption.decrypt_field(encoded)
assert result == plaintext
class TestDecodePassword:
"""Test cases for password decoding."""
def test_decode_password_base64(self):
"""Test decoding a Base64 encoded password."""
password = "SecureP@ssw0rd!"
encoded = base64.b64encode(password.encode("utf-8")).decode()
result = FieldEncryption.decrypt_password(encoded)
assert result == password
def test_decode_password_invalid_returns_none(self):
"""Test that invalid base64 passwords return None."""
invalid = "PlainPassword!@#"
result = FieldEncryption.decrypt_password(invalid)
# Should return None (decoding failed)
assert result is None
class TestDecodeVerificationCode:
"""Test cases for verification code decoding."""
def test_decode_code_base64(self):
"""Test decoding a Base64 encoded verification code."""
code = "789012"
encoded = base64.b64encode(code.encode("utf-8")).decode()
result = FieldEncryption.decrypt_verification_code(encoded)
assert result == code
def test_decode_code_invalid_returns_none(self):
"""Test that invalid base64 codes return None."""
invalid = "123456" # Plain 6-digit code, not base64
result = FieldEncryption.decrypt_verification_code(invalid)
# Should return None (decoding failed)
assert result is None
class TestRoundTripEncodingDecoding:
"""
Integration tests for complete encoding-decoding cycle.
These tests simulate the full frontend-to-backend flow using Base64.
"""
def test_roundtrip_password(self):
"""Test encoding and decoding a password."""
original_password = "SecureP@ssw0rd!"
# Simulate frontend encoding (Base64)
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
# Backend decoding
decoded = FieldEncryption.decrypt_password(encoded)
assert decoded == original_password
def test_roundtrip_verification_code(self):
"""Test encoding and decoding a verification code."""
original_code = "123456"
# Simulate frontend encoding
encoded = base64.b64encode(original_code.encode("utf-8")).decode()
# Backend decoding
decoded = FieldEncryption.decrypt_verification_code(encoded)
assert decoded == original_code
def test_roundtrip_unicode_password(self):
"""Test encoding and decoding password with Unicode characters."""
original_password = "密码Test123!@#"
# Frontend encoding
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
# Backend decoding
decoded = FieldEncryption.decrypt_password(encoded)
assert decoded == original_password
def test_roundtrip_long_password(self):
"""Test encoding and decoding a long password."""
original_password = "ThisIsAVeryLongPasswordWithLotsOfCharacters123!@#$%^&*()"
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
decoded = FieldEncryption.decrypt_password(encoded)
assert decoded == original_password
def test_roundtrip_with_whitespace(self):
"""Test encoding and decoding with whitespace."""
original_password = "pass word with spaces"
encoded = base64.b64encode(original_password.encode("utf-8")).decode()
decoded = FieldEncryption.decrypt_field(encoded)
assert decoded == original_password

View File

@ -518,55 +518,6 @@ class TestEdgeCases:
assert isinstance(result.result, StringSegment)
class TestTruncateJsonPrimitives:
"""Test _truncate_json_primitives method with different data types."""
@pytest.fixture
def truncator(self):
return VariableTruncator()
def test_truncate_json_primitives_file_type(self, truncator, file):
"""Test that File objects are handled correctly in _truncate_json_primitives."""
# Test File object is returned as-is without truncation
result = truncator._truncate_json_primitives(file, 1000)
assert result.value == file
assert result.truncated is False
# Size should be calculated correctly
expected_size = VariableTruncator.calculate_json_size(file)
assert result.value_size == expected_size
def test_truncate_json_primitives_file_type_small_budget(self, truncator, file):
"""Test that File objects are returned as-is even with small budget."""
# Even with a small size budget, File objects should not be truncated
result = truncator._truncate_json_primitives(file, 10)
assert result.value == file
assert result.truncated is False
def test_truncate_json_primitives_file_type_in_array(self, truncator, file):
"""Test File objects in arrays are handled correctly."""
array_with_files = [file, file]
result = truncator._truncate_json_primitives(array_with_files, 1000)
assert isinstance(result.value, list)
assert len(result.value) == 2
assert result.value[0] == file
assert result.value[1] == file
assert result.truncated is False
def test_truncate_json_primitives_file_type_in_object(self, truncator, file):
"""Test File objects in objects are handled correctly."""
obj_with_files = {"file1": file, "file2": file}
result = truncator._truncate_json_primitives(obj_with_files, 1000)
assert isinstance(result.value, dict)
assert len(result.value) == 2
assert result.value["file1"] == file
assert result.value["file2"] == file
assert result.truncated is False
class TestIntegrationScenarios:
"""Test realistic integration scenarios."""

View File

@ -1229,7 +1229,7 @@ NGINX_SSL_PORT=443
# and modify the env vars below accordingly.
NGINX_SSL_CERT_FILENAME=dify.crt
NGINX_SSL_CERT_KEY_FILENAME=dify.key
NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3
NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3
# Nginx performance tuning
NGINX_WORKER_PROCESSES=auto
@ -1421,7 +1421,7 @@ QUEUE_MONITOR_ALERT_EMAILS=
QUEUE_MONITOR_INTERVAL=30
# Swagger UI configuration
SWAGGER_UI_ENABLED=false
SWAGGER_UI_ENABLED=true
SWAGGER_UI_PATH=/swagger-ui.html
# Whether to encrypt dataset IDs when exporting DSL files (default: true)
@ -1460,4 +1460,4 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
ANNOTATION_IMPORT_MAX_CONCURRENT=5
# The API key of amplitude
AMPLITUDE_API_KEY=
AMPLITUDE_API_KEY=

View File

@ -414,7 +414,7 @@ services:
# and modify the env vars below in .env if HTTPS_ENABLED is true.
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}

View File

@ -528,7 +528,7 @@ x-shared-env: &shared-api-worker-env
NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443}
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
@ -631,7 +631,7 @@ x-shared-env: &shared-api-worker-env
QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200}
QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-}
QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30}
SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-false}
SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-true}
SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html}
DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true}
DATASET_MAX_SEGMENTS_PER_REQUEST: ${DATASET_MAX_SEGMENTS_PER_REQUEST:-0}
@ -1071,7 +1071,7 @@ services:
# and modify the env vars below in .env if HTTPS_ENABLED is true.
NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt}
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}

View File

@ -19,13 +19,7 @@
*/
export const useTranslation = () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return [`${key}-feature-1`, `${key}-feature-2`]
if (options)
return `${key}:${JSON.stringify(options)}`
return key
},
t: (key: string) => key,
i18n: {
language: 'en',
changeLanguage: jest.fn(),

View File

@ -1,53 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import EditItem, { EditItemType } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('AddAnnotationModal/EditItem', () => {
test('should render query inputs with user avatar and placeholder strings', () => {
render(
<EditItem
type={EditItemType.Query}
content="Why?"
onChange={jest.fn()}
/>,
)
expect(screen.getByText('appAnnotation.addModal.queryName')).toBeInTheDocument()
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toBeInTheDocument()
expect(screen.getByText('Why?')).toBeInTheDocument()
})
test('should render answer name and placeholder text', () => {
render(
<EditItem
type={EditItemType.Answer}
content="Existing answer"
onChange={jest.fn()}
/>,
)
expect(screen.getByText('appAnnotation.addModal.answerName')).toBeInTheDocument()
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing answer')).toBeInTheDocument()
})
test('should propagate changes when answer content updates', () => {
const handleChange = jest.fn()
render(
<EditItem
type={EditItemType.Answer}
content=""
onChange={handleChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { target: { value: 'Because' } })
expect(handleChange).toHaveBeenCalledWith('Because')
})
})

View File

@ -1,155 +0,0 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import AddAnnotationModal from './index'
import { useProviderContext } from '@/context/provider-context'
jest.mock('@/context/provider-context', () => ({
useProviderContext: jest.fn(),
}))
const mockToastNotify = jest.fn()
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(args => mockToastNotify(args)),
},
}))
jest.mock('@/app/components/billing/annotation-full', () => () => <div data-testid="annotation-full" />)
const mockUseProviderContext = useProviderContext as jest.Mock
const getProviderContext = ({ usage = 0, total = 10, enableBilling = false } = {}) => ({
plan: {
usage: { annotatedResponse: usage },
total: { annotatedResponse: total },
},
enableBilling,
})
describe('AddAnnotationModal', () => {
const baseProps = {
isShow: true,
onHide: jest.fn(),
onAdd: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
mockUseProviderContext.mockReturnValue(getProviderContext())
})
const typeQuestion = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder'), {
target: { value },
})
}
const typeAnswer = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), {
target: { value },
})
}
test('should render modal title when drawer is visible', () => {
render(<AddAnnotationModal {...baseProps} />)
expect(screen.getByText('appAnnotation.addModal.title')).toBeInTheDocument()
})
test('should capture query input text when typing', () => {
render(<AddAnnotationModal {...baseProps} />)
typeQuestion('Sample question')
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('Sample question')
})
test('should capture answer input text when typing', () => {
render(<AddAnnotationModal {...baseProps} />)
typeAnswer('Sample answer')
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('Sample answer')
})
test('should show annotation full notice and disable submit when quota exceeded', () => {
mockUseProviderContext.mockReturnValue(getProviderContext({ usage: 10, total: 10, enableBilling: true }))
render(<AddAnnotationModal {...baseProps} />)
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
})
test('should call onAdd with form values when create next enabled', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Question value')
typeAnswer('Answer value')
fireEvent.click(screen.getByTestId('checkbox-create-next-checkbox'))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(onAdd).toHaveBeenCalledWith({ question: 'Question value', answer: 'Answer value' })
})
test('should reset fields after saving when create next enabled', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Question value')
typeAnswer('Answer value')
const createNextToggle = screen.getByText('appAnnotation.addModal.createNext').previousElementSibling as HTMLElement
fireEvent.click(createNextToggle)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('')
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('')
})
})
test('should show toast when validation fails for missing question', () => {
render(<AddAnnotationModal {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appAnnotation.errorMessage.queryRequired',
}))
})
test('should show toast when validation fails for missing answer', () => {
render(<AddAnnotationModal {...baseProps} />)
typeQuestion('Filled question')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appAnnotation.errorMessage.answerRequired',
}))
})
test('should close modal when save completes and create next unchecked', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Q')
typeAnswer('A')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(baseProps.onHide).toHaveBeenCalled()
})
test('should allow cancel button to close the drawer', () => {
render(<AddAnnotationModal {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(baseProps.onHide).toHaveBeenCalled()
})
})

View File

@ -101,7 +101,7 @@ const AddAnnotationModal: FC<Props> = ({
<div
className='flex items-center space-x-2'
>
<Checkbox id='create-next-checkbox' checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<Checkbox checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<div>{t('appAnnotation.addModal.createNext')}</div>
</div>
<div className='mt-2 flex space-x-2'>

View File

@ -1,397 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EditItem, { EditItemType, EditTitle } from './index'
describe('EditTitle', () => {
it('should render title content correctly', () => {
// Arrange
const props = { title: 'Test Title' }
// Act
render(<EditTitle {...props} />)
// Assert
expect(screen.getByText(/test title/i)).toBeInTheDocument()
// Should contain edit icon (svg element)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('should apply custom className when provided', () => {
// Arrange
const props = {
title: 'Test Title',
className: 'custom-class',
}
// Act
const { container } = render(<EditTitle {...props} />)
// Assert
expect(screen.getByText(/test title/i)).toBeInTheDocument()
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
})
describe('EditItem', () => {
const defaultProps = {
type: EditItemType.Query,
content: 'Test content',
onSave: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render content correctly', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/test content/i)).toBeInTheDocument()
// Should show item name (query or answer)
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
})
it('should render different item types correctly', () => {
// Arrange
const props = {
...defaultProps,
type: EditItemType.Answer,
content: 'Answer content',
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/answer content/i)).toBeInTheDocument()
expect(screen.getByText('appAnnotation.editModal.answerName')).toBeInTheDocument()
})
it('should show edit controls when not readonly', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
})
it('should hide edit controls when readonly', () => {
// Arrange
const props = {
...defaultProps,
readonly: true,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should respect readonly prop for edit functionality', () => {
// Arrange
const props = {
...defaultProps,
readonly: true,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/test content/i)).toBeInTheDocument()
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
it('should display provided content', () => {
// Arrange
const props = {
...defaultProps,
content: 'Custom content for testing',
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/custom content for testing/i)).toBeInTheDocument()
})
it('should render appropriate content based on type', () => {
// Arrange
const props = {
...defaultProps,
type: EditItemType.Query,
content: 'Question content',
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(/question content/i)).toBeInTheDocument()
expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should activate edit mode when edit button is clicked', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should save new content when save button is clicked', async () => {
// Arrange
const mockSave = jest.fn().mockResolvedValue(undefined)
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
// Type new content
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Updated content')
// Save
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockSave).toHaveBeenCalledWith('Updated content')
})
it('should exit edit mode when cancel button is clicked', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
// Assert
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText(/test content/i)).toBeInTheDocument()
})
it('should show content preview while typing', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.type(textarea, 'New content')
// Assert
expect(screen.getByText(/new content/i)).toBeInTheDocument()
})
it('should call onSave with correct content when saving', async () => {
// Arrange
const mockSave = jest.fn().mockResolvedValue(undefined)
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Test save content')
// Save
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockSave).toHaveBeenCalledWith('Test save content')
})
it('should show delete option when content changes', async () => {
// Arrange
const mockSave = jest.fn().mockResolvedValue(undefined)
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
// Enter edit mode and change content
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified content')
// Save to trigger content change
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockSave).toHaveBeenCalledWith('Modified content')
})
it('should handle keyboard interactions in edit mode', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
// Test typing
await user.type(textarea, 'Keyboard test')
// Assert
expect(textarea).toHaveValue('Keyboard test')
expect(screen.getByText(/keyboard test/i)).toBeInTheDocument()
})
})
// State Management
describe('State Management', () => {
it('should reset newContent when content prop changes', async () => {
// Arrange
const { rerender } = render(<EditItem {...defaultProps} />)
// Act - Enter edit mode and type something
const user = userEvent.setup()
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New content')
// Rerender with new content prop
rerender(<EditItem {...defaultProps} content="Updated content" />)
// Assert - Textarea value should be reset due to useEffect
expect(textarea).toHaveValue('')
})
it('should preserve edit state across content changes', async () => {
// Arrange
const { rerender } = render(<EditItem {...defaultProps} />)
const user = userEvent.setup()
// Act - Enter edit mode
await user.click(screen.getByText('common.operation.edit'))
// Rerender with new content
rerender(<EditItem {...defaultProps} content="Updated content" />)
// Assert - Should still be in edit mode
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle empty content', () => {
// Arrange
const props = {
...defaultProps,
content: '',
}
// Act
const { container } = render(<EditItem {...props} />)
// Assert - Should render without crashing
// Check that the component renders properly with empty content
expect(container.querySelector('.grow')).toBeInTheDocument()
// Should still show edit button
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
})
it('should handle very long content', () => {
// Arrange
const longContent = 'A'.repeat(1000)
const props = {
...defaultProps,
content: longContent,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(longContent)).toBeInTheDocument()
})
it('should handle content with special characters', () => {
// Arrange
const specialContent = 'Content with & < > " \' characters'
const props = {
...defaultProps,
content: specialContent,
}
// Act
render(<EditItem {...props} />)
// Assert
expect(screen.getByText(specialContent)).toBeInTheDocument()
})
it('should handle rapid edit/cancel operations', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
// Rapid edit/cancel operations
await user.click(screen.getByText('common.operation.edit'))
await user.click(screen.getByText('common.operation.cancel'))
await user.click(screen.getByText('common.operation.edit'))
await user.click(screen.getByText('common.operation.cancel'))
// Assert
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('Test content')).toBeInTheDocument()
})
})
})

View File

@ -1,408 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast'
import EditAnnotationModal from './index'
// Mock only external dependencies
jest.mock('@/service/annotation', () => ({
addAnnotation: jest.fn(),
editAnnotation: jest.fn(),
}))
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: {
usage: { annotatedResponse: 5 },
total: { annotatedResponse: 10 },
},
enableBilling: true,
}),
}))
jest.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: () => '2023-12-01 10:30:00',
}),
}))
// Note: i18n is automatically mocked by Jest via __mocks__/react-i18next.ts
jest.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
type ToastNotifyProps = Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className' | 'customComponent' | 'onClose'>
type ToastWithNotify = typeof Toast & { notify: (props: ToastNotifyProps) => ToastHandle }
const toastWithNotify = Toast as unknown as ToastWithNotify
const toastNotifySpy = jest.spyOn(toastWithNotify, 'notify').mockReturnValue({ clear: jest.fn() })
const { addAnnotation: mockAddAnnotation, editAnnotation: mockEditAnnotation } = jest.requireMock('@/service/annotation') as {
addAnnotation: jest.Mock
editAnnotation: jest.Mock
}
describe('EditAnnotationModal', () => {
const defaultProps = {
isShow: true,
onHide: jest.fn(),
appId: 'test-app-id',
query: 'Test query',
answer: 'Test answer',
onEdited: jest.fn(),
onAdded: jest.fn(),
onRemove: jest.fn(),
}
afterAll(() => {
toastNotifySpy.mockRestore()
})
beforeEach(() => {
jest.clearAllMocks()
mockAddAnnotation.mockResolvedValue({
id: 'test-id',
account: { name: 'Test User' },
})
mockEditAnnotation.mockResolvedValue({})
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render modal when isShow is true', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Check for modal title as it appears in the mock
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
})
it('should not render modal when isShow is false', () => {
// Arrange
const props = { ...defaultProps, isShow: false }
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.queryByText('appAnnotation.editModal.title')).not.toBeInTheDocument()
})
it('should display query and answer sections', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Look for query and answer content
expect(screen.getByText('Test query')).toBeInTheDocument()
expect(screen.getByText('Test answer')).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should handle different query and answer content', () => {
// Arrange
const props = {
...defaultProps,
query: 'Custom query content',
answer: 'Custom answer content',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Check content is displayed
expect(screen.getByText('Custom query content')).toBeInTheDocument()
expect(screen.getByText('Custom answer content')).toBeInTheDocument()
})
it('should show remove option when annotationId is provided', () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Remove option should be present (using pattern)
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should enable editing for query and answer sections', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Edit links should be visible (using text content)
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
expect(editLinks).toHaveLength(2)
})
it('should show remove option when annotationId is provided', () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
})
it('should save content when edited', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
const user = userEvent.setup()
// Mock API response
mockAddAnnotation.mockResolvedValueOnce({
id: 'test-annotation-id',
account: { name: 'Test User' },
})
// Act
render(<EditAnnotationModal {...props} />)
// Find and click edit link for query
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
// Find textarea and enter new content
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New query content')
// Click save button
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
question: 'New query content',
answer: 'Test answer',
message_id: undefined,
})
})
})
// API Calls
describe('API Calls', () => {
it('should call addAnnotation when saving new annotation', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
const user = userEvent.setup()
// Mock the API response
mockAddAnnotation.mockResolvedValueOnce({
id: 'test-annotation-id',
account: { name: 'Test User' },
})
// Act
render(<EditAnnotationModal {...props} />)
// Edit query content
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Updated query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
question: 'Updated query',
answer: 'Test answer',
message_id: undefined,
})
})
it('should call editAnnotation when updating existing annotation', async () => {
// Arrange
const mockOnEdited = jest.fn()
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
messageId: 'test-message-id',
onEdited: mockOnEdited,
}
const user = userEvent.setup()
// Act
render(<EditAnnotationModal {...props} />)
// Edit query content
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
expect(mockEditAnnotation).toHaveBeenCalledWith(
'test-app-id',
'test-annotation-id',
{
message_id: 'test-message-id',
question: 'Modified query',
answer: 'Test answer',
},
)
})
})
// State Management
describe('State Management', () => {
it('should initialize with closed confirm modal', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Confirm dialog should not be visible initially
expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument()
})
it('should show confirm modal when remove is clicked', async () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
const user = userEvent.setup()
// Act
render(<EditAnnotationModal {...props} />)
await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
// Assert - Confirmation dialog should appear
expect(screen.getByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
})
it('should call onRemove when removal is confirmed', async () => {
// Arrange
const mockOnRemove = jest.fn()
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
onRemove: mockOnRemove,
}
const user = userEvent.setup()
// Act
render(<EditAnnotationModal {...props} />)
// Click remove
await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
// Click confirm
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
await user.click(confirmButton)
// Assert
expect(mockOnRemove).toHaveBeenCalled()
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle empty query and answer', () => {
// Arrange
const props = {
...defaultProps,
query: '',
answer: '',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
})
it('should handle very long content', () => {
// Arrange
const longQuery = 'Q'.repeat(1000)
const longAnswer = 'A'.repeat(1000)
const props = {
...defaultProps,
query: longQuery,
answer: longAnswer,
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText(longQuery)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
it('should handle special characters in content', () => {
// Arrange
const specialQuery = 'Query with & < > " \' characters'
const specialAnswer = 'Answer with & < > " \' characters'
const props = {
...defaultProps,
query: specialQuery,
answer: specialAnswer,
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert
expect(screen.getByText(specialQuery)).toBeInTheDocument()
expect(screen.getByText(specialAnswer)).toBeInTheDocument()
})
it('should handle onlyEditResponse prop', () => {
// Arrange
const props = {
...defaultProps,
onlyEditResponse: true,
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Query should be readonly, answer should be editable
const editLinks = screen.queryAllByText(/common\.operation\.edit/i)
expect(editLinks).toHaveLength(1) // Only answer should have edit button
})
})
})

View File

@ -1,22 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import CannotQueryDataset from './cannot-query-dataset'
describe('CannotQueryDataset WarningMask', () => {
test('should render dataset warning copy and action button', () => {
const onConfirm = jest.fn()
render(<CannotQueryDataset onConfirm={onConfirm} />)
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })).toBeInTheDocument()
})
test('should invoke onConfirm when OK button clicked', () => {
const onConfirm = jest.fn()
render(<CannotQueryDataset onConfirm={onConfirm} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,39 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import FormattingChanged from './formatting-changed'
describe('FormattingChanged WarningMask', () => {
test('should display translation text and both actions', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
render(
<FormattingChanged
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument()
expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.refresh/ })).toBeInTheDocument()
})
test('should call callbacks when buttons are clicked', () => {
const onConfirm = jest.fn()
const onCancel = jest.fn()
render(
<FormattingChanged
onConfirm={onConfirm}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,26 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import HasNotSetAPI from './has-not-set-api'
describe('HasNotSetAPI WarningMask', () => {
test('should show default title when trial not finished', () => {
render(<HasNotSetAPI isTrailFinished={false} onSetting={jest.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
})
test('should show trail finished title when flag is true', () => {
render(<HasNotSetAPI isTrailFinished onSetting={jest.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
})
test('should call onSetting when primary button clicked', () => {
const onSetting = jest.fn()
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
expect(onSetting).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,25 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import WarningMask from './index'
describe('WarningMask', () => {
// Rendering of title, description, and footer content
describe('Rendering', () => {
test('should display provided title, description, and footer node', () => {
const footer = <button type="button">Retry</button>
// Arrange
render(
<WarningMask
title="Access Restricted"
description="Only workspace owners may modify this section."
footer={footer}
/>,
)
// Assert
expect(screen.getByText('Access Restricted')).toBeInTheDocument()
expect(screen.getByText('Only workspace owners may modify this section.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
})
})

View File

@ -1,121 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ConfigString, { type IConfigStringProps } from './index'
const renderConfigString = (props?: Partial<IConfigStringProps>) => {
const onChange = jest.fn()
const defaultProps: IConfigStringProps = {
value: 5,
maxLength: 10,
modelId: 'model-id',
onChange,
}
render(<ConfigString {...defaultProps} {...props} />)
return { onChange }
}
describe('ConfigString', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render numeric input with bounds', () => {
renderConfigString({ value: 3, maxLength: 8 })
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(3)
expect(input).toHaveAttribute('min', '1')
expect(input).toHaveAttribute('max', '8')
})
it('should render empty input when value is undefined', () => {
const { onChange } = renderConfigString({ value: undefined })
expect(screen.getByRole('spinbutton')).toHaveValue(null)
expect(onChange).not.toHaveBeenCalled()
})
})
describe('Effect behavior', () => {
it('should clamp initial value to maxLength when it exceeds limit', async () => {
const onChange = jest.fn()
render(
<ConfigString
value={15}
maxLength={10}
modelId="model-id"
onChange={onChange}
/>,
)
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(10)
})
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should clamp when updated prop value exceeds maxLength', async () => {
const onChange = jest.fn()
const { rerender } = render(
<ConfigString
value={4}
maxLength={6}
modelId="model-id"
onChange={onChange}
/>,
)
rerender(
<ConfigString
value={9}
maxLength={6}
modelId="model-id"
onChange={onChange}
/>,
)
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(6)
})
expect(onChange).toHaveBeenCalledTimes(1)
})
})
describe('User interactions', () => {
it('should clamp entered value above maxLength', () => {
const { onChange } = renderConfigString({ maxLength: 7 })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
expect(onChange).toHaveBeenCalledWith(7)
})
it('should raise value below minimum to one', () => {
const { onChange } = renderConfigString()
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0' } })
expect(onChange).toHaveBeenCalledWith(1)
})
it('should forward parsed value when within bounds', () => {
const { onChange } = renderConfigString({ maxLength: 9 })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
expect(onChange).toHaveBeenCalledWith(7)
})
it('should pass through NaN when input is cleared', () => {
const { onChange } = renderConfigString()
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange.mock.calls[0][0]).toBeNaN()
})
})
})

View File

@ -1,45 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SelectTypeItem from './index'
import { InputVarType } from '@/app/components/workflow/types'
describe('SelectTypeItem', () => {
// Rendering pathways based on type and selection state
describe('Rendering', () => {
test('should render ok', () => {
// Arrange
const { container } = render(
<SelectTypeItem
type={InputVarType.textInput}
selected={false}
onClick={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
// User interaction outcomes
describe('Interactions', () => {
test('should trigger onClick when item is pressed', () => {
const handleClick = jest.fn()
// Arrange
render(
<SelectTypeItem
type={InputVarType.paragraph}
selected={false}
onClick={handleClick}
/>,
)
// Act
fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -1,142 +0,0 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import Authorize from '@/app/components/plugins/plugin-auth/authorize'
import Authorized from '@/app/components/plugins/plugin-auth/authorized'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { usePluginAuth } from '@/app/components/plugins/plugin-auth/hooks/use-plugin-auth'
import type { Credential } from '@/app/components/plugins/plugin-auth/types'
import cn from '@/utils/classnames'
import type { CollectionType } from '@/app/components/tools/types'
type GroupAuthControlProps = {
providerId: string
providerName: string
providerType: CollectionType
credentialId?: string
onChange: (credentialId: string) => void
}
const GroupAuthControl: FC<GroupAuthControlProps> = ({
providerId,
providerName,
providerType,
credentialId,
onChange,
}) => {
const { t } = useTranslation()
const {
isAuthorized,
canOAuth,
canApiKey,
credentials,
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
} = usePluginAuth({
provider: providerName,
providerType,
category: AuthCategory.tool,
detail: { id: providerId, name: providerName, type: providerType } as any,
}, true)
const extraAuthorizationItems: Credential[] = [
{
id: '__workspace_default__',
name: t('plugin.auth.workspaceDefault'),
provider: '',
is_default: !credentialId,
isWorkspaceDefault: true,
},
]
const handleAuthorizationItemClick = useCallback((id: string) => {
onChange(id === '__workspace_default__' ? '' : id)
}, [onChange])
const renderTrigger = useCallback((open?: boolean) => {
let label = ''
let removed = false
let unavailable = false
let color = 'green'
if (!credentialId) {
label = t('plugin.auth.workspaceDefault')
}
else {
const credential = credentials.find(c => c.id === credentialId)
label = credential ? credential.name : t('plugin.auth.authRemoved')
removed = !credential
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
if (removed)
color = 'red'
else if (unavailable)
color = 'gray'
}
return (
<Button
className={cn(
'h-9',
open && 'bg-components-button-secondary-bg-hover',
removed && 'text-text-destructive',
)}
variant='secondary'
size='small'
>
<Indicator className='mr-2' color={color as any} />
{label}
{
unavailable && t('plugin.auth.unavailable')
}
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
</Button>
)
}, [credentialId, credentials, t])
if (!isAuthorized) {
return (
<Authorize
pluginPayload={{
provider: providerName,
providerType,
category: AuthCategory.tool,
detail: { id: providerId, name: providerName, type: providerType } as any,
}}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
return (
<Authorized
pluginPayload={{
provider: providerName,
providerType,
category: AuthCategory.tool,
detail: { id: providerId, name: providerName, type: providerType } as any,
}}
credentials={credentials}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
disableSetDefault
onItemClick={handleAuthorizationItemClick}
extraAuthorizationItems={extraAuthorizationItems}
showItemSelectedIcon
renderTrigger={renderTrigger}
selectedCredentialId={credentialId || '__workspace_default__'}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
/>
)
}
export default React.memo(GroupAuthControl)

View File

@ -6,7 +6,6 @@ import { useContext } from 'use-context-selector'
import copy from 'copy-to-clipboard'
import { produce } from 'immer'
import {
RiArrowDownSLine,
RiDeleteBinLine,
RiEqualizer2Line,
RiInformation2Line,
@ -25,6 +24,7 @@ import { type Collection, CollectionType } from '@/app/components/tools/types'
import { MAX_TOOLS_NUM } from '@/config'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import cn from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
@ -33,9 +33,8 @@ import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTo
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useMittContextSelector } from '@/context/mitt-context'
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
const AgentTools: FC = () => {
const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
@ -94,13 +93,6 @@ const AgentTools: FC = () => {
}
const [isDeleting, setIsDeleting] = useState<number>(-1)
const [expandedProviders, setExpandedProviders] = useState<Record<string, boolean>>({})
const toggleProviderExpand = useCallback((providerId: string) => {
setExpandedProviders(prev => ({
...prev,
[providerId]: !prev[providerId],
}))
}, [])
const getToolValue = (tool: ToolDefaultValue) => {
const currToolInCollections = collectionList.find(c => c.id === tool.provider_id)
const currToolWithConfigs = currToolInCollections?.tools.find(t => t.name === tool.tool_name)
@ -115,9 +107,7 @@ const AgentTools: FC = () => {
tool_parameters: paramsWithDefaultValue,
notAuthor: !tool.is_team_authorization,
enabled: true,
use_end_user_credentials: false,
end_user_credential_type: '',
} as any
}
}
const handleSelectTool = (tool: ToolDefaultValue) => {
const newModelConfig = produce(modelConfig, (draft) => {
@ -153,34 +143,6 @@ const AgentTools: FC = () => {
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).use_end_user_credentials = enabled
})
setCurrentTool({
...currentTool,
use_end_user_credentials: enabled,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).end_user_credential_type = type
})
setCurrentTool({
...currentTool,
end_user_credential_type: type,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
return (
<>
<Panel
@ -220,307 +182,135 @@ const AgentTools: FC = () => {
</div>
}
>
<div className='space-y-2'>
{Object.values(
tools.reduce((acc, item, idx) => {
const key = item.provider_id
if (!acc[key]) {
acc[key] = {
providerId: item.provider_id,
providerName: getProviderShowName(item) || '',
icon: item.icon,
providerType: item.provider_type,
tools: [] as (AgentTool & { __index: number })[],
}
}
acc[key].tools.push({ ...item, __index: idx })
return acc
}, {} as Record<string, { providerId: string; providerName: string; providerType: CollectionType; icon: any; tools: (AgentTool & { __index: number })[] }>),
).map(group => (
<div
key={group.providerId}
className='rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2 shadow-xs'
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
<div key={index}
className={cn(
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}
>
<div
className='flex cursor-pointer items-center gap-2 px-1'
onClick={() => toggleProviderExpand(group.providerId)}
>
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-tertiary transition-transform',
!expandedProviders[group.providerId] && '-rotate-90',
)}
/>
{typeof group.icon === 'string'
? <div className='h-5 w-5 shrink-0 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${group.icon})` }} />
: <AppIcon className='shrink-0 rounded-md' size='xs' icon={group.icon?.content} background={group.icon?.background} />}
<div className='system-sm-semibold truncate text-text-secondary'>{group.providerName}</div>
<div className='ml-auto flex shrink-0 items-center gap-2'>
<div className='system-xs-regular text-text-tertiary'>
{group.tools.filter(tool => tool.enabled).length}/{group.tools.length}&nbsp;{t('appDebug.agent.tools.enabled')}
<div className='flex w-0 grow items-center'>
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
{!item.isDeleted && (
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
</div>
{group.tools.every(tool => tool.notAuthor) && (
<Button
variant='secondary'
size='small'
onClick={(e) => {
e.stopPropagation()
const first = group.tools[0]
setCurrentTool(first as any)
setIsShowSettingTool(true)
}}
)}
<div
className={cn(
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
)}
>
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
<span className='text-text-tertiary'>{item.tool_label}</span>
{!item.isDeleted && (
<Tooltip
popupContent={
<div className='w-[180px]'>
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
</div>
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</Tooltip>
)}
</div>
</div>
<div className='ml-1 flex shrink-0 items-center'>
{item.isDeleted && (
<div className='mr-2 flex items-center'>
<Tooltip
popupContent={t('tools.toolRemoved')}
>
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
</div>
</Tooltip>
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
{!item.isDeleted && (
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
{!item.notAuthor && (
<Tooltip
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
needsDelay={false}
>
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
)}
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
size='md'
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
(draft.agentConfig.tools[index] as any).enabled = enabled
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}} />
)}
{item.notAuthor && (
<Button variant='secondary' size='small' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
{t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' />
</Button>
)}
</div>
</div>
<div className={cn('space-y-1', expandedProviders[group.providerId] ? 'mt-1' : 'hidden')}>
{group.tools.map(item => (
<div
key={`${item.provider_id}-${item.tool_name}`}
className={cn(
'group relative flex w-full items-center justify-between rounded-lg pl-[21px] pr-2 hover:bg-state-base-hover',
isDeleting === item.__index && 'border border-state-destructive-border hover:bg-state-destructive-hover',
)}
>
<div className='flex w-0 grow items-center'>
<div
className={cn(
'system-xs-regular flex w-0 grow items-center truncate border-l-2 border-divider-subtle pl-4',
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
)}
>
<span className='system-xs-medium pr-1.5 text-text-secondary'>{item.tool_label}</span>
<span className='text-text-tertiary'>{item.tool_name}</span>
{!item.isDeleted && (
<Tooltip
popupContent={
<div className='w-[180px]'>
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
</div>
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</Tooltip>
)}
</div>
</div>
<div className='flex shrink-0 items-center space-x-2'>
{item.isDeleted && (
<div className='mr-2 flex items-center'>
<Tooltip
popupContent={t('tools.toolRemoved')}
>
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
</div>
</Tooltip>
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(item.__index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(item.__index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
{!item.isDeleted && (
<div className='pointer-events-none mr-2 flex items-center gap-1 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100'>
{!item.notAuthor && (
<Tooltip
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
>
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
setCurrentTool(item as any)
setIsShowSettingTool(true)
}}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
)}
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(item.__index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(item.__index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
size='md'
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
(draft.agentConfig.tools[item.__index] as any).enabled = enabled
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}} />
)}
</div>
</div>
</div>
))}
</div>
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
<div key={index}
className={cn(
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}
>
<div className='flex w-0 grow items-center'>
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
{!item.isDeleted && (
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
</div>
)}
<div
className={cn(
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
)}
>
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
<span className='text-text-tertiary'>{item.tool_label}</span>
{!item.isDeleted && (
<Tooltip
popupContent={
<div className='w-[180px]'>
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
</div>
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</Tooltip>
)}
</div>
</div>
<div className='ml-1 flex shrink-0 items-center'>
{item.isDeleted && (
<div className='mr-2 flex items-center'>
<Tooltip
popupContent={t('tools.toolRemoved')}
>
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
</div>
</Tooltip>
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
{!item.isDeleted && (
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
{!item.notAuthor && (
<Tooltip
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
needsDelay={false}
>
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
)}
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
size='md'
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
(draft.agentConfig.tools[index] as any).enabled = enabled
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}} />
)}
{item.notAuthor && (
<Button variant='secondary' size='small' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
{t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' />
</Button>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</Panel>
</div >
</Panel >
{isShowSettingTool && (
<SettingBuiltInTool
toolName={currentTool?.tool_name as string}
@ -531,10 +321,6 @@ const AgentTools: FC = () => {
onHide={() => setIsShowSettingTool(false)}
credentialId={currentTool?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
useEndUserCredentialEnabled={currentTool?.use_end_user_credentials}
endUserCredentialType={currentTool?.end_user_credential_type}
onEndUserCredentialChange={handleEndUserCredentialChange}
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
/>
)}
</>

View File

@ -42,10 +42,6 @@ type Props = {
onSave?: (value: Record<string, any>) => void
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
useEndUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialChange?: (enabled: boolean) => void
onEndUserCredentialTypeChange?: (type: string) => void
}
const SettingBuiltInTool: FC<Props> = ({
@ -60,10 +56,6 @@ const SettingBuiltInTool: FC<Props> = ({
onSave,
credentialId,
onAuthorizationItemClick,
useEndUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialChange,
onEndUserCredentialTypeChange,
}) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
@ -224,10 +216,6 @@ const SettingBuiltInTool: FC<Props> = ({
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
endUserCredentialType={endUserCredentialType}
onEndUserCredentialChange={onEndUserCredentialChange}
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
/>
)
}

View File

@ -1,242 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Item from './index'
import type React from 'react'
import type { DataSet } from '@/models/datasets'
import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets'
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { RetrievalConfig } from '@/types/app'
import { RETRIEVE_METHOD } from '@/types/app'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
jest.mock('../settings-modal', () => ({
__esModule: true,
default: ({ onSave, onCancel, currentDataset }: any) => (
<div>
<div>Mock settings modal</div>
<button onClick={() => onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes</button>
<button onClick={onCancel}>Close</button>
</div>
),
}))
jest.mock('@/hooks/use-breakpoints', () => {
const actual = jest.requireActual('@/hooks/use-breakpoints')
return {
__esModule: true,
...actual,
default: jest.fn(() => actual.MediaType.pc),
}
})
const mockedUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints>
const baseRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'provider',
reranking_model_name: 'rerank-model',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
}
const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => {
const {
retrieval_model,
retrieval_model_dict,
icon_info,
...restOverrides
} = overrides
const resolvedRetrievalModelDict = {
...baseRetrievalConfig,
...retrieval_model_dict,
}
const resolvedRetrievalModel = {
...baseRetrievalConfig,
...(retrieval_model ?? retrieval_model_dict),
}
const defaultIconInfo = {
icon: '📘',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
}
const resolvedIconInfo = ('icon_info' in overrides)
? icon_info
: defaultIconInfo
return {
id: 'dataset-id',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: resolvedIconInfo as DataSet['icon_info'],
description: 'A test dataset',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: defaultIndexingTechnique,
author_name: 'author',
created_by: 'creator',
updated_by: 'updater',
updated_at: 0,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
total_available_documents: 0,
word_count: 0,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: resolvedRetrievalModelDict,
retrieval_model: resolvedRetrievalModel,
tags: [],
external_knowledge_info: {
external_knowledge_id: 'external-id',
external_knowledge_api_id: 'api-id',
external_knowledge_api_name: 'api-name',
external_knowledge_api_endpoint: 'https://endpoint',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: true,
},
built_in_field_enabled: true,
doc_metadata: [],
keyword_number: 3,
pipeline_id: 'pipeline-id',
is_published: true,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...restOverrides,
}
}
const renderItem = (config: DataSet, props?: Partial<React.ComponentProps<typeof Item>>) => {
const onSave = jest.fn()
const onRemove = jest.fn()
render(
<Item
config={config}
onSave={onSave}
onRemove={onRemove}
{...props}
/>,
)
return { onSave, onRemove }
}
describe('dataset-config/card-item', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedUseBreakpoints.mockReturnValue(MediaType.pc)
})
it('should render dataset details with indexing and external badges', () => {
const dataset = createDataset({
provider: 'external',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
})
renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const actionButtons = within(card).getAllByRole('button', { hidden: true })
expect(screen.getByText(dataset.name)).toBeInTheDocument()
expect(screen.getByText('dataset.indexingTechnique.high_quality · dataset.indexingMethod.semantic_search')).toBeInTheDocument()
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
expect(actionButtons).toHaveLength(2)
})
it('should open settings drawer from edit action and close after saving', async () => {
const user = userEvent.setup()
const dataset = createDataset()
const { onSave } = renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const [editButton] = within(card).getAllByRole('button', { hidden: true })
await user.click(editButton)
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeVisible()
})
await user.click(screen.getByText('Save changes'))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
})
await waitFor(() => {
expect(screen.getByText('Mock settings modal')).not.toBeVisible()
})
})
it('should call onRemove and toggle destructive state on hover', async () => {
const user = userEvent.setup()
const dataset = createDataset()
const { onRemove } = renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const buttons = within(card).getAllByRole('button', { hidden: true })
const deleteButton = buttons[buttons.length - 1]
expect(deleteButton.className).not.toContain('action-btn-destructive')
fireEvent.mouseEnter(deleteButton)
expect(deleteButton.className).toContain('action-btn-destructive')
expect(card.className).toContain('border-state-destructive-border')
fireEvent.mouseLeave(deleteButton)
expect(deleteButton.className).not.toContain('action-btn-destructive')
await user.click(deleteButton)
expect(onRemove).toHaveBeenCalledWith(dataset.id)
})
it('should use default icon information when icon details are missing', () => {
const dataset = createDataset({ icon_info: undefined })
renderItem(dataset)
const nameElement = screen.getByText(dataset.name)
const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement
expect(iconElement).toHaveStyle({ background: '#FFF4ED' })
expect(iconElement.querySelector('em-emoji')).toHaveAttribute('id', '📙')
})
it('should apply mask overlay on mobile when drawer is open', async () => {
mockedUseBreakpoints.mockReturnValue(MediaType.mobile)
const user = userEvent.setup()
const dataset = createDataset()
renderItem(dataset)
const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
const [editButton] = within(card).getAllByRole('button', { hidden: true })
await user.click(editButton)
expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
const overlay = Array.from(document.querySelectorAll('[class]'))
.find(element => element.className.toString().includes('bg-black/30'))
expect(overlay).toBeInTheDocument()
})
})

View File

@ -1,299 +0,0 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ContextVar from './index'
import type { Props } from './var-picker'
// Mock external dependencies only
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn() }),
usePathname: () => '/test',
}))
type PortalToFollowElemProps = {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode; asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
jest.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
return (
<PortalContext.Provider value={{ open: !!open }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
)
}
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const { open } = React.useContext(PortalContext)
if (!open) return null
return (
<div data-testid="portal-content" {...props}>
{children}
</div>
)
}
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props}>
{children}
</div>
)
}
return {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
}
})
describe('ContextVar', () => {
const mockOptions: Props['options'] = [
{ name: 'Variable 1', value: 'var1', type: 'string' },
{ name: 'Variable 2', value: 'var2', type: 'number' },
]
const defaultProps: Props = {
value: 'var1',
options: mockOptions,
onChange: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should display query variable selector when options are provided', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
})
it('should show selected variable with proper formatting when value is provided', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('var1')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should display selected variable when value prop is provided', () => {
// Arrange
const props = { ...defaultProps, value: 'var2' }
// Act
render(<ContextVar {...props} />)
// Assert - Should display the selected value
expect(screen.getByText('var2')).toBeInTheDocument()
})
it('should show placeholder text when no value is selected', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert - Should show placeholder instead of variable
expect(screen.queryByText('var1')).not.toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should display custom tip message when notSelectedVarTip is provided', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
notSelectedVarTip: 'Select a variable',
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('Select a variable')).toBeInTheDocument()
})
it('should apply custom className to VarPicker when provided', () => {
// Arrange
const props = {
...defaultProps,
className: 'custom-class',
}
// Act
const { container } = render(<ContextVar {...props} />)
// Assert
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onChange when user selects a different variable', async () => {
// Arrange
const onChange = jest.fn()
const props = { ...defaultProps, onChange }
const user = userEvent.setup()
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
await user.click(varPickerTrigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Select a different option
const options = screen.getAllByText('var2')
expect(options.length).toBeGreaterThan(0)
await user.click(options[0])
// Assert
expect(onChange).toHaveBeenCalledWith('var2')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should toggle dropdown when clicking the trigger button', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<ContextVar {...props} />)
const triggers = screen.getAllByTestId('portal-trigger')
const varPickerTrigger = triggers[triggers.length - 1]
// Open dropdown
await user.click(varPickerTrigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close dropdown
await user.click(varPickerTrigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined value gracefully', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
expect(screen.queryByText('var1')).not.toBeInTheDocument()
})
it('should handle empty options array', () => {
// Arrange
const props = {
...defaultProps,
options: [],
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle null value without crashing', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle options with different data types', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'String Var', value: 'strVar', type: 'string' },
{ name: 'Number Var', value: '42', type: 'number' },
{ name: 'Boolean Var', value: 'true', type: 'boolean' },
],
value: 'strVar',
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('strVar')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
})
it('should render variable names with special characters safely', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
],
value: 'specialVar',
}
// Act
render(<ContextVar {...props} />)
// Assert
expect(screen.getByText('specialVar')).toBeInTheDocument()
})
})
})

View File

@ -1,392 +0,0 @@
import * as React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import VarPicker, { type Props } from './var-picker'
// Mock external dependencies only
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn() }),
usePathname: () => '/test',
}))
type PortalToFollowElemProps = {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
type PortalToFollowElemTriggerProps = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode; asChild?: boolean }
type PortalToFollowElemContentProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
jest.mock('@/app/components/base/portal-to-follow-elem', () => {
const PortalContext = React.createContext({ open: false })
const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
return (
<PortalContext.Provider value={{ open: !!open }}>
<div data-testid="portal">{children}</div>
</PortalContext.Provider>
)
}
const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
const { open } = React.useContext(PortalContext)
if (!open) return null
return (
<div data-testid="portal-content" {...props}>
{children}
</div>
)
}
const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
...props,
'data-testid': 'portal-trigger',
} as React.HTMLAttributes<HTMLElement>)
}
return (
<div data-testid="portal-trigger" {...props}>
{children}
</div>
)
}
return {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
}
})
describe('VarPicker', () => {
const mockOptions: Props['options'] = [
{ name: 'Variable 1', value: 'var1', type: 'string' },
{ name: 'Variable 2', value: 'var2', type: 'number' },
{ name: 'Variable 3', value: 'var3', type: 'boolean' },
]
const defaultProps: Props = {
value: 'var1',
options: mockOptions,
onChange: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render variable picker with dropdown trigger', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByText('var1')).toBeInTheDocument()
})
it('should display selected variable with type icon when value is provided', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('var1')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
// IconTypeIcon should be rendered (check for svg icon)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('should show placeholder text when no value is selected', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.queryByText('var1')).not.toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should display custom tip message when notSelectedVarTip is provided', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
notSelectedVarTip: 'Select a variable',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('Select a variable')).toBeInTheDocument()
})
it('should render dropdown indicator icon', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert - Trigger should be present
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should apply custom className to wrapper', () => {
// Arrange
const props = {
...defaultProps,
className: 'custom-class',
}
// Act
const { container } = render(<VarPicker {...props} />)
// Assert
expect(container.querySelector('.custom-class')).toBeInTheDocument()
})
it('should apply custom triggerClassName to trigger button', () => {
// Arrange
const props = {
...defaultProps,
triggerClassName: 'custom-trigger-class',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByTestId('portal-trigger')).toHaveClass('custom-trigger-class')
})
it('should display selected value with proper formatting', () => {
// Arrange
const props = {
...defaultProps,
value: 'customVar',
options: [
{ name: 'Custom Variable', value: 'customVar', type: 'string' },
],
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('customVar')).toBeInTheDocument()
expect(screen.getByText('{{')).toBeInTheDocument()
expect(screen.getByText('}}')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should open dropdown when clicking the trigger button', async () => {
// Arrange
const onChange = jest.fn()
const props = { ...defaultProps, onChange }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
await user.click(screen.getByTestId('portal-trigger'))
// Assert
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should call onChange and close dropdown when selecting an option', async () => {
// Arrange
const onChange = jest.fn()
const props = { ...defaultProps, onChange }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
// Open dropdown
await user.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Select a different option
const options = screen.getAllByText('var2')
expect(options.length).toBeGreaterThan(0)
await user.click(options[0])
// Assert
expect(onChange).toHaveBeenCalledWith('var2')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should toggle dropdown when clicking trigger button multiple times', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
const trigger = screen.getByTestId('portal-trigger')
// Open dropdown
await user.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close dropdown
await user.click(trigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
})
// State Management
describe('State Management', () => {
it('should initialize with closed dropdown', () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should toggle dropdown state on trigger click', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
const trigger = screen.getByTestId('portal-trigger')
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
// Open dropdown
await user.click(trigger)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
// Close dropdown
await user.click(trigger)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should preserve selected value when dropdown is closed without selection', async () => {
// Arrange
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<VarPicker {...props} />)
// Open and close dropdown without selecting anything
const trigger = screen.getByTestId('portal-trigger')
await user.click(trigger)
await user.click(trigger)
// Assert
expect(screen.getByText('var1')).toBeInTheDocument() // Original value still displayed
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined value gracefully', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should handle empty options array', () => {
// Arrange
const props = {
...defaultProps,
options: [],
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle null value without crashing', () => {
// Arrange
const props = {
...defaultProps,
value: undefined,
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
})
it('should handle variable names with special characters safely', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
],
value: 'specialVar',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('specialVar')).toBeInTheDocument()
})
it('should handle long variable names', () => {
// Arrange
const props = {
...defaultProps,
options: [
{ name: 'A very long variable name that should be truncated', value: 'longVar', type: 'string' },
],
value: 'longVar',
}
// Act
render(<VarPicker {...props} />)
// Assert
expect(screen.getByText('longVar')).toBeInTheDocument()
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
})
})

View File

@ -1,347 +0,0 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AppCard from './index'
import type { AppIconType } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import type { App } from '@/models/explore'
jest.mock('@heroicons/react/20/solid', () => ({
PlusIcon: ({ className }: any) => <div data-testid="plus-icon" className={className} aria-label="Add icon">+</div>,
}))
const mockApp: App = {
app: {
id: 'test-app-id',
mode: AppModeEnum.CHAT,
icon_type: 'emoji' as AppIconType,
icon: '🤖',
icon_background: '#FFEAD5',
icon_url: '',
name: 'Test Chat App',
description: 'A test chat application for demonstration purposes',
use_icon_as_answer_icon: false,
},
app_id: 'test-app-id',
description: 'A comprehensive chat application template',
copyright: 'Test Corp',
privacy_policy: null,
custom_disclaimer: null,
category: 'Assistant',
position: 1,
is_listed: true,
install_count: 100,
installed: false,
editable: true,
is_agent: false,
}
describe('AppCard', () => {
const defaultProps = {
app: mockApp,
canCreate: true,
onCreate: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<AppCard {...defaultProps} />)
expect(container.querySelector('em-emoji')).toBeInTheDocument()
expect(screen.getByText('Test Chat App')).toBeInTheDocument()
expect(screen.getByText(mockApp.description)).toBeInTheDocument()
})
it('should render app type icon and label', () => {
const { container } = render(<AppCard {...defaultProps} />)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
})
})
describe('Props', () => {
describe('canCreate behavior', () => {
it('should show create button when canCreate is true', () => {
render(<AppCard {...defaultProps} canCreate={true} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
expect(button).toBeInTheDocument()
})
it('should hide create button when canCreate is false', () => {
render(<AppCard {...defaultProps} canCreate={false} />)
const button = screen.queryByRole('button', { name: /app\.newApp\.useTemplate/ })
expect(button).not.toBeInTheDocument()
})
})
it('should display app name from appBasicInfo', () => {
const customApp = {
...mockApp,
app: {
...mockApp.app,
name: 'Custom App Name',
},
}
render(<AppCard {...defaultProps} app={customApp} />)
expect(screen.getByText('Custom App Name')).toBeInTheDocument()
})
it('should display app description from app level', () => {
const customApp = {
...mockApp,
description: 'Custom description for the app',
}
render(<AppCard {...defaultProps} app={customApp} />)
expect(screen.getByText('Custom description for the app')).toBeInTheDocument()
})
it('should truncate long app names', () => {
const longNameApp = {
...mockApp,
app: {
...mockApp.app,
name: 'This is a very long app name that should be truncated with line-clamp-1',
},
}
render(<AppCard {...defaultProps} app={longNameApp} />)
const nameElement = screen.getByTitle('This is a very long app name that should be truncated with line-clamp-1')
expect(nameElement).toBeInTheDocument()
})
})
describe('App Modes - Data Driven Tests', () => {
const testCases = [
{
mode: AppModeEnum.CHAT,
expectedLabel: 'app.typeSelector.chatbot',
description: 'Chat application mode',
},
{
mode: AppModeEnum.AGENT_CHAT,
expectedLabel: 'app.typeSelector.agent',
description: 'Agent chat mode',
},
{
mode: AppModeEnum.COMPLETION,
expectedLabel: 'app.typeSelector.completion',
description: 'Completion mode',
},
{
mode: AppModeEnum.ADVANCED_CHAT,
expectedLabel: 'app.typeSelector.advanced',
description: 'Advanced chat mode',
},
{
mode: AppModeEnum.WORKFLOW,
expectedLabel: 'app.typeSelector.workflow',
description: 'Workflow mode',
},
]
testCases.forEach(({ mode, expectedLabel, description }) => {
it(`should display correct type label for ${description}`, () => {
const appWithMode = {
...mockApp,
app: {
...mockApp.app,
mode,
},
}
render(<AppCard {...defaultProps} app={appWithMode} />)
expect(screen.getByText(expectedLabel)).toBeInTheDocument()
})
})
})
describe('Icon Type Tests', () => {
it('should render emoji icon without image element', () => {
const appWithIcon = {
...mockApp,
app: {
...mockApp.app,
icon_type: 'emoji' as AppIconType,
icon: '🤖',
},
}
const { container } = render(<AppCard {...defaultProps} app={appWithIcon} />)
const card = container.firstElementChild as HTMLElement
expect(within(card).queryByRole('img', { name: 'app icon' })).not.toBeInTheDocument()
expect(card.querySelector('em-emoji')).toBeInTheDocument()
})
it('should prioritize icon_url when both icon and icon_url are provided', () => {
const appWithImageUrl = {
...mockApp,
app: {
...mockApp.app,
icon_type: 'image' as AppIconType,
icon: 'local-icon.png',
icon_url: 'https://example.com/remote-icon.png',
},
}
render(<AppCard {...defaultProps} app={appWithImageUrl} />)
expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute('src', 'https://example.com/remote-icon.png')
})
})
describe('User Interactions', () => {
it('should call onCreate when create button is clicked', async () => {
const mockOnCreate = jest.fn()
render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
await userEvent.click(button)
expect(mockOnCreate).toHaveBeenCalledTimes(1)
})
it('should handle click on card itself', async () => {
const mockOnCreate = jest.fn()
const { container } = render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
const card = container.firstElementChild as HTMLElement
await userEvent.click(card)
// Note: Card click doesn't trigger onCreate, only the button does
expect(mockOnCreate).not.toHaveBeenCalled()
})
})
describe('Keyboard Accessibility', () => {
it('should allow the create button to be focused', async () => {
const mockOnCreate = jest.fn()
render(<AppCard {...defaultProps} onCreate={mockOnCreate} />)
await userEvent.tab()
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) as HTMLButtonElement
// Test that button can be focused
expect(button).toHaveFocus()
// Test click event works (keyboard events on buttons typically trigger click)
await userEvent.click(button)
expect(mockOnCreate).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle app with null icon_type', () => {
const appWithNullIcon = {
...mockApp,
app: {
...mockApp.app,
icon_type: null,
},
}
const { container } = render(<AppCard {...defaultProps} app={appWithNullIcon} />)
const appIcon = container.querySelector('em-emoji')
expect(appIcon).toBeInTheDocument()
// AppIcon component should handle null icon_type gracefully
})
it('should handle app with empty description', () => {
const appWithEmptyDesc = {
...mockApp,
description: '',
}
const { container } = render(<AppCard {...defaultProps} app={appWithEmptyDesc} />)
const descriptionContainer = container.querySelector('.line-clamp-3')
expect(descriptionContainer).toBeInTheDocument()
expect(descriptionContainer).toHaveTextContent('')
})
it('should handle app with very long description', () => {
const longDescription = 'This is a very long description that should be truncated with line-clamp-3. '.repeat(5)
const appWithLongDesc = {
...mockApp,
description: longDescription,
}
render(<AppCard {...defaultProps} app={appWithLongDesc} />)
expect(screen.getByText(/This is a very long description/)).toBeInTheDocument()
})
it('should handle app with special characters in name', () => {
const appWithSpecialChars = {
...mockApp,
app: {
...mockApp.app,
name: 'App <script>alert("test")</script> & Special "Chars"',
},
}
render(<AppCard {...defaultProps} app={appWithSpecialChars} />)
expect(screen.getByText('App <script>alert("test")</script> & Special "Chars"')).toBeInTheDocument()
})
it('should handle onCreate function throwing error', async () => {
const errorOnCreate = jest.fn(() => {
throw new Error('Create failed')
})
// Mock console.error to avoid test output noise
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
render(<AppCard {...defaultProps} onCreate={errorOnCreate} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
let capturedError: unknown
try {
await userEvent.click(button)
}
catch (err) {
capturedError = err
}
expect(errorOnCreate).toHaveBeenCalledTimes(1)
expect(consoleSpy).toHaveBeenCalled()
if (capturedError instanceof Error)
expect(capturedError.message).toContain('Create failed')
consoleSpy.mockRestore()
})
})
describe('Accessibility', () => {
it('should have proper elements for accessibility', () => {
const { container } = render(<AppCard {...defaultProps} />)
expect(container.querySelector('em-emoji')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should have title attribute for app name when truncated', () => {
render(<AppCard {...defaultProps} />)
const nameElement = screen.getByText('Test Chat App')
expect(nameElement).toHaveAttribute('title', 'Test Chat App')
})
it('should have accessible button with proper label', () => {
render(<AppCard {...defaultProps} />)
const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ })
expect(button).toBeEnabled()
expect(button).toHaveTextContent('app.newApp.useTemplate')
})
})
describe('User-Visible Behavior Tests', () => {
it('should show plus icon in create button', () => {
render(<AppCard {...defaultProps} />)
expect(screen.getByTestId('plus-icon')).toBeInTheDocument()
})
})
})

View File

@ -15,7 +15,6 @@ export type AppCardProps = {
const AppCard = ({
app,
canCreate,
onCreate,
}: AppCardProps) => {
const { t } = useTranslation()
@ -46,16 +45,14 @@ const AppCard = ({
{app.description}
</div>
</div>
{canCreate && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}>
<Button variant='primary' className='grow' onClick={() => onCreate()}>
<PlusIcon className='mr-1 h-4 w-4' />
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
</Button>
</div>
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}>
<Button variant='primary' className='grow' onClick={() => onCreate()}>
<PlusIcon className='mr-1 h-4 w-4' />
<span className='text-xs'>{t('app.newApp.useTemplate')}</span>
</Button>
</div>
)}
</div>
</div>
)
}

View File

@ -1,209 +0,0 @@
import type { RenderOptions } from '@testing-library/react'
import { fireEvent, render } from '@testing-library/react'
import { defaultPlan } from '@/app/components/billing/config'
import { noop } from 'lodash-es'
import type { ModalContextState } from '@/context/modal-context'
import APIKeyInfoPanel from './index'
// Mock the modules before importing the functions
jest.mock('@/context/provider-context', () => ({
useProviderContext: jest.fn(),
}))
jest.mock('@/context/modal-context', () => ({
useModalContext: jest.fn(),
}))
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
import { useModalContext as actualUseModalContext } from '@/context/modal-context'
// Type casting for mocks
const mockUseProviderContext = actualUseProviderContext as jest.MockedFunction<typeof actualUseProviderContext>
const mockUseModalContext = actualUseModalContext as jest.MockedFunction<typeof actualUseModalContext>
// Default mock data
const defaultProviderContext = {
modelProviders: [],
refreshModelProviders: noop,
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: false,
plan: defaultPlan,
isFetchedPlan: false,
enableBilling: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
educationAccountExpireAt: null,
isLoadingEducationAccountInfo: false,
isFetchingEducationAccountInfo: false,
webappCopyrightEnabled: false,
licenseLimit: {
workspace_members: {
size: 0,
limit: 0,
},
},
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
}
const defaultModalContext: ModalContextState = {
setShowAccountSettingModal: noop,
setShowApiBasedExtensionModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,
setShowAnnotationFullModal: noop,
setShowModelModal: noop,
setShowExternalKnowledgeAPIModal: noop,
setShowModelLoadBalancingModal: noop,
setShowOpeningModal: noop,
setShowUpdatePluginModal: noop,
setShowEducationExpireNoticeModal: noop,
setShowTriggerEventsLimitModal: noop,
}
export type MockOverrides = {
providerContext?: Partial<typeof defaultProviderContext>
modalContext?: Partial<typeof defaultModalContext>
}
export type APIKeyInfoPanelRenderOptions = {
mockOverrides?: MockOverrides
} & Omit<RenderOptions, 'wrapper'>
// Setup function to configure mocks
export function setupMocks(overrides: MockOverrides = {}) {
mockUseProviderContext.mockReturnValue({
...defaultProviderContext,
...overrides.providerContext,
})
mockUseModalContext.mockReturnValue({
...defaultModalContext,
...overrides.modalContext,
})
}
// Custom render function
export function renderAPIKeyInfoPanel(options: APIKeyInfoPanelRenderOptions = {}) {
const { mockOverrides, ...renderOptions } = options
setupMocks(mockOverrides)
return render(<APIKeyInfoPanel />, renderOptions)
}
// Helper functions for common test scenarios
export const scenarios = {
// Render with API key not set (default)
withAPIKeyNotSet: (overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
providerContext: { isAPIKeySet: false },
...overrides,
},
}),
// Render with API key already set
withAPIKeySet: (overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
providerContext: { isAPIKeySet: true },
...overrides,
},
}),
// Render with mock modal function
withMockModal: (mockSetShowAccountSettingModal: jest.Mock, overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
modalContext: { setShowAccountSettingModal: mockSetShowAccountSettingModal },
...overrides,
},
}),
}
// Common test assertions
export const assertions = {
// Should render main button
shouldRenderMainButton: () => {
const button = document.querySelector('button.btn-primary')
expect(button).toBeInTheDocument()
return button
},
// Should not render at all
shouldNotRender: (container: HTMLElement) => {
expect(container.firstChild).toBeNull()
},
// Should have correct panel styling
shouldHavePanelStyling: (panel: HTMLElement) => {
expect(panel).toHaveClass(
'border-components-panel-border',
'bg-components-panel-bg',
'relative',
'mb-6',
'rounded-2xl',
'border',
'p-8',
'shadow-md',
)
},
// Should have close button
shouldHaveCloseButton: (container: HTMLElement) => {
const closeButton = container.querySelector('.absolute.right-4.top-4')
expect(closeButton).toBeInTheDocument()
expect(closeButton).toHaveClass('cursor-pointer')
return closeButton
},
}
// Common user interactions
export const interactions = {
// Click the main button
clickMainButton: () => {
const button = document.querySelector('button.btn-primary')
if (button) fireEvent.click(button)
return button
},
// Click the close button
clickCloseButton: (container: HTMLElement) => {
const closeButton = container.querySelector('.absolute.right-4.top-4')
if (closeButton) fireEvent.click(closeButton)
return closeButton
},
}
// Text content keys for assertions
export const textKeys = {
selfHost: {
titleRow1: /appOverview\.apiKeyInfo\.selfHost\.title\.row1/,
titleRow2: /appOverview\.apiKeyInfo\.selfHost\.title\.row2/,
setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
tryCloud: /appOverview\.apiKeyInfo\.tryCloud/,
},
cloud: {
trialTitle: /appOverview\.apiKeyInfo\.cloud\.trial\.title/,
trialDescription: /appOverview\.apiKeyInfo\.cloud\.trial\.description/,
setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/,
},
}
// Setup and cleanup utilities
export function clearAllMocks() {
jest.clearAllMocks()
}
// Export mock functions for external access
export { mockUseProviderContext, mockUseModalContext, defaultModalContext }

View File

@ -1,122 +0,0 @@
import { cleanup, screen } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
assertions,
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
scenarios,
textKeys,
} from './apikey-info-panel.test-utils'
// Mock config for Cloud edition
jest.mock('@/config', () => ({
IS_CE_EDITION: false, // Test Cloud edition
}))
afterEach(cleanup)
describe('APIKeyInfoPanel - Cloud Edition', () => {
const mockSetShowAccountSettingModal = jest.fn()
beforeEach(() => {
clearAllMocks()
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
})
})
describe('Rendering', () => {
it('should render without crashing when API key is not set', () => {
scenarios.withAPIKeyNotSet()
assertions.shouldRenderMainButton()
})
it('should not render when API key is already set', () => {
const { container } = scenarios.withAPIKeySet()
assertions.shouldNotRender(container)
})
it('should not render when panel is hidden by user', () => {
const { container } = scenarios.withAPIKeyNotSet()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Cloud Edition Content', () => {
it('should display cloud version title', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.cloud.trialTitle)).toBeInTheDocument()
})
it('should display emoji for cloud version', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.querySelector('em-emoji')).toBeInTheDocument()
expect(container.querySelector('em-emoji')).toHaveAttribute('id', '😀')
})
it('should display cloud version description', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.cloud.trialDescription)).toBeInTheDocument()
})
it('should not render external link for cloud version', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.querySelector('a[href="https://cloud.dify.ai/apps"]')).not.toBeInTheDocument()
})
it('should display set API button text', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.cloud.setAPIBtn)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call setShowAccountSettingModal when set API button is clicked', () => {
scenarios.withMockModal(mockSetShowAccountSettingModal)
interactions.clickMainButton()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
})
it('should hide panel when close button is clicked', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.firstChild).toBeInTheDocument()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Props and Styling', () => {
it('should render button with primary variant', () => {
scenarios.withAPIKeyNotSet()
const button = screen.getByRole('button')
expect(button).toHaveClass('btn-primary')
})
it('should render panel container with correct classes', () => {
const { container } = scenarios.withAPIKeyNotSet()
const panel = container.firstChild as HTMLElement
assertions.shouldHavePanelStyling(panel)
})
})
describe('Accessibility', () => {
it('should have button with proper role', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should have clickable close button', () => {
const { container } = scenarios.withAPIKeyNotSet()
assertions.shouldHaveCloseButton(container)
})
})
})

View File

@ -1,162 +0,0 @@
import { cleanup, screen } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
assertions,
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
scenarios,
textKeys,
} from './apikey-info-panel.test-utils'
// Mock config for CE edition
jest.mock('@/config', () => ({
IS_CE_EDITION: true, // Test CE edition by default
}))
afterEach(cleanup)
describe('APIKeyInfoPanel - Community Edition', () => {
const mockSetShowAccountSettingModal = jest.fn()
beforeEach(() => {
clearAllMocks()
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
})
})
describe('Rendering', () => {
it('should render without crashing when API key is not set', () => {
scenarios.withAPIKeyNotSet()
assertions.shouldRenderMainButton()
})
it('should not render when API key is already set', () => {
const { container } = scenarios.withAPIKeySet()
assertions.shouldNotRender(container)
})
it('should not render when panel is hidden by user', () => {
const { container } = scenarios.withAPIKeyNotSet()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Content Display', () => {
it('should display self-host title content', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.selfHost.titleRow1)).toBeInTheDocument()
expect(screen.getByText(textKeys.selfHost.titleRow2)).toBeInTheDocument()
})
it('should display set API button text', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByText(textKeys.selfHost.setAPIBtn)).toBeInTheDocument()
})
it('should render external link with correct href for self-host version', () => {
const { container } = scenarios.withAPIKeyNotSet()
const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
expect(link).toHaveTextContent(textKeys.selfHost.tryCloud)
})
it('should have external link with proper styling for self-host version', () => {
const { container } = scenarios.withAPIKeyNotSet()
const link = container.querySelector('a[href="https://cloud.dify.ai/apps"]')
expect(link).toHaveClass(
'mt-2',
'flex',
'h-[26px]',
'items-center',
'space-x-1',
'p-1',
'text-xs',
'font-medium',
'text-[#155EEF]',
)
})
})
describe('User Interactions', () => {
it('should call setShowAccountSettingModal when set API button is clicked', () => {
scenarios.withMockModal(mockSetShowAccountSettingModal)
interactions.clickMainButton()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
})
it('should hide panel when close button is clicked', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.firstChild).toBeInTheDocument()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Props and Styling', () => {
it('should render button with primary variant', () => {
scenarios.withAPIKeyNotSet()
const button = screen.getByRole('button')
expect(button).toHaveClass('btn-primary')
})
it('should render panel container with correct classes', () => {
const { container } = scenarios.withAPIKeyNotSet()
const panel = container.firstChild as HTMLElement
assertions.shouldHavePanelStyling(panel)
})
})
describe('State Management', () => {
it('should start with visible panel (isShow: true)', () => {
scenarios.withAPIKeyNotSet()
assertions.shouldRenderMainButton()
})
it('should toggle visibility when close button is clicked', () => {
const { container } = scenarios.withAPIKeyNotSet()
expect(container.firstChild).toBeInTheDocument()
interactions.clickCloseButton(container)
assertions.shouldNotRender(container)
})
})
describe('Edge Cases', () => {
it('should handle provider context loading state', () => {
scenarios.withAPIKeyNotSet({
providerContext: {
modelProviders: [],
textGenerationModelList: [],
},
})
assertions.shouldRenderMainButton()
})
})
describe('Accessibility', () => {
it('should have button with proper role', () => {
scenarios.withAPIKeyNotSet()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should have clickable close button', () => {
const { container } = scenarios.withAPIKeyNotSet()
assertions.shouldHaveCloseButton(container)
})
})
})

View File

@ -24,10 +24,6 @@ import cn from '@/utils/classnames'
import type { FileEntity } from '../../file-uploader/types'
import { formatBooleanInputs } from '@/utils/model-config'
import Avatar from '../../avatar'
import ServiceConnectionPanel from '@/app/components/base/service-connection-panel'
import type { AuthType, ServiceConnectionItem as ServiceConnectionItemType } from '@/app/components/base/service-connection-panel'
import { Notion } from '@/app/components/base/icons/src/public/common'
import { Google } from '@/app/components/base/icons/src/public/plugins'
const ChatWrapper = () => {
const {
@ -171,53 +167,6 @@ const ChatWrapper = () => {
const [collapsed, setCollapsed] = useState(!!currentConversationId)
// Demo: Service connection state
const [serviceConnections, setServiceConnections] = useState<ServiceConnectionItemType[]>([
{
id: 'notion',
name: 'Notion Page Search',
icon: <Notion className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'gmail',
name: 'Gmail Tools',
icon: <img src="https://www.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" alt="Gmail" className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'youtube',
name: 'YouTube Data Upload',
icon: <img src="https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png" alt="YouTube" className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'google-serp',
name: 'Google SerpApi Search',
icon: <Google className="h-6 w-6" />,
authType: 'api_key',
status: 'pending',
},
])
const [showServiceConnection, setShowServiceConnection] = useState(true)
const handleServiceConnect = useCallback((serviceId: string, _authType: AuthType) => {
// Demo: 模拟连接成功
setServiceConnections(prev => prev.map(service =>
service.id === serviceId
? { ...service, status: 'connected' as const }
: service,
))
}, [])
const handleServiceContinue = useCallback(() => {
setShowServiceConnection(false)
}, [])
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
return null
@ -304,23 +253,6 @@ const ChatWrapper = () => {
/>
: null
// 如果需要显示服务连接面板,则显示面板而非聊天界面
if (showServiceConnection) {
return (
<div className={cn(
'flex h-full items-center justify-center overflow-auto bg-chatbot-bg',
isMobile && 'px-4 py-8',
)}>
<ServiceConnectionPanel
services={serviceConnections}
onConnect={handleServiceConnect}
onContinue={handleServiceContinue}
className={cn(isMobile && 'max-w-full')}
/>
</div>
)
}
return (
<div
className='h-full overflow-hidden bg-chatbot-bg'

View File

@ -1,79 +0,0 @@
'use client'
import type { FC } from 'react'
import { memo, useMemo } from 'react'
import { RiArrowRightLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ServiceItem from './service-item'
import type { ServiceConnectionPanelProps } from './types'
import cn from '@/utils/classnames'
const ServiceConnectionPanel: FC<ServiceConnectionPanelProps> = ({
title,
description,
services,
onConnect,
onContinue,
continueDisabled,
continueText,
className,
}) => {
const { t } = useTranslation()
const allConnected = useMemo(() => {
return services.every(service => service.status === 'connected')
}, [services])
const displayTitle = title || t('share.serviceConnection.title')
const displayDescription = description || t('share.serviceConnection.description', { count: services.length })
return (
<div className={cn(
'flex w-full max-w-[600px] flex-col items-center',
className,
)}>
<div className="mb-6 text-center">
<h2 className="system-xl-semibold mb-1 text-text-primary">
{displayTitle}
</h2>
<p className="system-sm-regular text-text-tertiary">
{displayDescription}
</p>
</div>
<div className="w-full space-y-2">
{services.map(service => (
<ServiceItem
key={service.id}
service={service}
onConnect={onConnect}
/>
))}
</div>
{onContinue && (
<div className="mt-6 flex w-full justify-end">
<Button
variant="primary"
disabled={continueDisabled ?? !allConnected}
onClick={onContinue}
>
{continueText || t('share.serviceConnection.continue')}
<RiArrowRightLine className="ml-1 h-4 w-4" />
</Button>
</div>
)}
</div>
)
}
export default memo(ServiceConnectionPanel)
export { default as ServiceItem } from './service-item'
export type {
ServiceConnectionPanelProps,
ServiceConnectionItem,
AuthType,
ServiceConnectionStatus,
} from './types'

View File

@ -1,72 +0,0 @@
'use client'
import type { FC } from 'react'
import { memo } from 'react'
import { RiAddLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import type { AuthType, ServiceConnectionItem } from './types'
import cn from '@/utils/classnames'
type ServiceItemProps = {
service: ServiceConnectionItem
onConnect: (serviceId: string, authType: AuthType) => void
}
const ServiceItem: FC<ServiceItemProps> = ({
service,
onConnect,
}) => {
const { t } = useTranslation()
const handleConnect = () => {
onConnect(service.id, service.authType)
}
const getButtonText = () => {
if (service.status === 'connected')
return t('share.serviceConnection.connected')
if (service.authType === 'api_key')
return t('share.serviceConnection.addApiKey')
return t('share.serviceConnection.connect')
}
const isConnected = service.status === 'connected'
return (
<div className={cn(
'flex items-center justify-between gap-3 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg px-4 py-3',
'hover:border-components-panel-border hover:shadow-xs',
'transition-all duration-200',
)}>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
{service.icon}
</div>
<div className="flex flex-col">
<span className="system-sm-medium text-text-secondary">
{service.name}
</span>
{service.description && (
<span className="system-xs-regular text-text-tertiary">
{service.description}
</span>
)}
</div>
</div>
<Button
variant={isConnected ? 'secondary' : 'secondary-accent'}
size="small"
onClick={handleConnect}
disabled={isConnected}
>
{!isConnected && <RiAddLine className="mr-0.5 h-3.5 w-3.5" />}
{getButtonText()}
</Button>
</div>
)
}
export default memo(ServiceItem)

View File

@ -1,25 +0,0 @@
import type { ReactNode } from 'react'
export type AuthType = 'oauth' | 'api_key'
export type ServiceConnectionStatus = 'pending' | 'connected' | 'error'
export type ServiceConnectionItem = {
id: string
name: string
icon: ReactNode
authType: AuthType
status: ServiceConnectionStatus
description?: string
}
export type ServiceConnectionPanelProps = {
title?: string
description?: string
services: ServiceConnectionItem[]
onConnect: (serviceId: string, authType: AuthType) => void
onContinue?: () => void
continueDisabled?: boolean
continueText?: string
className?: string
}

View File

@ -33,10 +33,7 @@ const PlanUpgradeModal: FC<Props> = ({
const handleUpgrade = useCallback(() => {
onClose()
if (onUpgrade)
onUpgrade()
else
setShowPricingModal()
onUpgrade ? onUpgrade() : setShowPricingModal()
}, [onClose, onUpgrade, setShowPricingModal])
return (

View File

@ -1,50 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Button from './button'
import { Plan } from '../../../type'
describe('CloudPlanButton', () => {
describe('Disabled state', () => {
test('should disable button and hide arrow when plan is not available', () => {
const handleGetPayUrl = jest.fn()
// Arrange
render(
<Button
plan={Plan.team}
isPlanDisabled
btnText="Get started"
handleGetPayUrl={handleGetPayUrl}
/>,
)
const button = screen.getByRole('button', { name: /Get started/i })
// Assert
expect(button).toBeDisabled()
expect(button.className).toContain('cursor-not-allowed')
expect(handleGetPayUrl).not.toHaveBeenCalled()
})
})
describe('Enabled state', () => {
test('should invoke handler and render arrow when plan is available', () => {
const handleGetPayUrl = jest.fn()
// Arrange
render(
<Button
plan={Plan.sandbox}
isPlanDisabled={false}
btnText="Start now"
handleGetPayUrl={handleGetPayUrl}
/>,
)
const button = screen.getByRole('button', { name: /Start now/i })
// Act
fireEvent.click(button)
// Assert
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
expect(button).not.toBeDisabled()
})
})
})

View File

@ -1,188 +0,0 @@
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CloudPlanItem from './index'
import { Plan } from '../../../type'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing'
import Toast from '../../../../base/toast'
import { ALL_PLANS } from '../../../config'
jest.mock('../../../../base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/context/app-context', () => ({
useAppContext: jest.fn(),
}))
jest.mock('@/service/billing', () => ({
fetchBillingUrl: jest.fn(),
fetchSubscriptionUrls: jest.fn(),
}))
jest.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: jest.fn(),
}))
jest.mock('../../assets', () => ({
Sandbox: () => <div>Sandbox Icon</div>,
Professional: () => <div>Professional Icon</div>,
Team: () => <div>Team Icon</div>,
}))
const mockUseAppContext = useAppContext as jest.Mock
const mockUseAsyncWindowOpen = useAsyncWindowOpen as jest.Mock
const mockFetchBillingUrl = fetchBillingUrl as jest.Mock
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as jest.Mock
const mockToastNotify = Toast.notify as jest.Mock
let assignedHref = ''
const originalLocation = window.location
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return assignedHref
},
set href(value: string) {
assignedHref = value
},
} as unknown as Location,
})
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
beforeEach(() => {
jest.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
mockUseAsyncWindowOpen.mockReturnValue(jest.fn(async open => await open()))
mockFetchBillingUrl.mockResolvedValue({ url: 'https://billing.example' })
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' })
assignedHref = ''
})
describe('CloudPlanItem', () => {
// Static content for each plan
describe('Rendering', () => {
test('should show plan metadata and free label for sandbox plan', () => {
render(
<CloudPlanItem
plan={Plan.sandbox}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
expect(screen.getByText('billing.plans.sandbox.description')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.free')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })).toBeInTheDocument()
})
test('should display yearly pricing with discount when planRange is yearly', () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
const professionalPlan = ALL_PLANS[Plan.professional]
expect(screen.getByText(`$${professionalPlan.price * 12}`)).toBeInTheDocument()
expect(screen.getByText(`$${professionalPlan.price * 10}`)).toBeInTheDocument()
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
})
test('should disable CTA when workspace already on higher tier', () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.team}
planRange={PlanRange.monthly}
canPay
/>,
)
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
expect(button).toBeDisabled()
})
})
// Payment actions triggered from the CTA
describe('Plan purchase flow', () => {
test('should show toast when non-manager tries to buy a plan', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'billing.buyPermissionDeniedTip',
}))
expect(mockFetchBillingUrl).not.toHaveBeenCalled()
})
test('should open billing portal when upgrading current paid plan', async () => {
const openWindow = jest.fn(async (cb: () => Promise<string>) => await cb())
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
await waitFor(() => {
expect(mockFetchBillingUrl).toHaveBeenCalledTimes(1)
})
expect(openWindow).toHaveBeenCalledTimes(1)
})
test('should redirect to subscription url when selecting a new paid plan', async () => {
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(assignedHref).toBe('https://subscription.example')
})
})
})
})

View File

@ -1,30 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import List from './index'
import { Plan } from '../../../../type'
describe('CloudPlanItem/List', () => {
test('should show sandbox specific quotas', () => {
render(<List plan={Plan.sandbox} />)
expect(screen.getByText('billing.plansCommon.messageRequest.title:{"count":200}')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.triggerEvents.sandbox:{"count":3000}')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.startNodes.limited:{"count":2}')).toBeInTheDocument()
})
test('should show professional monthly quotas and tooltips', () => {
render(<List plan={Plan.professional} />)
expect(screen.getByText('billing.plansCommon.messageRequest.titlePerMonth:{"count":5000}')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.vectorSpaceTooltip')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.workflowExecution.faster')).toBeInTheDocument()
})
test('should show unlimited messaging details for team plan', () => {
render(<List plan={Plan.team} />)
expect(screen.getByText('billing.plansCommon.triggerEvents.unlimited')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.workflowExecution.priority')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.unlimitedApiRate')).toBeInTheDocument()
})
})

View File

@ -1,87 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Plans from './index'
import { Plan, type UsagePlanInfo } from '../../type'
import { PlanRange } from '../plan-switcher/plan-range-switcher'
jest.mock('./cloud-plan-item', () => ({
__esModule: true,
default: jest.fn(props => (
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
Cloud {props.plan}
</div>
)),
}))
jest.mock('./self-hosted-plan-item', () => ({
__esModule: true,
default: jest.fn(props => (
<div data-testid={`self-plan-${props.plan}`}>
Self {props.plan}
</div>
)),
}))
const buildPlan = (type: Plan) => {
const usage: UsagePlanInfo = {
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
}
return {
type,
usage,
total: usage,
}
}
describe('Plans', () => {
// Cloud plans visible only when currentPlan is cloud
describe('Cloud plan rendering', () => {
test('should render sandbox, professional, and team cloud plans when workspace is cloud', () => {
render(
<Plans
plan={buildPlan(Plan.enterprise)}
currentPlan="cloud"
planRange={PlanRange.monthly}
canPay
/>,
)
expect(screen.getByTestId('cloud-plan-sandbox')).toBeInTheDocument()
expect(screen.getByTestId('cloud-plan-professional')).toBeInTheDocument()
expect(screen.getByTestId('cloud-plan-team')).toBeInTheDocument()
const cloudPlanItem = jest.requireMock('./cloud-plan-item').default as jest.Mock
const firstCallProps = cloudPlanItem.mock.calls[0][0]
expect(firstCallProps.plan).toBe(Plan.sandbox)
// Enterprise should be normalized to team when passed down
expect(firstCallProps.currentPlan).toBe(Plan.team)
})
})
// Self-hosted plans visible for self-managed workspaces
describe('Self-hosted plan rendering', () => {
test('should render all self-hosted plans when workspace type is self-hosted', () => {
render(
<Plans
plan={buildPlan(Plan.sandbox)}
currentPlan="self"
planRange={PlanRange.yearly}
canPay={false}
/>,
)
expect(screen.getByTestId('self-plan-community')).toBeInTheDocument()
expect(screen.getByTestId('self-plan-premium')).toBeInTheDocument()
expect(screen.getByTestId('self-plan-enterprise')).toBeInTheDocument()
const selfPlanItem = jest.requireMock('./self-hosted-plan-item').default as jest.Mock
expect(selfPlanItem).toHaveBeenCalledTimes(3)
})
})
})

View File

@ -1,61 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Button from './button'
import { SelfHostedPlan } from '../../../type'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
jest.mock('@/hooks/use-theme')
jest.mock('@/app/components/base/icons/src/public/billing', () => ({
AwsMarketplaceLight: () => <div>AwsMarketplaceLight</div>,
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
}))
const mockUseTheme = useTheme as jest.MockedFunction<typeof useTheme>
beforeEach(() => {
jest.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light } as unknown as ReturnType<typeof useTheme>)
})
describe('SelfHostedPlanButton', () => {
test('should invoke handler when clicked', () => {
const handleGetPayUrl = jest.fn()
render(
<Button
plan={SelfHostedPlan.community}
handleGetPayUrl={handleGetPayUrl}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
})
test('should render AWS marketplace badge for premium plan in light theme', () => {
const handleGetPayUrl = jest.fn()
render(
<Button
plan={SelfHostedPlan.premium}
handleGetPayUrl={handleGetPayUrl}
/>,
)
expect(screen.getByText('AwsMarketplaceLight')).toBeInTheDocument()
})
test('should switch to dark AWS badge in dark theme', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark } as unknown as ReturnType<typeof useTheme>)
render(
<Button
plan={SelfHostedPlan.premium}
handleGetPayUrl={jest.fn()}
/>,
)
expect(screen.getByText('AwsMarketplaceDark')).toBeInTheDocument()
})
})

View File

@ -1,143 +0,0 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SelfHostedPlanItem from './index'
import { SelfHostedPlan } from '../../../type'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import { useAppContext } from '@/context/app-context'
import Toast from '../../../../base/toast'
const featuresTranslations: Record<string, string[]> = {
'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
'billing.plans.premium.features': ['premium-feature-1'],
'billing.plans.enterprise.features': ['enterprise-feature-1'],
}
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return featuresTranslations[key] || []
return key
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}))
jest.mock('../../../../base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/context/app-context', () => ({
useAppContext: jest.fn(),
}))
jest.mock('../../assets', () => ({
Community: () => <div>Community Icon</div>,
Premium: () => <div>Premium Icon</div>,
Enterprise: () => <div>Enterprise Icon</div>,
PremiumNoise: () => <div>PremiumNoise</div>,
EnterpriseNoise: () => <div>EnterpriseNoise</div>,
}))
jest.mock('@/app/components/base/icons/src/public/billing', () => ({
Azure: () => <div>Azure</div>,
GoogleCloud: () => <div>Google Cloud</div>,
AwsMarketplaceDark: () => <div>AwsMarketplaceDark</div>,
AwsMarketplaceLight: () => <div>AwsMarketplaceLight</div>,
}))
const mockUseAppContext = useAppContext as jest.Mock
const mockToastNotify = Toast.notify as jest.Mock
let assignedHref = ''
const originalLocation = window.location
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return assignedHref
},
set href(value: string) {
assignedHref = value
},
} as unknown as Location,
})
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
beforeEach(() => {
jest.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
assignedHref = ''
})
describe('SelfHostedPlanItem', () => {
// Copy rendering for each plan
describe('Rendering', () => {
test('should display community plan info', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
})
test('should show premium extras such as cloud provider notice', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
expect(screen.getByText('billing.plans.premium.price')).toBeInTheDocument()
expect(screen.getByText('billing.plans.premium.comingSoon')).toBeInTheDocument()
expect(screen.getByText('Azure')).toBeInTheDocument()
expect(screen.getByText('Google Cloud')).toBeInTheDocument()
})
})
// CTA behavior for each plan
describe('CTA interactions', () => {
test('should show toast when non-manager tries to proceed', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'billing.buyPermissionDeniedTip',
}))
})
test('should redirect to community url when community plan button clicked', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.community.btnText' }))
expect(assignedHref).toBe(getStartedWithCommunityUrl)
})
test('should redirect to premium marketplace url when premium button clicked', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ }))
expect(assignedHref).toBe(getWithPremiumUrl)
})
test('should redirect to contact sales form when enterprise button clicked', () => {
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
fireEvent.click(screen.getByRole('button', { name: 'billing.plans.enterprise.btnText' }))
expect(assignedHref).toBe(contactSalesUrl)
})
})
})

View File

@ -1,25 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import List from './index'
import { SelfHostedPlan } from '@/app/components/billing/type'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.returnObjects)
return ['Feature A', 'Feature B']
return key
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
}))
describe('SelfHostedPlanItem/List', () => {
test('should render plan info', () => {
render(<List plan={SelfHostedPlan.community} />)
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
expect(screen.getByText('Feature A')).toBeInTheDocument()
expect(screen.getByText('Feature B')).toBeInTheDocument()
})
})

View File

@ -1,12 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Item from './item'
describe('SelfHostedPlanItem/List/Item', () => {
test('should display provided feature label', () => {
const { container } = render(<Item label="Dedicated support" />)
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@ -0,0 +1,564 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Tab from './index'
// Define enum locally to avoid importing the whole module
enum CreateFromDSLModalTab {
FROM_FILE = 'from-file',
FROM_URL = 'from-url',
}
// Mock the create-from-dsl-modal module to export the enum
jest.mock('@/app/components/app/create-from-dsl-modal', () => ({
CreateFromDSLModalTab: {
FROM_FILE: 'from-file',
FROM_URL: 'from-url',
},
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Tab', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Tests for basic rendering
describe('Rendering', () => {
it('should render without crashing', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
})
it('should render two tab items', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
// Should have 2 clickable tab items
const tabItems = container.querySelectorAll('.cursor-pointer')
expect(tabItems.length).toBe(2)
})
it('should render with correct container styling', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const tabContainer = container.firstChild as HTMLElement
expect(tabContainer).toHaveClass('flex')
expect(tabContainer).toHaveClass('h-9')
expect(tabContainer).toHaveClass('items-center')
expect(tabContainer).toHaveClass('gap-x-6')
expect(tabContainer).toHaveClass('border-b')
expect(tabContainer).toHaveClass('border-divider-subtle')
expect(tabContainer).toHaveClass('px-6')
})
it('should render tab labels with translation keys', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
})
})
// Tests for active tab indication
describe('Active Tab Indication', () => {
it('should show FROM_FILE tab as active when currentTab is FROM_FILE', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
// getByText returns the Item element directly (text is inside it)
const fileTab = screen.getByText('app.importFromDSLFile')
const urlTab = screen.getByText('app.importFromDSLUrl')
// Active tab should have text-text-primary class
expect(fileTab).toHaveClass('text-text-primary')
// Inactive tab should have text-text-tertiary class
expect(urlTab).toHaveClass('text-text-tertiary')
expect(urlTab).not.toHaveClass('text-text-primary')
})
it('should show FROM_URL tab as active when currentTab is FROM_URL', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_URL}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
const urlTab = screen.getByText('app.importFromDSLUrl')
// Inactive tab should have text-text-tertiary class
expect(fileTab).toHaveClass('text-text-tertiary')
expect(fileTab).not.toHaveClass('text-text-primary')
// Active tab should have text-text-primary class
expect(urlTab).toHaveClass('text-text-primary')
})
it('should render active indicator bar for active tab', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
// Active tab should have the indicator bar
const indicatorBars = container.querySelectorAll('.bg-util-colors-blue-brand-blue-brand-600')
expect(indicatorBars.length).toBe(1)
})
it('should render active indicator bar for URL tab when active', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_URL}
setCurrentTab={setCurrentTab}
/>,
)
// Should have one indicator bar
const indicatorBars = container.querySelectorAll('.bg-util-colors-blue-brand-blue-brand-600')
expect(indicatorBars.length).toBe(1)
// The indicator should be in the URL tab
const urlTab = screen.getByText('app.importFromDSLUrl')
expect(urlTab.querySelector('.bg-util-colors-blue-brand-blue-brand-600')).toBeInTheDocument()
})
it('should not render indicator bar for inactive tab', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
// The URL tab (inactive) should not have an indicator bar
const urlTab = screen.getByText('app.importFromDSLUrl')
expect(urlTab.querySelector('.bg-util-colors-blue-brand-blue-brand-600')).not.toBeInTheDocument()
})
})
// Tests for user interactions
describe('User Interactions', () => {
it('should call setCurrentTab with FROM_FILE when file tab is clicked', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_URL}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
fireEvent.click(fileTab)
expect(setCurrentTab).toHaveBeenCalledTimes(1)
// .bind() passes tab.key as first arg, event as second
expect(setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_FILE, expect.anything())
})
it('should call setCurrentTab with FROM_URL when url tab is clicked', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const urlTab = screen.getByText('app.importFromDSLUrl')
fireEvent.click(urlTab)
expect(setCurrentTab).toHaveBeenCalledTimes(1)
expect(setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL, expect.anything())
})
it('should call setCurrentTab when clicking already active tab', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
fireEvent.click(fileTab)
// Should still call setCurrentTab even for active tab
expect(setCurrentTab).toHaveBeenCalledTimes(1)
expect(setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_FILE, expect.anything())
})
it('should handle multiple tab clicks', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
const urlTab = screen.getByText('app.importFromDSLUrl')
fireEvent.click(urlTab)
fireEvent.click(fileTab)
fireEvent.click(urlTab)
expect(setCurrentTab).toHaveBeenCalledTimes(3)
expect(setCurrentTab).toHaveBeenNthCalledWith(1, CreateFromDSLModalTab.FROM_URL, expect.anything())
expect(setCurrentTab).toHaveBeenNthCalledWith(2, CreateFromDSLModalTab.FROM_FILE, expect.anything())
expect(setCurrentTab).toHaveBeenNthCalledWith(3, CreateFromDSLModalTab.FROM_URL, expect.anything())
})
})
// Tests for props variations
describe('Props Variations', () => {
it('should handle FROM_FILE as currentTab prop', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
expect(fileTab).toHaveClass('text-text-primary')
})
it('should handle FROM_URL as currentTab prop', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_URL}
setCurrentTab={setCurrentTab}
/>,
)
const urlTab = screen.getByText('app.importFromDSLUrl')
expect(urlTab).toHaveClass('text-text-primary')
})
it('should work with different setCurrentTab callback functions', () => {
const setCurrentTab1 = jest.fn()
const { rerender } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab1}
/>,
)
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
expect(setCurrentTab1).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL, expect.anything())
const setCurrentTab2 = jest.fn()
rerender(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab2}
/>,
)
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
expect(setCurrentTab2).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL, expect.anything())
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle component mounting without errors', () => {
const setCurrentTab = jest.fn()
expect(() =>
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
),
).not.toThrow()
})
it('should handle component unmounting without errors', () => {
const setCurrentTab = jest.fn()
const { unmount } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
expect(() => unmount()).not.toThrow()
})
it('should handle currentTab prop change', () => {
const setCurrentTab = jest.fn()
const { rerender } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
// Initially FROM_FILE is active
let fileTab = screen.getByText('app.importFromDSLFile')
expect(fileTab).toHaveClass('text-text-primary')
// Change to FROM_URL
rerender(
<Tab
currentTab={CreateFromDSLModalTab.FROM_URL}
setCurrentTab={setCurrentTab}
/>,
)
// Now FROM_URL should be active
const urlTab = screen.getByText('app.importFromDSLUrl')
fileTab = screen.getByText('app.importFromDSLFile')
expect(urlTab).toHaveClass('text-text-primary')
expect(fileTab).not.toHaveClass('text-text-primary')
})
it('should handle multiple rerenders', () => {
const setCurrentTab = jest.fn()
const { rerender } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
rerender(
<Tab
currentTab={CreateFromDSLModalTab.FROM_URL}
setCurrentTab={setCurrentTab}
/>,
)
rerender(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
expect(fileTab).toHaveClass('text-text-primary')
})
it('should maintain DOM structure after multiple interactions', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const initialTabCount = container.querySelectorAll('.cursor-pointer').length
// Multiple clicks
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
fireEvent.click(screen.getByText('app.importFromDSLFile'))
const afterClicksTabCount = container.querySelectorAll('.cursor-pointer').length
expect(afterClicksTabCount).toBe(initialTabCount)
})
})
// Tests for Item component integration
describe('Item Component Integration', () => {
it('should render Item components with correct cursor style', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const tabItems = container.querySelectorAll('.cursor-pointer')
expect(tabItems.length).toBe(2)
})
it('should pass correct isActive prop to Item components', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
const urlTab = screen.getByText('app.importFromDSLUrl')
// File tab should be active
expect(fileTab).toHaveClass('text-text-primary')
// URL tab should be inactive
expect(urlTab).not.toHaveClass('text-text-primary')
})
it('should pass correct label to Item components', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
expect(screen.getByText('app.importFromDSLFile')).toBeInTheDocument()
expect(screen.getByText('app.importFromDSLUrl')).toBeInTheDocument()
})
it('should pass correct onClick handler to Item components', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
const urlTab = screen.getByText('app.importFromDSLUrl')
fireEvent.click(fileTab)
fireEvent.click(urlTab)
expect(setCurrentTab).toHaveBeenCalledTimes(2)
expect(setCurrentTab).toHaveBeenNthCalledWith(1, CreateFromDSLModalTab.FROM_FILE, expect.anything())
expect(setCurrentTab).toHaveBeenNthCalledWith(2, CreateFromDSLModalTab.FROM_URL, expect.anything())
})
})
// Tests for accessibility
describe('Accessibility', () => {
it('should have clickable elements for each tab', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const clickableElements = container.querySelectorAll('.cursor-pointer')
expect(clickableElements.length).toBe(2)
})
it('should have visible text labels for each tab', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileLabel = screen.getByText('app.importFromDSLFile')
const urlLabel = screen.getByText('app.importFromDSLUrl')
expect(fileLabel).toBeVisible()
expect(urlLabel).toBeVisible()
})
it('should visually distinguish active tab from inactive tabs', () => {
const setCurrentTab = jest.fn()
const { container } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
// Active tab has indicator bar
const indicatorBars = container.querySelectorAll('.bg-util-colors-blue-brand-blue-brand-600')
expect(indicatorBars.length).toBe(1)
// Active tab has different text color
const fileTab = screen.getByText('app.importFromDSLFile')
expect(fileTab).toHaveClass('text-text-primary')
})
})
// Tests for component stability
describe('Component Stability', () => {
it('should handle rapid mount/unmount cycles', () => {
const setCurrentTab = jest.fn()
for (let i = 0; i < 5; i++) {
const { unmount } = render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
unmount()
}
expect(true).toBe(true)
})
it('should handle rapid tab switching', () => {
const setCurrentTab = jest.fn()
render(
<Tab
currentTab={CreateFromDSLModalTab.FROM_FILE}
setCurrentTab={setCurrentTab}
/>,
)
const fileTab = screen.getByText('app.importFromDSLFile')
const urlTab = screen.getByText('app.importFromDSLUrl')
// Rapid clicks
for (let i = 0; i < 10; i++)
fireEvent.click(i % 2 === 0 ? urlTab : fileTab)
expect(setCurrentTab).toHaveBeenCalledTimes(10)
})
})
})

View File

@ -0,0 +1,439 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import CreateFromPipeline from './index'
// Mock list component to avoid deep dependency issues
jest.mock('./list', () => ({
__esModule: true,
default: () => <div data-testid="list">List Component</div>,
}))
// Mock CreateFromDSLModal to avoid deep dependency chain
jest.mock('./create-options/create-from-dsl-modal', () => ({
__esModule: true,
default: ({ show, onClose, onSuccess }: { show: boolean; onClose: () => void; onSuccess: () => void }) => (
show
? (
<div data-testid="dsl-modal">
<button data-testid="dsl-modal-close" onClick={onClose}>Close</button>
<button data-testid="dsl-modal-success" onClick={onSuccess}>Import Success</button>
</div>
)
: null
),
CreateFromDSLModalTab: {
FROM_URL: 'from-url',
},
}))
// Mock next/navigation
const mockReplace = jest.fn()
const mockPush = jest.fn()
let mockSearchParams = new URLSearchParams()
jest.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
push: mockPush,
}),
useSearchParams: () => mockSearchParams,
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useInvalidDatasetList hook
const mockInvalidDatasetList = jest.fn()
jest.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
describe('CreateFromPipeline', () => {
beforeEach(() => {
jest.clearAllMocks()
mockSearchParams = new URLSearchParams()
})
// Tests for basic rendering
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toBeInTheDocument()
})
it('should render the main container with correct className', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('relative')
expect(mainContainer).toHaveClass('flex')
expect(mainContainer).toHaveClass('h-[calc(100vh-56px)]')
expect(mainContainer).toHaveClass('flex-col')
expect(mainContainer).toHaveClass('overflow-hidden')
expect(mainContainer).toHaveClass('rounded-t-2xl')
expect(mainContainer).toHaveClass('border-t')
expect(mainContainer).toHaveClass('border-effects-highlight')
expect(mainContainer).toHaveClass('bg-background-default-subtle')
})
it('should render Header component with back to knowledge text', () => {
render(<CreateFromPipeline />)
expect(screen.getByText('datasetPipeline.creation.backToKnowledge')).toBeInTheDocument()
})
it('should render List component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('list')).toBeInTheDocument()
})
it('should render Footer component with import DSL button', () => {
render(<CreateFromPipeline />)
expect(screen.getByText('datasetPipeline.creation.importDSL')).toBeInTheDocument()
})
it('should render Effect component with blur effect', () => {
const { container } = render(<CreateFromPipeline />)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).toBeInTheDocument()
})
it('should render Effect component with correct positioning classes', () => {
const { container } = render(<CreateFromPipeline />)
const effectElement = container.querySelector('.left-8.top-\\[-34px\\].opacity-20')
expect(effectElement).toBeInTheDocument()
})
})
// Tests for Header component integration
describe('Header Component Integration', () => {
it('should render header with navigation link', () => {
render(<CreateFromPipeline />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/datasets')
})
it('should render back button inside header', () => {
render(<CreateFromPipeline />)
const button = screen.getByRole('button', { name: '' })
expect(button).toBeInTheDocument()
expect(button).toHaveClass('rounded-full')
})
it('should render header with correct styling', () => {
const { container } = render(<CreateFromPipeline />)
const headerElement = container.querySelector('.px-16.pb-2.pt-5')
expect(headerElement).toBeInTheDocument()
})
})
// Tests for Footer component integration
describe('Footer Component Integration', () => {
it('should render footer with import DSL button', () => {
render(<CreateFromPipeline />)
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
expect(importButton).toBeInTheDocument()
})
it('should render footer at bottom with correct positioning classes', () => {
const { container } = render(<CreateFromPipeline />)
const footer = container.querySelector('.absolute.bottom-0.left-0.right-0')
expect(footer).toBeInTheDocument()
})
it('should render footer with backdrop blur', () => {
const { container } = render(<CreateFromPipeline />)
const footer = container.querySelector('.backdrop-blur-\\[6px\\]')
expect(footer).toBeInTheDocument()
})
it('should render divider in footer', () => {
const { container } = render(<CreateFromPipeline />)
// Divider renders with w-8 class
const divider = container.querySelector('.w-8')
expect(divider).toBeInTheDocument()
})
it('should open import modal when import DSL button is clicked', () => {
render(<CreateFromPipeline />)
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
})
it('should not show import modal initially', () => {
render(<CreateFromPipeline />)
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
})
})
// Tests for Effect component integration
describe('Effect Component Integration', () => {
it('should render Effect with blur effect', () => {
const { container } = render(<CreateFromPipeline />)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).toBeInTheDocument()
})
it('should render Effect with absolute positioning', () => {
const { container } = render(<CreateFromPipeline />)
const effectElement = container.querySelector('.absolute.size-\\[112px\\].rounded-full')
expect(effectElement).toBeInTheDocument()
})
it('should render Effect with brand color', () => {
const { container } = render(<CreateFromPipeline />)
const effectElement = container.querySelector('.bg-util-colors-blue-brand-blue-brand-500')
expect(effectElement).toBeInTheDocument()
})
it('should render Effect with custom opacity', () => {
const { container } = render(<CreateFromPipeline />)
const effectElement = container.querySelector('.opacity-20')
expect(effectElement).toBeInTheDocument()
})
})
// Tests for layout structure
describe('Layout Structure', () => {
it('should render children in correct order', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
const children = mainContainer.children
// Should have 4 children: Effect, Header, List, Footer
expect(children.length).toBe(4)
})
it('should have flex column layout', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('flex-col')
})
it('should have overflow hidden on main container', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('overflow-hidden')
})
it('should have correct height calculation', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('h-[calc(100vh-56px)]')
})
})
// Tests for styling
describe('Styling', () => {
it('should have border styling on main container', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('border-t')
expect(mainContainer).toHaveClass('border-effects-highlight')
})
it('should have rounded top corners', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('rounded-t-2xl')
})
it('should have subtle background color', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('bg-background-default-subtle')
})
it('should have relative positioning for child absolute positioning', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('relative')
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle component mounting without errors', () => {
expect(() => render(<CreateFromPipeline />)).not.toThrow()
})
it('should handle component unmounting without errors', () => {
const { unmount } = render(<CreateFromPipeline />)
expect(() => unmount()).not.toThrow()
})
it('should handle multiple renders without issues', () => {
const { rerender } = render(<CreateFromPipeline />)
rerender(<CreateFromPipeline />)
rerender(<CreateFromPipeline />)
rerender(<CreateFromPipeline />)
expect(screen.getByText('datasetPipeline.creation.backToKnowledge')).toBeInTheDocument()
})
it('should maintain consistent DOM structure across rerenders', () => {
const { container, rerender } = render(<CreateFromPipeline />)
const initialChildCount = (container.firstChild as HTMLElement)?.children.length
rerender(<CreateFromPipeline />)
const afterRerenderChildCount = (container.firstChild as HTMLElement)?.children.length
expect(afterRerenderChildCount).toBe(initialChildCount)
})
it('should handle remoteInstallUrl search param', () => {
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl.yaml')
render(<CreateFromPipeline />)
// Should render without crashing when remoteInstallUrl is present
expect(screen.getByText('datasetPipeline.creation.backToKnowledge')).toBeInTheDocument()
})
})
// Tests for accessibility
describe('Accessibility', () => {
it('should have accessible link for navigation', () => {
render(<CreateFromPipeline />)
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '/datasets')
})
it('should have accessible buttons', () => {
render(<CreateFromPipeline />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(2) // back button and import DSL button
})
it('should use semantic structure for content', () => {
const { container } = render(<CreateFromPipeline />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer.tagName).toBe('DIV')
})
})
// Tests for component stability
describe('Component Stability', () => {
it('should not cause memory leaks on unmount', () => {
const { unmount } = render(<CreateFromPipeline />)
unmount()
expect(true).toBe(true)
})
it('should handle rapid mount/unmount cycles', () => {
for (let i = 0; i < 5; i++) {
const { unmount } = render(<CreateFromPipeline />)
unmount()
}
expect(true).toBe(true)
})
})
// Tests for user interactions
describe('User Interactions', () => {
it('should toggle import modal when clicking import DSL button', () => {
render(<CreateFromPipeline />)
// Initially modal is not shown
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
// Click import DSL button
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
fireEvent.click(importButton)
// Modal should be shown
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
})
it('should close modal when close button is clicked', () => {
render(<CreateFromPipeline />)
// Open modal
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
// Click close button
const closeButton = screen.getByTestId('dsl-modal-close')
fireEvent.click(closeButton)
// Modal should be hidden
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
})
it('should close modal and redirect when close button is clicked with remoteInstallUrl', () => {
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl.yaml')
render(<CreateFromPipeline />)
// Open modal
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
fireEvent.click(importButton)
// Click close button
const closeButton = screen.getByTestId('dsl-modal-close')
fireEvent.click(closeButton)
// Should call replace to remove the URL param
expect(mockReplace).toHaveBeenCalledWith('/datasets/create-from-pipeline')
})
it('should call invalidDatasetList when import is successful', () => {
render(<CreateFromPipeline />)
// Open modal
const importButton = screen.getByText('datasetPipeline.creation.importDSL')
fireEvent.click(importButton)
// Click success button
const successButton = screen.getByTestId('dsl-modal-success')
fireEvent.click(successButton)
// Should call invalidDatasetList
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,842 @@
import { render, screen } from '@testing-library/react'
import List from './index'
import type { PipelineTemplate, PipelineTemplateListResponse } from '@/models/pipeline'
import { ChunkingMode } from '@/models/datasets'
// Mock i18n context
let mockLocale = 'en-US'
jest.mock('@/context/i18n', () => ({
useI18N: () => ({
locale: mockLocale,
}),
}))
// Mock global public store
let mockEnableMarketplace = true
jest.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => boolean) =>
selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
}))
// Mock pipeline service hooks
let mockBuiltInPipelineData: PipelineTemplateListResponse | undefined
let mockBuiltInIsLoading = false
let mockCustomizedPipelineData: PipelineTemplateListResponse | undefined
let mockCustomizedIsLoading = false
jest.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (params: { type: 'built-in' | 'customized'; language?: string }, enabled?: boolean) => {
if (params.type === 'built-in') {
return {
data: enabled !== false ? mockBuiltInPipelineData : undefined,
isLoading: mockBuiltInIsLoading,
}
}
return {
data: mockCustomizedPipelineData,
isLoading: mockCustomizedIsLoading,
}
},
}))
// Mock CreateCard component to avoid deep service dependencies
jest.mock('./create-card', () => ({
__esModule: true,
default: () => (
<div data-testid="create-card" className="h-[132px] cursor-pointer">
<span>datasetPipeline.creation.createFromScratch.title</span>
<span>datasetPipeline.creation.createFromScratch.description</span>
</div>
),
}))
// Mock TemplateCard component to avoid deep service dependencies
jest.mock('./template-card', () => ({
__esModule: true,
default: ({ pipeline, type, showMoreOperations }: {
pipeline: PipelineTemplate
type: 'built-in' | 'customized'
showMoreOperations?: boolean
}) => (
<div
data-testid={`template-card-${pipeline.id}`}
data-type={type}
data-show-more={showMoreOperations}
className="h-[132px]"
>
<span data-testid={`template-name-${pipeline.id}`}>{pipeline.name}</span>
<span data-testid={`template-description-${pipeline.id}`}>{pipeline.description}</span>
<span data-testid={`template-chunk-structure-${pipeline.id}`}>{pipeline.chunk_structure}</span>
</div>
),
}))
// Factory function for creating mock pipeline templates
const createMockPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'template-1',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon: {
icon_type: 'emoji',
icon: '🔧',
icon_background: '#FFEAD5',
icon_url: '',
},
position: 1,
chunk_structure: ChunkingMode.text,
...overrides,
})
describe('List', () => {
beforeEach(() => {
jest.clearAllMocks()
mockLocale = 'en-US'
mockEnableMarketplace = true
mockBuiltInPipelineData = undefined
mockBuiltInIsLoading = false
mockCustomizedPipelineData = undefined
mockCustomizedIsLoading = false
})
/**
* List Component Container
* Tests for the main List wrapper component rendering and styling
*/
describe('List Component Container', () => {
it('should render without crashing', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toBeInTheDocument()
})
it('should render the main container as a div element', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer.tagName).toBe('DIV')
})
it('should render the main container with grow class for flex expansion', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('grow')
})
it('should render the main container with gap-y-1 class for vertical spacing', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('gap-y-1')
})
it('should render the main container with overflow-y-auto for vertical scrolling', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('overflow-y-auto')
})
it('should render the main container with horizontal padding px-16', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('px-16')
})
it('should render the main container with bottom padding pb-[60px]', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('pb-[60px]')
})
it('should render the main container with top padding pt-1', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('pt-1')
})
it('should have all required styling classes applied', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('grow')
expect(mainContainer).toHaveClass('gap-y-1')
expect(mainContainer).toHaveClass('overflow-y-auto')
expect(mainContainer).toHaveClass('px-16')
expect(mainContainer).toHaveClass('pb-[60px]')
expect(mainContainer).toHaveClass('pt-1')
})
it('should render both BuiltInPipelineList and CustomizedList as children when customized data exists', () => {
mockCustomizedPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-child-test' })],
}
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
// BuiltInPipelineList always renders (1 child)
// CustomizedList renders when it has data (adds more children: title + grid)
// So we should have at least 2 children when customized data exists
expect(mainContainer.children.length).toBeGreaterThanOrEqual(2)
})
it('should render only BuiltInPipelineList when customized list is empty', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
// CustomizedList returns null when empty, so only BuiltInPipelineList renders
expect(mainContainer.children.length).toBe(1)
})
})
/**
* BuiltInPipelineList Integration
* Tests for built-in pipeline templates list including CreateCard and TemplateCards
*/
describe('BuiltInPipelineList Integration', () => {
it('should render CreateCard component', () => {
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.creation.createFromScratch.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.creation.createFromScratch.description')).toBeInTheDocument()
})
it('should render grid container with correct responsive classes', () => {
const { container } = render(<List />)
const gridContainer = container.querySelector('.grid')
expect(gridContainer).toBeInTheDocument()
expect(gridContainer).toHaveClass('grid-cols-1')
expect(gridContainer).toHaveClass('gap-3')
expect(gridContainer).toHaveClass('py-2')
expect(gridContainer).toHaveClass('sm:grid-cols-2')
expect(gridContainer).toHaveClass('md:grid-cols-3')
expect(gridContainer).toHaveClass('lg:grid-cols-4')
})
it('should not render built-in template cards when loading', () => {
mockBuiltInIsLoading = true
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate()],
}
render(<List />)
expect(screen.queryByTestId('template-card-template-1')).not.toBeInTheDocument()
})
it('should render built-in template cards when data is loaded', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'built-1', name: 'Pipeline 1' }),
createMockPipelineTemplate({ id: 'built-2', name: 'Pipeline 2' }),
],
}
render(<List />)
expect(screen.getByTestId('template-card-built-1')).toBeInTheDocument()
expect(screen.getByTestId('template-card-built-2')).toBeInTheDocument()
expect(screen.getByText('Pipeline 1')).toBeInTheDocument()
expect(screen.getByText('Pipeline 2')).toBeInTheDocument()
})
it('should render empty state when no built-in templates (only CreateCard visible)', () => {
mockBuiltInPipelineData = {
pipeline_templates: [],
}
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.queryByTestId(/^template-card-/)).not.toBeInTheDocument()
})
it('should handle undefined pipeline_templates gracefully', () => {
mockBuiltInPipelineData = {} as PipelineTemplateListResponse
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should pass type=built-in to TemplateCard', () => {
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'built-type-test' })],
}
render(<List />)
const templateCard = screen.getByTestId('template-card-built-type-test')
expect(templateCard).toHaveAttribute('data-type', 'built-in')
})
it('should pass showMoreOperations=false to built-in TemplateCards', () => {
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'built-ops-test' })],
}
render(<List />)
const templateCard = screen.getByTestId('template-card-built-ops-test')
expect(templateCard).toHaveAttribute('data-show-more', 'false')
})
it('should render multiple built-in templates in order', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'first', name: 'First' }),
createMockPipelineTemplate({ id: 'second', name: 'Second' }),
createMockPipelineTemplate({ id: 'third', name: 'Third' }),
],
}
const { container } = render(<List />)
const gridContainer = container.querySelector('.grid')
const cards = gridContainer?.querySelectorAll('[data-testid^="template-card-"]')
expect(cards?.length).toBe(3)
})
})
/**
* CustomizedList Integration
* Tests for customized pipeline templates list including conditional rendering
*/
describe('CustomizedList Integration', () => {
it('should return null when loading', () => {
mockCustomizedIsLoading = true
render(<List />)
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
})
it('should return null when list is empty', () => {
mockCustomizedPipelineData = {
pipeline_templates: [],
}
render(<List />)
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
})
it('should return null when pipeline_templates is undefined', () => {
mockCustomizedPipelineData = {} as PipelineTemplateListResponse
render(<List />)
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
})
it('should render customized section title when data is available', () => {
mockCustomizedPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-1' })],
}
render(<List />)
expect(screen.getByText('datasetPipeline.templates.customized')).toBeInTheDocument()
})
it('should render customized title with correct styling', () => {
mockCustomizedPipelineData = {
pipeline_templates: [createMockPipelineTemplate()],
}
const { container } = render(<List />)
const title = container.querySelector('.system-sm-semibold-uppercase')
expect(title).toBeInTheDocument()
expect(title).toHaveClass('pt-2')
expect(title).toHaveClass('text-text-tertiary')
})
it('should render customized template cards', () => {
mockCustomizedPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'custom-1', name: 'Custom Pipeline 1' }),
],
}
render(<List />)
expect(screen.getByTestId('template-card-custom-1')).toBeInTheDocument()
expect(screen.getByText('Custom Pipeline 1')).toBeInTheDocument()
})
it('should render multiple customized templates', () => {
mockCustomizedPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'custom-1', name: 'Custom 1' }),
createMockPipelineTemplate({ id: 'custom-2', name: 'Custom 2' }),
createMockPipelineTemplate({ id: 'custom-3', name: 'Custom 3' }),
],
}
render(<List />)
expect(screen.getByText('Custom 1')).toBeInTheDocument()
expect(screen.getByText('Custom 2')).toBeInTheDocument()
expect(screen.getByText('Custom 3')).toBeInTheDocument()
})
it('should pass type=customized to TemplateCard', () => {
mockCustomizedPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-type-test' })],
}
render(<List />)
const templateCard = screen.getByTestId('template-card-custom-type-test')
expect(templateCard).toHaveAttribute('data-type', 'customized')
})
it('should not pass showMoreOperations prop to customized TemplateCards (defaults to true)', () => {
mockCustomizedPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-ops-test' })],
}
render(<List />)
const templateCard = screen.getByTestId('template-card-custom-ops-test')
// showMoreOperations is not passed, so data-show-more should be undefined
expect(templateCard).not.toHaveAttribute('data-show-more', 'false')
})
it('should render customized grid with responsive classes', () => {
mockCustomizedPipelineData = {
pipeline_templates: [createMockPipelineTemplate()],
}
const { container } = render(<List />)
// Find the second grid (customized list grid)
const grids = container.querySelectorAll('.grid')
expect(grids.length).toBe(2) // built-in grid and customized grid
expect(grids[1]).toHaveClass('grid-cols-1')
expect(grids[1]).toHaveClass('gap-3')
expect(grids[1]).toHaveClass('py-2')
})
})
/**
* Language Handling
* Tests for locale-based language selection in BuiltInPipelineList
*/
describe('Language Handling', () => {
it('should use zh-Hans locale when set', () => {
mockLocale = 'zh-Hans'
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate()],
}
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should use ja-JP locale when set', () => {
mockLocale = 'ja-JP'
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate()],
}
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should fallback to default language for unsupported locales', () => {
mockLocale = 'fr-FR'
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate()],
}
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should handle ko-KR locale (fallback)', () => {
mockLocale = 'ko-KR'
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
})
/**
* Marketplace Feature Flag
* Tests for enable_marketplace system feature affecting built-in templates fetching
*/
describe('Marketplace Feature Flag', () => {
it('should not fetch built-in templates when marketplace is disabled', () => {
mockEnableMarketplace = false
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ name: 'Should Not Show' })],
}
render(<List />)
// CreateCard should render but template should not (enabled=false)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.queryByText('Should Not Show')).not.toBeInTheDocument()
})
it('should fetch built-in templates when marketplace is enabled', () => {
mockEnableMarketplace = true
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'marketplace', name: 'Marketplace Template' })],
}
render(<List />)
expect(screen.getByText('Marketplace Template')).toBeInTheDocument()
})
})
/**
* Template Data Rendering
* Tests for correct rendering of template properties (name, description, chunk_structure)
*/
describe('Template Data Rendering', () => {
it('should render template name correctly', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'name-test', name: 'My Custom Pipeline Name' }),
],
}
render(<List />)
expect(screen.getByTestId('template-name-name-test')).toHaveTextContent('My Custom Pipeline Name')
})
it('should render template description correctly', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'desc-test', description: 'This is a detailed description' }),
],
}
render(<List />)
expect(screen.getByTestId('template-description-desc-test')).toHaveTextContent('This is a detailed description')
})
it('should render template with text chunk structure', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'chunk-text', chunk_structure: ChunkingMode.text }),
],
}
render(<List />)
expect(screen.getByTestId('template-chunk-structure-chunk-text')).toHaveTextContent(ChunkingMode.text)
})
it('should render template with qa chunk structure', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'chunk-qa', chunk_structure: ChunkingMode.qa }),
],
}
render(<List />)
expect(screen.getByTestId('template-chunk-structure-chunk-qa')).toHaveTextContent(ChunkingMode.qa)
})
it('should render template with parentChild chunk structure', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'chunk-pc', chunk_structure: ChunkingMode.parentChild }),
],
}
render(<List />)
expect(screen.getByTestId('template-chunk-structure-chunk-pc')).toHaveTextContent(ChunkingMode.parentChild)
})
})
/**
* Edge Cases
* Tests for boundary conditions, special characters, and component lifecycle
*/
describe('Edge Cases', () => {
it('should handle component mounting without errors', () => {
expect(() => render(<List />)).not.toThrow()
})
it('should handle component unmounting without errors', () => {
const { unmount } = render(<List />)
expect(() => unmount()).not.toThrow()
})
it('should handle multiple rerenders without issues', () => {
const { rerender } = render(<List />)
rerender(<List />)
rerender(<List />)
rerender(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should maintain consistent DOM structure across rerenders', () => {
const { container, rerender } = render(<List />)
const initialChildCount = (container.firstChild as HTMLElement)?.children.length
rerender(<List />)
const afterRerenderChildCount = (container.firstChild as HTMLElement)?.children.length
expect(afterRerenderChildCount).toBe(initialChildCount)
})
it('should handle concurrent built-in and customized templates', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'built-in-1', name: 'Built-in Template' }),
],
}
mockCustomizedPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'custom-1', name: 'Customized Template' }),
],
}
render(<List />)
expect(screen.getByText('Built-in Template')).toBeInTheDocument()
expect(screen.getByText('Customized Template')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.templates.customized')).toBeInTheDocument()
})
it('should handle templates with long names gracefully', () => {
const longName = 'A'.repeat(100)
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'long-name', name: longName }),
],
}
render(<List />)
expect(screen.getByTestId('template-name-long-name')).toHaveTextContent(longName)
})
it('should handle templates with empty description', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'empty-desc', description: '' }),
],
}
render(<List />)
expect(screen.getByTestId('template-description-empty-desc')).toHaveTextContent('')
})
it('should handle templates with special characters in name', () => {
mockBuiltInPipelineData = {
pipeline_templates: [
createMockPipelineTemplate({ id: 'special', name: 'Test <>&"\'Pipeline' }),
],
}
render(<List />)
expect(screen.getByTestId('template-name-special')).toHaveTextContent('Test <>&"\'Pipeline')
})
it('should handle rapid mount/unmount cycles', () => {
for (let i = 0; i < 5; i++) {
const { unmount } = render(<List />)
unmount()
}
expect(true).toBe(true)
})
})
/**
* Loading States
* Tests for component behavior during data loading
*/
describe('Loading States', () => {
it('should handle both lists loading simultaneously', () => {
mockBuiltInIsLoading = true
mockCustomizedIsLoading = true
render(<List />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
})
it('should handle built-in loading while customized is loaded', () => {
mockBuiltInIsLoading = true
mockCustomizedPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'custom-only', name: 'Customized Only' })],
}
render(<List />)
expect(screen.getByText('Customized Only')).toBeInTheDocument()
})
it('should handle customized loading while built-in is loaded', () => {
mockCustomizedIsLoading = true
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'built-only', name: 'Built-in Only' })],
}
render(<List />)
expect(screen.getByText('Built-in Only')).toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.templates.customized')).not.toBeInTheDocument()
})
it('should transition from loading to loaded state', () => {
mockBuiltInIsLoading = true
const { rerender } = render(<List />)
expect(screen.queryByTestId('template-card-transition')).not.toBeInTheDocument()
// Simulate data loaded
mockBuiltInIsLoading = false
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'transition', name: 'After Load' })],
}
rerender(<List />)
expect(screen.getByText('After Load')).toBeInTheDocument()
})
})
/**
* Component Stability
* Tests for consistent rendering and state management
*/
describe('Component Stability', () => {
it('should render same structure on initial render and rerender', () => {
const { container, rerender } = render(<List />)
const initialHTML = container.innerHTML
rerender(<List />)
const rerenderHTML = container.innerHTML
expect(rerenderHTML).toBe(initialHTML)
})
it('should not cause memory leaks on unmount', () => {
const { unmount } = render(<List />)
unmount()
expect(true).toBe(true)
})
it('should handle state changes correctly', () => {
mockBuiltInPipelineData = undefined
const { rerender } = render(<List />)
// Add data
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate({ id: 'state-test', name: 'State Test' })],
}
rerender(<List />)
expect(screen.getByText('State Test')).toBeInTheDocument()
})
})
/**
* Accessibility
* Tests for semantic structure and keyboard navigation support
*/
describe('Accessibility', () => {
it('should use semantic div structure for main container', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer.tagName).toBe('DIV')
})
it('should have scrollable container for keyboard navigation', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('overflow-y-auto')
})
it('should have appropriate spacing for readability', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('gap-y-1')
expect(mainContainer).toHaveClass('px-16')
})
it('should render grid structure for template cards', () => {
mockBuiltInPipelineData = {
pipeline_templates: [createMockPipelineTemplate()],
}
const { container } = render(<List />)
const grid = container.querySelector('.grid')
expect(grid).toBeInTheDocument()
})
})
/**
* Large Datasets
* Tests for performance with many templates
*/
describe('Large Datasets', () => {
it('should handle many built-in templates', () => {
mockBuiltInPipelineData = {
pipeline_templates: Array.from({ length: 50 }, (_, i) =>
createMockPipelineTemplate({ id: `built-${i}`, name: `Pipeline ${i}` }),
),
}
render(<List />)
expect(screen.getByText('Pipeline 0')).toBeInTheDocument()
expect(screen.getByText('Pipeline 49')).toBeInTheDocument()
})
it('should handle many customized templates', () => {
mockCustomizedPipelineData = {
pipeline_templates: Array.from({ length: 50 }, (_, i) =>
createMockPipelineTemplate({ id: `custom-${i}`, name: `Custom ${i}` }),
),
}
render(<List />)
expect(screen.getByText('Custom 0')).toBeInTheDocument()
expect(screen.getByText('Custom 49')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,786 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Details from './index'
import type { PipelineTemplateByIdResponse } from '@/models/pipeline'
import { ChunkingMode } from '@/models/datasets'
import type { Edge, Node, Viewport } from 'reactflow'
// Mock usePipelineTemplateById hook
let mockPipelineTemplateData: PipelineTemplateByIdResponse | undefined
let mockIsLoading = false
jest.mock('@/service/use-pipeline', () => ({
usePipelineTemplateById: (params: { template_id: string; type: 'customized' | 'built-in' }, enabled: boolean) => ({
data: enabled ? mockPipelineTemplateData : undefined,
isLoading: mockIsLoading,
}),
}))
// Mock WorkflowPreview component to avoid deep dependencies
jest.mock('@/app/components/workflow/workflow-preview', () => ({
__esModule: true,
default: ({ nodes, edges, viewport, className }: {
nodes: Node[]
edges: Edge[]
viewport: Viewport
className?: string
}) => (
<div
data-testid="workflow-preview"
data-nodes-count={nodes?.length ?? 0}
data-edges-count={edges?.length ?? 0}
data-viewport-zoom={viewport?.zoom}
className={className}
>
WorkflowPreview
</div>
),
}))
// Factory function for creating mock pipeline template response
const createMockPipelineTemplate = (
overrides: Partial<PipelineTemplateByIdResponse> = {},
): PipelineTemplateByIdResponse => ({
id: 'test-template-id',
name: 'Test Pipeline Template',
icon_info: {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
},
description: 'Test pipeline description for testing purposes',
chunk_structure: ChunkingMode.text,
export_data: '{}',
graph: {
nodes: [
{ id: 'node-1', type: 'custom', position: { x: 0, y: 0 }, data: {} },
] as unknown as Node[],
edges: [] as Edge[],
viewport: { x: 0, y: 0, zoom: 1 },
},
created_by: 'Test Author',
...overrides,
})
// Default props factory
const createDefaultProps = () => ({
id: 'test-id',
type: 'built-in' as const,
onApplyTemplate: jest.fn(),
onClose: jest.fn(),
})
describe('Details', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPipelineTemplateData = undefined
mockIsLoading = false
})
/**
* Loading State Tests
* Tests for component behavior when data is loading or undefined
*/
describe('Loading State', () => {
it('should render Loading component when pipelineTemplateInfo is undefined', () => {
mockPipelineTemplateData = undefined
const props = createDefaultProps()
const { container } = render(<Details {...props} />)
// Loading component renders a spinner SVG with spin-animation class
const spinner = container.querySelector('.spin-animation')
expect(spinner).toBeInTheDocument()
})
it('should render Loading component when data is still loading', () => {
mockIsLoading = true
mockPipelineTemplateData = undefined
const props = createDefaultProps()
const { container } = render(<Details {...props} />)
// Loading component renders a spinner SVG with spin-animation class
const spinner = container.querySelector('.spin-animation')
expect(spinner).toBeInTheDocument()
})
it('should not render main content while loading', () => {
mockPipelineTemplateData = undefined
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.queryByTestId('workflow-preview')).not.toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.operations.useTemplate')).not.toBeInTheDocument()
})
})
/**
* Rendering Tests
* Tests for correct rendering when data is available
*/
describe('Rendering', () => {
it('should render without crashing when data is available', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { container } = render(<Details {...props} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render the main container with flex layout', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { container } = render(<Details {...props} />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('flex')
expect(mainContainer).toHaveClass('h-full')
})
it('should render WorkflowPreview component', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should pass graph data to WorkflowPreview', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
graph: {
nodes: [
{ id: '1', type: 'custom', position: { x: 0, y: 0 }, data: {} },
{ id: '2', type: 'custom', position: { x: 100, y: 100 }, data: {} },
] as unknown as Node[],
edges: [
{ id: 'e1', source: '1', target: '2' },
] as unknown as Edge[],
viewport: { x: 10, y: 20, zoom: 1.5 },
},
})
const props = createDefaultProps()
render(<Details {...props} />)
const preview = screen.getByTestId('workflow-preview')
expect(preview).toHaveAttribute('data-nodes-count', '2')
expect(preview).toHaveAttribute('data-edges-count', '1')
expect(preview).toHaveAttribute('data-viewport-zoom', '1.5')
})
it('should render template name', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'My Test Pipeline' })
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('My Test Pipeline')).toBeInTheDocument()
})
it('should render template description', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ description: 'This is a test description' })
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('This is a test description')).toBeInTheDocument()
})
it('should render created_by information when available', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ created_by: 'John Doe' })
const props = createDefaultProps()
render(<Details {...props} />)
// The translation key includes the author
expect(screen.getByText('datasetPipeline.details.createdBy')).toBeInTheDocument()
})
it('should not render created_by when not available', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ created_by: '' })
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.queryByText(/createdBy/)).not.toBeInTheDocument()
})
it('should render "Use Template" button', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('datasetPipeline.operations.useTemplate')).toBeInTheDocument()
})
it('should render close button', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const closeButton = screen.getByRole('button', { name: '' })
expect(closeButton).toBeInTheDocument()
})
it('should render structure section title', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
})
it('should render structure tooltip', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
// Tooltip component should be rendered
expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
})
})
/**
* Event Handler Tests
* Tests for user interactions and callback functions
*/
describe('Event Handlers', () => {
it('should call onApplyTemplate when "Use Template" button is clicked', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
fireEvent.click(useTemplateButton!)
expect(props.onApplyTemplate).toHaveBeenCalledTimes(1)
})
it('should call onClose when close button is clicked', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { container } = render(<Details {...props} />)
// Find the close button (the one with RiCloseLine icon)
const closeButton = container.querySelector('button.absolute.right-4')
fireEvent.click(closeButton!)
expect(props.onClose).toHaveBeenCalledTimes(1)
})
it('should not call handlers on multiple clicks (each click should trigger once)', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
fireEvent.click(useTemplateButton!)
fireEvent.click(useTemplateButton!)
fireEvent.click(useTemplateButton!)
expect(props.onApplyTemplate).toHaveBeenCalledTimes(3)
})
})
/**
* Props Variations Tests
* Tests for different prop combinations
*/
describe('Props Variations', () => {
it('should handle built-in type', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = { ...createDefaultProps(), type: 'built-in' as const }
render(<Details {...props} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should handle customized type', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = { ...createDefaultProps(), type: 'customized' as const }
render(<Details {...props} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should handle different template IDs', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = { ...createDefaultProps(), id: 'unique-template-123' }
render(<Details {...props} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
})
/**
* App Icon Memoization Tests
* Tests for the useMemo logic that computes appIcon
*/
describe('App Icon Memoization', () => {
it('should use default emoji icon when pipelineTemplateInfo is undefined', () => {
mockPipelineTemplateData = undefined
const props = createDefaultProps()
render(<Details {...props} />)
// Loading state - no AppIcon rendered
expect(screen.queryByTestId('workflow-preview')).not.toBeInTheDocument()
})
it('should handle emoji icon type', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
icon_info: {
icon_type: 'emoji',
icon: '🚀',
icon_background: '#E6F4FF',
icon_url: '',
},
})
const props = createDefaultProps()
render(<Details {...props} />)
// AppIcon should be rendered with emoji
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should handle image icon type', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
icon_info: {
icon_type: 'image',
icon: 'file-id-123',
icon_background: '',
icon_url: 'https://example.com/image.png',
},
})
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should handle image icon type with empty url and icon (fallback branch)', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
icon_info: {
icon_type: 'image',
icon: '', // empty string - triggers || '' fallback
icon_background: '',
icon_url: '', // empty string - triggers || '' fallback
},
})
const props = createDefaultProps()
render(<Details {...props} />)
// Component should still render without errors
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should handle missing icon properties gracefully', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
icon_info: {
icon_type: 'emoji',
icon: '',
icon_background: '',
icon_url: '',
},
})
const props = createDefaultProps()
expect(() => render(<Details {...props} />)).not.toThrow()
})
})
/**
* Chunk Structure Tests
* Tests for different chunk_structure values and ChunkStructureCard rendering
*/
describe('Chunk Structure', () => {
it('should render ChunkStructureCard for text chunk structure', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
chunk_structure: ChunkingMode.text,
})
const props = createDefaultProps()
render(<Details {...props} />)
// ChunkStructureCard should be rendered
expect(screen.getByText('datasetPipeline.details.structure')).toBeInTheDocument()
// General option title
expect(screen.getByText('General')).toBeInTheDocument()
})
it('should render ChunkStructureCard for parentChild chunk structure', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
chunk_structure: ChunkingMode.parentChild,
})
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('Parent-Child')).toBeInTheDocument()
})
it('should render ChunkStructureCard for qa chunk structure', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
chunk_structure: ChunkingMode.qa,
})
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('Q&A')).toBeInTheDocument()
})
})
/**
* Edge Cases Tests
* Tests for boundary conditions and unusual inputs
*/
describe('Edge Cases', () => {
it('should handle empty name', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ name: '' })
const props = createDefaultProps()
expect(() => render(<Details {...props} />)).not.toThrow()
})
it('should handle empty description', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ description: '' })
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should handle very long name', () => {
const longName = 'A'.repeat(200)
mockPipelineTemplateData = createMockPipelineTemplate({ name: longName })
const props = createDefaultProps()
render(<Details {...props} />)
const nameElement = screen.getByText(longName)
expect(nameElement).toBeInTheDocument()
expect(nameElement).toHaveClass('truncate')
})
it('should handle very long description', () => {
const longDesc = 'B'.repeat(1000)
mockPipelineTemplateData = createMockPipelineTemplate({ description: longDesc })
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText(longDesc)).toBeInTheDocument()
})
it('should handle special characters in name', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
name: 'Test <>&"\'Pipeline @#$%^&*()',
})
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('Test <>&"\'Pipeline @#$%^&*()')).toBeInTheDocument()
})
it('should handle unicode characters', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
name: '测试管道 🚀 テスト',
description: '这是一个测试描述 日本語テスト',
})
const props = createDefaultProps()
render(<Details {...props} />)
expect(screen.getByText('测试管道 🚀 テスト')).toBeInTheDocument()
expect(screen.getByText('这是一个测试描述 日本語テスト')).toBeInTheDocument()
})
it('should handle empty graph nodes and edges', () => {
mockPipelineTemplateData = createMockPipelineTemplate({
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
const props = createDefaultProps()
render(<Details {...props} />)
const preview = screen.getByTestId('workflow-preview')
expect(preview).toHaveAttribute('data-nodes-count', '0')
expect(preview).toHaveAttribute('data-edges-count', '0')
})
})
/**
* Component Memoization Tests
* Tests for React.memo behavior
*/
describe('Component Memoization', () => {
it('should render correctly after rerender with same props', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { rerender } = render(<Details {...props} />)
expect(screen.getByText('Test Pipeline Template')).toBeInTheDocument()
rerender(<Details {...props} />)
expect(screen.getByText('Test Pipeline Template')).toBeInTheDocument()
})
it('should update when id prop changes', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'First Template' })
const props = createDefaultProps()
const { rerender } = render(<Details {...props} />)
expect(screen.getByText('First Template')).toBeInTheDocument()
// Change the id prop which should trigger a rerender
// Update mock data for the new id
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'Second Template' })
rerender(<Details {...props} id="new-id" />)
expect(screen.getByText('Second Template')).toBeInTheDocument()
})
it('should handle callback reference changes', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { rerender } = render(<Details {...props} />)
const newOnApplyTemplate = jest.fn()
rerender(<Details {...props} onApplyTemplate={newOnApplyTemplate} />)
const useTemplateButton = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
fireEvent.click(useTemplateButton!)
expect(newOnApplyTemplate).toHaveBeenCalledTimes(1)
expect(props.onApplyTemplate).not.toHaveBeenCalled()
})
})
/**
* Component Structure Tests
* Tests for DOM structure and layout
*/
describe('Component Structure', () => {
it('should have left panel for workflow preview', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { container } = render(<Details {...props} />)
const leftPanel = container.querySelector('.grow.items-center.justify-center')
expect(leftPanel).toBeInTheDocument()
})
it('should have right panel with fixed width', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { container } = render(<Details {...props} />)
const rightPanel = container.querySelector('.w-\\[360px\\]')
expect(rightPanel).toBeInTheDocument()
})
it('should have primary button variant for Use Template', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const button = screen.getByText('datasetPipeline.operations.useTemplate').closest('button')
// Button should have primary styling
expect(button).toBeInTheDocument()
})
it('should have title attribute for truncation tooltip', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'My Pipeline Name' })
const props = createDefaultProps()
render(<Details {...props} />)
const nameElement = screen.getByText('My Pipeline Name')
expect(nameElement).toHaveAttribute('title', 'My Pipeline Name')
})
it('should have title attribute on created_by for truncation', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ created_by: 'Author Name' })
const props = createDefaultProps()
render(<Details {...props} />)
const createdByElement = screen.getByText('datasetPipeline.details.createdBy')
expect(createdByElement).toHaveAttribute('title', 'Author Name')
})
})
/**
* Component Lifecycle Tests
* Tests for mount/unmount behavior
*/
describe('Component Lifecycle', () => {
it('should mount without errors', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
expect(() => render(<Details {...props} />)).not.toThrow()
})
it('should unmount without errors', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
const { unmount } = render(<Details {...props} />)
expect(() => unmount()).not.toThrow()
})
it('should handle rapid mount/unmount cycles', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
for (let i = 0; i < 5; i++) {
const { unmount } = render(<Details {...props} />)
unmount()
}
expect(true).toBe(true)
})
it('should transition from loading to loaded state', () => {
mockPipelineTemplateData = undefined
const props = createDefaultProps()
const { rerender, container } = render(<Details {...props} />)
// Loading component renders a spinner SVG with spin-animation class
const spinner = container.querySelector('.spin-animation')
expect(spinner).toBeInTheDocument()
// Simulate data loaded - need to change props to trigger rerender with React.memo
mockPipelineTemplateData = createMockPipelineTemplate()
rerender(<Details {...props} id="loaded-id" />)
expect(container.querySelector('.spin-animation')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
})
/**
* Styling Tests
* Tests for CSS classes and visual styling
*/
describe('Styling', () => {
it('should apply overflow-hidden rounded-2xl to WorkflowPreview container', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const preview = screen.getByTestId('workflow-preview')
expect(preview).toHaveClass('overflow-hidden')
expect(preview).toHaveClass('rounded-2xl')
})
it('should apply correct typography classes to template name', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const nameElement = screen.getByText('Test Pipeline Template')
expect(nameElement).toHaveClass('system-md-semibold')
expect(nameElement).toHaveClass('text-text-secondary')
})
it('should apply correct styling to description', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const description = screen.getByText('Test pipeline description for testing purposes')
expect(description).toHaveClass('system-sm-regular')
expect(description).toHaveClass('text-text-secondary')
})
it('should apply correct styling to structure title', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = createDefaultProps()
render(<Details {...props} />)
const structureTitle = screen.getByText('datasetPipeline.details.structure')
expect(structureTitle).toHaveClass('system-sm-semibold-uppercase')
expect(structureTitle).toHaveClass('text-text-secondary')
})
})
/**
* API Hook Integration Tests
* Tests for usePipelineTemplateById hook behavior
*/
describe('API Hook Integration', () => {
it('should pass correct params to usePipelineTemplateById for built-in type', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = { ...createDefaultProps(), id: 'test-id-123', type: 'built-in' as const }
render(<Details {...props} />)
// The hook should be called with the correct parameters
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should pass correct params to usePipelineTemplateById for customized type', () => {
mockPipelineTemplateData = createMockPipelineTemplate()
const props = { ...createDefaultProps(), id: 'custom-id-456', type: 'customized' as const }
render(<Details {...props} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should handle data refetch on id change', () => {
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'First Template' })
const props = createDefaultProps()
const { rerender } = render(<Details {...props} />)
expect(screen.getByText('First Template')).toBeInTheDocument()
// Change id and update mock data
mockPipelineTemplateData = createMockPipelineTemplate({ name: 'Second Template' })
rerender(<Details {...props} id="new-id" />)
expect(screen.getByText('Second Template')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,965 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import TemplateCard from './index'
import type { PipelineTemplate, PipelineTemplateByIdResponse } from '@/models/pipeline'
import { ChunkingMode } from '@/models/datasets'
// Mock Next.js router
const mockPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
let mockCreateDataset: jest.Mock
let mockDeleteTemplate: jest.Mock
let mockExportTemplateDSL: jest.Mock
let mockInvalidCustomizedTemplateList: jest.Mock
let mockInvalidDatasetList: jest.Mock
let mockHandleCheckPluginDependencies: jest.Mock
let mockIsExporting = false
// Mock service hooks
let mockPipelineTemplateByIdData: PipelineTemplateByIdResponse | undefined
let mockRefetch: jest.Mock
jest.mock('@/service/use-pipeline', () => ({
usePipelineTemplateById: () => ({
data: mockPipelineTemplateByIdData,
refetch: mockRefetch,
}),
useDeleteTemplate: () => ({
mutateAsync: mockDeleteTemplate,
}),
useExportTemplateDSL: () => ({
mutateAsync: mockExportTemplateDSL,
isPending: mockIsExporting,
}),
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
jest.mock('@/service/knowledge/use-create-dataset', () => ({
useCreatePipelineDatasetFromCustomized: () => ({
mutateAsync: mockCreateDataset,
}),
}))
jest.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
jest.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
}),
}))
// Mock downloadFile
const mockDownloadFile = jest.fn()
jest.mock('@/utils/format', () => ({
downloadFile: (params: { data: Blob; fileName: string }) => mockDownloadFile(params),
}))
// Mock trackEvent
const mockTrackEvent = jest.fn()
jest.mock('@/app/components/base/amplitude', () => ({
trackEvent: (name: string, params: Record<string, unknown>) => mockTrackEvent(name, params),
}))
// Mock child components to simplify testing
jest.mock('./content', () => ({
__esModule: true,
default: ({ name, description, iconInfo, chunkStructure }: {
name: string
description: string
iconInfo: { icon_type: string }
chunkStructure: string
}) => (
<div data-testid="content">
<span data-testid="content-name">{name}</span>
<span data-testid="content-description">{description}</span>
<span data-testid="content-icon-type">{iconInfo.icon_type}</span>
<span data-testid="content-chunk-structure">{chunkStructure}</span>
</div>
),
}))
jest.mock('./actions', () => ({
__esModule: true,
default: ({
onApplyTemplate,
handleShowTemplateDetails,
showMoreOperations,
openEditModal,
handleExportDSL,
handleDelete,
}: {
onApplyTemplate: () => void
handleShowTemplateDetails: () => void
showMoreOperations: boolean
openEditModal: () => void
handleExportDSL: () => void
handleDelete: () => void
}) => (
<div data-testid="actions" data-show-more={showMoreOperations}>
<button data-testid="apply-template-btn" onClick={onApplyTemplate}>Apply</button>
<button data-testid="show-details-btn" onClick={handleShowTemplateDetails}>Details</button>
<button data-testid="edit-modal-btn" onClick={openEditModal}>Edit</button>
<button data-testid="export-dsl-btn" onClick={handleExportDSL}>Export</button>
<button data-testid="delete-btn" onClick={handleDelete}>Delete</button>
</div>
),
}))
jest.mock('./details', () => ({
__esModule: true,
default: ({ id, type, onClose, onApplyTemplate }: {
id: string
type: string
onClose: () => void
onApplyTemplate: () => void
}) => (
<div data-testid="details-modal">
<span data-testid="details-id">{id}</span>
<span data-testid="details-type">{type}</span>
<button data-testid="details-close-btn" onClick={onClose}>Close</button>
<button data-testid="details-apply-btn" onClick={onApplyTemplate}>Apply</button>
</div>
),
}))
jest.mock('./edit-pipeline-info', () => ({
__esModule: true,
default: ({ pipeline, onClose }: {
pipeline: PipelineTemplate
onClose: () => void
}) => (
<div data-testid="edit-pipeline-modal">
<span data-testid="edit-pipeline-id">{pipeline.id}</span>
<button data-testid="edit-close-btn" onClick={onClose}>Close</button>
</div>
),
}))
// Factory function for creating mock pipeline template
const createMockPipeline = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'test-pipeline-id',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon: {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
},
position: 1,
chunk_structure: ChunkingMode.text,
...overrides,
})
// Factory function for creating mock pipeline template by id response
const createMockPipelineByIdResponse = (
overrides: Partial<PipelineTemplateByIdResponse> = {},
): PipelineTemplateByIdResponse => ({
id: 'test-pipeline-id',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon_info: {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
},
chunk_structure: ChunkingMode.text,
export_data: 'yaml_content_here',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
created_by: 'Test Author',
...overrides,
})
// Default props factory
const createDefaultProps = () => ({
pipeline: createMockPipeline(),
type: 'built-in' as const,
showMoreOperations: true,
})
describe('TemplateCard', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPipelineTemplateByIdData = undefined
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn()
mockDeleteTemplate = jest.fn()
mockExportTemplateDSL = jest.fn()
mockInvalidCustomizedTemplateList = jest.fn()
mockInvalidDatasetList = jest.fn()
mockHandleCheckPluginDependencies = jest.fn()
mockIsExporting = false
})
/**
* Rendering Tests
* Tests for basic component rendering
*/
describe('Rendering', () => {
it('should render without crashing', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content')).toBeInTheDocument()
expect(screen.getByTestId('actions')).toBeInTheDocument()
})
it('should render Content component with correct props', () => {
const pipeline = createMockPipeline({
name: 'My Pipeline',
description: 'My description',
chunk_structure: ChunkingMode.qa,
})
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-name')).toHaveTextContent('My Pipeline')
expect(screen.getByTestId('content-description')).toHaveTextContent('My description')
expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(ChunkingMode.qa)
})
it('should render Actions component with showMoreOperations=true by default', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
const actions = screen.getByTestId('actions')
expect(actions).toHaveAttribute('data-show-more', 'true')
})
it('should render Actions component with showMoreOperations=false when specified', () => {
const props = { ...createDefaultProps(), showMoreOperations: false }
render(<TemplateCard {...props} />)
const actions = screen.getByTestId('actions')
expect(actions).toHaveAttribute('data-show-more', 'false')
})
it('should have correct container styling', () => {
const props = createDefaultProps()
const { container } = render(<TemplateCard {...props} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('group')
expect(card).toHaveClass('relative')
expect(card).toHaveClass('flex')
expect(card).toHaveClass('h-[132px]')
expect(card).toHaveClass('cursor-pointer')
expect(card).toHaveClass('rounded-xl')
})
})
/**
* Props Variations Tests
* Tests for different prop combinations
*/
describe('Props Variations', () => {
it('should handle built-in type', () => {
const props = { ...createDefaultProps(), type: 'built-in' as const }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should handle customized type', () => {
const props = { ...createDefaultProps(), type: 'customized' as const }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should handle different pipeline data', () => {
const pipeline = createMockPipeline({
id: 'unique-id-123',
name: 'Unique Pipeline',
description: 'Unique description',
chunk_structure: ChunkingMode.parentChild,
})
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-name')).toHaveTextContent('Unique Pipeline')
expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(ChunkingMode.parentChild)
})
it('should handle image icon type', () => {
const pipeline = createMockPipeline({
icon: {
icon_type: 'image',
icon: 'file-id',
icon_background: '',
icon_url: 'https://example.com/image.png',
},
})
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-icon-type')).toHaveTextContent('image')
})
})
/**
* State Management Tests
* Tests for modal state (showEditModal, showDeleteConfirm, showDetailModal)
*/
describe('State Management', () => {
it('should not show edit modal initially', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
})
it('should show edit modal when openEditModal is called', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('edit-modal-btn'))
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
})
it('should close edit modal when onClose is called', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('edit-modal-btn'))
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('edit-close-btn'))
expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
})
it('should not show delete confirm initially', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
})
it('should show delete confirm when handleDelete is called', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('delete-btn'))
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
})
it('should not show details modal initially', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
})
it('should show details modal when handleShowTemplateDetails is called', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('show-details-btn'))
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
})
it('should close details modal when onClose is called', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('show-details-btn'))
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('details-close-btn'))
expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
})
it('should pass correct props to details modal', () => {
const pipeline = createMockPipeline({ id: 'detail-test-id' })
const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('show-details-btn'))
expect(screen.getByTestId('details-id')).toHaveTextContent('detail-test-id')
expect(screen.getByTestId('details-type')).toHaveTextContent('customized')
})
})
/**
* Event Handlers Tests
* Tests for callback functions and user interactions
*/
describe('Event Handlers', () => {
describe('handleUseTemplate', () => {
it('should call getPipelineTemplateInfo when apply template is clicked', async () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
it('should not call createDataset when pipelineTemplateInfo is not available', async () => {
mockRefetch = jest.fn().mockResolvedValue({ data: null })
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
// createDataset should not be called when pipelineTemplateInfo is null
expect(mockCreateDataset).not.toHaveBeenCalled()
})
it('should call createDataset with correct yaml_content', async () => {
const pipelineResponse = createMockPipelineByIdResponse({ export_data: 'test-yaml-content' })
mockRefetch = jest.fn().mockResolvedValue({ data: pipelineResponse })
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockCreateDataset).toHaveBeenCalledWith(
{ yaml_content: 'test-yaml-content' },
expect.any(Object),
)
})
})
it('should invalidate list, check plugin dependencies, and navigate on success', async () => {
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: 'new-pipeline-id' })
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('new-pipeline-id', true)
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-id/pipeline')
})
})
it('should track event on successful dataset creation', async () => {
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: 'new-pipeline-id' })
})
const pipeline = createMockPipeline({ id: 'track-test-id', name: 'Track Test Pipeline' })
const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('create_datasets_with_pipeline', {
template_name: 'Track Test Pipeline',
template_id: 'track-test-id',
template_type: 'customized',
})
})
})
it('should not call handleCheckPluginDependencies when pipeline_id is not present', async () => {
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
options.onSuccess({ dataset_id: 'new-dataset-id', pipeline_id: null })
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
})
})
it('should call onError callback when createDataset fails', async () => {
const onErrorSpy = jest.fn()
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
onErrorSpy()
options.onError()
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockCreateDataset).toHaveBeenCalled()
expect(onErrorSpy).toHaveBeenCalled()
})
// Should not navigate on error
expect(mockPush).not.toHaveBeenCalled()
})
})
describe('handleExportDSL', () => {
it('should call exportPipelineDSL with pipeline id', async () => {
const pipeline = createMockPipeline({ id: 'export-test-id' })
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('export-dsl-btn'))
await waitFor(() => {
expect(mockExportTemplateDSL).toHaveBeenCalledWith('export-test-id', expect.any(Object))
})
})
it('should not call exportPipelineDSL when already exporting', async () => {
mockIsExporting = true
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('export-dsl-btn'))
await waitFor(() => {
expect(mockExportTemplateDSL).not.toHaveBeenCalled()
})
})
it('should download file on export success', async () => {
mockExportTemplateDSL = jest.fn().mockImplementation((_id, options) => {
options.onSuccess({ data: 'exported-yaml-content' })
})
const pipeline = createMockPipeline({ name: 'Export Pipeline' })
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('export-dsl-btn'))
await waitFor(() => {
expect(mockDownloadFile).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: 'Export Pipeline.pipeline',
})
})
})
it('should call onError callback on export failure', async () => {
const onErrorSpy = jest.fn()
mockExportTemplateDSL = jest.fn().mockImplementation((_id, options) => {
onErrorSpy()
options.onError()
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('export-dsl-btn'))
await waitFor(() => {
expect(mockExportTemplateDSL).toHaveBeenCalled()
expect(onErrorSpy).toHaveBeenCalled()
})
// Should not download file on error
expect(mockDownloadFile).not.toHaveBeenCalled()
})
})
describe('handleDelete', () => {
it('should call deletePipeline on confirm', async () => {
mockDeleteTemplate = jest.fn().mockImplementation((_id, options) => {
options.onSuccess()
})
const pipeline = createMockPipeline({ id: 'delete-test-id' })
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('delete-btn'))
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
// Find and click confirm button
const confirmButton = screen.getByText('common.operation.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockDeleteTemplate).toHaveBeenCalledWith('delete-test-id', expect.any(Object))
})
})
it('should invalidate customized template list and close confirm on success', async () => {
mockDeleteTemplate = jest.fn().mockImplementation((_id, options) => {
options.onSuccess()
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('delete-btn'))
const confirmButton = screen.getByText('common.operation.confirm')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
})
})
it('should close delete confirm on cancel', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('delete-btn'))
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
const cancelButton = screen.getByText('common.operation.cancel')
fireEvent.click(cancelButton)
expect(screen.queryByText('datasetPipeline.deletePipeline.title')).not.toBeInTheDocument()
})
})
})
/**
* Callback Stability Tests
* Tests for useCallback memoization
*/
describe('Callback Stability', () => {
it('should maintain stable handleShowTemplateDetails reference', () => {
const props = createDefaultProps()
const { rerender } = render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('show-details-btn'))
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('details-close-btn'))
rerender(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('show-details-btn'))
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
})
it('should maintain stable openEditModal reference', () => {
const props = createDefaultProps()
const { rerender } = render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('edit-modal-btn'))
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('edit-close-btn'))
rerender(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('edit-modal-btn'))
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
})
})
/**
* Component Memoization Tests
* Tests for React.memo behavior
*/
describe('Component Memoization', () => {
it('should render correctly after rerender with same props', () => {
const props = createDefaultProps()
const { rerender } = render(<TemplateCard {...props} />)
expect(screen.getByTestId('content')).toBeInTheDocument()
rerender(<TemplateCard {...props} />)
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should update when pipeline prop changes', () => {
const props = createDefaultProps()
const { rerender } = render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-name')).toHaveTextContent('Test Pipeline')
const newPipeline = createMockPipeline({ name: 'Updated Pipeline' })
rerender(<TemplateCard {...props} pipeline={newPipeline} />)
expect(screen.getByTestId('content-name')).toHaveTextContent('Updated Pipeline')
})
it('should update when type prop changes', () => {
const props = createDefaultProps()
const { rerender } = render(<TemplateCard {...props} />)
expect(screen.getByTestId('content')).toBeInTheDocument()
rerender(<TemplateCard {...props} type="customized" />)
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should update when showMoreOperations prop changes', () => {
const props = createDefaultProps()
const { rerender } = render(<TemplateCard {...props} />)
expect(screen.getByTestId('actions')).toHaveAttribute('data-show-more', 'true')
rerender(<TemplateCard {...props} showMoreOperations={false} />)
expect(screen.getByTestId('actions')).toHaveAttribute('data-show-more', 'false')
})
})
/**
* Edge Cases Tests
* Tests for boundary conditions and error handling
*/
describe('Edge Cases', () => {
it('should handle empty pipeline name', () => {
const pipeline = createMockPipeline({ name: '' })
const props = { ...createDefaultProps(), pipeline }
expect(() => render(<TemplateCard {...props} />)).not.toThrow()
expect(screen.getByTestId('content-name')).toHaveTextContent('')
})
it('should handle empty pipeline description', () => {
const pipeline = createMockPipeline({ description: '' })
const props = { ...createDefaultProps(), pipeline }
expect(() => render(<TemplateCard {...props} />)).not.toThrow()
expect(screen.getByTestId('content-description')).toHaveTextContent('')
})
it('should handle very long pipeline name', () => {
const longName = 'A'.repeat(200)
const pipeline = createMockPipeline({ name: longName })
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-name')).toHaveTextContent(longName)
})
it('should handle special characters in name', () => {
const pipeline = createMockPipeline({ name: 'Test <>&"\'Pipeline @#$%' })
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-name')).toHaveTextContent('Test <>&"\'Pipeline @#$%')
})
it('should handle unicode characters', () => {
const pipeline = createMockPipeline({ name: '测试管道 🚀 テスト' })
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-name')).toHaveTextContent('测试管道 🚀 テスト')
})
it('should handle all chunk structure types', () => {
const chunkModes = [ChunkingMode.text, ChunkingMode.parentChild, ChunkingMode.qa]
chunkModes.forEach((mode) => {
const pipeline = createMockPipeline({ chunk_structure: mode })
const props = { ...createDefaultProps(), pipeline }
const { unmount } = render(<TemplateCard {...props} />)
expect(screen.getByTestId('content-chunk-structure')).toHaveTextContent(mode)
unmount()
})
})
})
/**
* Component Lifecycle Tests
* Tests for mount/unmount behavior
*/
describe('Component Lifecycle', () => {
it('should mount without errors', () => {
const props = createDefaultProps()
expect(() => render(<TemplateCard {...props} />)).not.toThrow()
})
it('should unmount without errors', () => {
const props = createDefaultProps()
const { unmount } = render(<TemplateCard {...props} />)
expect(() => unmount()).not.toThrow()
})
it('should handle rapid mount/unmount cycles', () => {
const props = createDefaultProps()
for (let i = 0; i < 5; i++) {
const { unmount } = render(<TemplateCard {...props} />)
unmount()
}
expect(true).toBe(true)
})
})
/**
* Modal Integration Tests
* Tests for modal interactions and nested callbacks
*/
describe('Modal Integration', () => {
it('should pass correct pipeline to edit modal', () => {
const pipeline = createMockPipeline({ id: 'modal-test-id' })
const props = { ...createDefaultProps(), pipeline }
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('edit-modal-btn'))
expect(screen.getByTestId('edit-pipeline-id')).toHaveTextContent('modal-test-id')
})
it('should be able to apply template from details modal', async () => {
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
options.onSuccess({ dataset_id: 'new-id', pipeline_id: 'new-pipeline' })
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('show-details-btn'))
fireEvent.click(screen.getByTestId('details-apply-btn'))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
expect(mockCreateDataset).toHaveBeenCalled()
})
})
it('should handle multiple modals sequentially', () => {
const props = createDefaultProps()
render(<TemplateCard {...props} />)
// Open edit modal
fireEvent.click(screen.getByTestId('edit-modal-btn'))
expect(screen.getByTestId('edit-pipeline-modal')).toBeInTheDocument()
// Close edit modal
fireEvent.click(screen.getByTestId('edit-close-btn'))
expect(screen.queryByTestId('edit-pipeline-modal')).not.toBeInTheDocument()
// Open details modal
fireEvent.click(screen.getByTestId('show-details-btn'))
expect(screen.getByTestId('details-modal')).toBeInTheDocument()
// Close details modal
fireEvent.click(screen.getByTestId('details-close-btn'))
expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument()
// Open delete confirm
fireEvent.click(screen.getByTestId('delete-btn'))
expect(screen.getByText('datasetPipeline.deletePipeline.title')).toBeInTheDocument()
})
})
/**
* API Integration Tests
* Tests for service hook interactions
*/
describe('API Integration', () => {
it('should initialize hooks with correct parameters', () => {
const pipeline = createMockPipeline({ id: 'hook-test-id' })
const props = { ...createDefaultProps(), pipeline, type: 'customized' as const }
render(<TemplateCard {...props} />)
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should handle async operations correctly', async () => {
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn().mockImplementation(async (_req, options) => {
await new Promise(resolve => setTimeout(resolve, 10))
options.onSuccess({ dataset_id: 'async-test-id', pipeline_id: 'async-pipeline' })
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/async-test-id/pipeline')
})
})
it('should handle concurrent API calls gracefully', async () => {
mockRefetch = jest.fn().mockResolvedValue({ data: createMockPipelineByIdResponse() })
mockCreateDataset = jest.fn().mockImplementation((_req, options) => {
options.onSuccess({ dataset_id: 'concurrent-id', pipeline_id: 'concurrent-pipeline' })
})
const props = createDefaultProps()
render(<TemplateCard {...props} />)
// Trigger multiple clicks
fireEvent.click(screen.getByTestId('apply-template-btn'))
fireEvent.click(screen.getByTestId('apply-template-btn'))
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
})
})

View File

@ -29,6 +29,7 @@ const NotionPagePreview = ({
return
try {
const res = await fetchNotionPagePreview({
workspaceID: currentPage.workspace_id,
pageID: currentPage.page_id,
pageType: currentPage.type,
credentialID: notionCredentialId,

View File

@ -75,17 +75,11 @@ const OnlineDocuments = ({
const getOnlineDocuments = useCallback(async () => {
const { currentCredentialId } = dataSourceStore.getState()
// Convert datasource_parameters to inputs format for the API
const inputs = Object.entries(nodeData.datasource_parameters || {}).reduce((acc, [key, value]) => {
acc[key] = typeof value === 'object' && value !== null && 'value' in value ? value.value : value
return acc
}, {} as Record<string, any>)
ssePost(
datasourceNodeRunURL,
{
body: {
inputs,
inputs: {},
credential_id: currentCredentialId,
datasource_type: DatasourceType.onlineDocument,
},
@ -103,7 +97,7 @@ const OnlineDocuments = ({
},
},
)
}, [dataSourceStore, datasourceNodeRunURL, nodeData.datasource_parameters])
}, [dataSourceStore, datasourceNodeRunURL])
useEffect(() => {
if (!currentCredentialId) return

View File

@ -62,7 +62,7 @@ type CurrChildChunkType = {
showModal: boolean
}
export type SegmentListContextValue = {
type SegmentListContextValue = {
isCollapsed: boolean
fullScreen: boolean
toggleFullScreen: (fullscreen?: boolean) => void

View File

@ -129,7 +129,6 @@ const SegmentCard: FC<ISegmentCardProps> = ({
return (
<div
data-testid="segment-card"
className={cn(
'chunk-card group/card w-full rounded-xl px-3',
isFullDocMode ? '' : 'pb-2 pt-2.5 hover:bg-dataset-chunk-detail-card-hover-bg',
@ -173,7 +172,6 @@ const SegmentCard: FC<ISegmentCardProps> = ({
popupClassName='text-text-secondary system-xs-medium'
>
<div
data-testid="segment-edit-button"
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
@ -186,9 +184,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
popupContent='Delete'
popupClassName='text-text-secondary system-xs-medium'
>
<div
data-testid="segment-delete-button"
className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
<div className='group/delete flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-state-destructive-hover'
onClick={(e) => {
e.stopPropagation()
setShowModal(true)

View File

@ -10,7 +10,7 @@ import {
const ParentChunkCardSkelton = () => {
const { t } = useTranslation()
return (
<div data-testid='parent-chunk-card-skeleton' className='flex flex-col pb-2'>
<div className='flex flex-col pb-2'>
<SkeletonContainer className='gap-y-0 p-1 pb-0'>
<SkeletonContainer className='gap-y-0.5 px-2 pt-1.5'>
<SkeletonRow className='py-0.5'>

View File

@ -1,7 +1,7 @@
import type { ChunkingMode, ParentMode } from '@/models/datasets'
import { createContext, useContextSelector } from 'use-context-selector'
export type DocumentContextValue = {
type DocumentContextValue = {
datasetId?: string
documentId?: string
docForm?: ChunkingMode

View File

@ -1,786 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import PipelineSettings from './index'
import { DatasourceType } from '@/models/pipeline'
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock Next.js router
const mockPush = jest.fn()
const mockBack = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
}))
// Mock dataset detail context
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string; doc_form: string } }) => unknown) =>
selector({ dataset: { pipeline_id: mockPipelineId, doc_form: 'text_model' } }),
}))
// Mock API hooks for PipelineSettings
const mockUsePipelineExecutionLog = jest.fn()
const mockMutateAsync = jest.fn()
const mockUseRunPublishedPipeline = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
usePipelineExecutionLog: (params: { dataset_id: string; document_id: string }) => mockUsePipelineExecutionLog(params),
useRunPublishedPipeline: () => mockUseRunPublishedPipeline(),
// For ProcessDocuments component
usePublishedPipelineProcessingParams: () => ({
data: { variables: [] },
isFetching: false,
}),
}))
// Mock document invalidation hooks
const mockInvalidDocumentList = jest.fn()
const mockInvalidDocumentDetail = jest.fn()
jest.mock('@/service/knowledge/use-document', () => ({
useInvalidDocumentList: () => mockInvalidDocumentList,
useInvalidDocumentDetail: () => mockInvalidDocumentDetail,
}))
// Mock Form component in ProcessDocuments - internal dependencies are too complex
jest.mock('../../../create-from-pipeline/process-documents/form', () => {
return function MockForm({
ref,
initialData,
configurations,
onSubmit,
onPreview,
isRunning,
}: {
ref: React.RefObject<{ submit: () => void }>
initialData: Record<string, unknown>
configurations: Array<{ variable: string; label: string; type: string }>
schema: unknown
onSubmit: (data: Record<string, unknown>) => void
onPreview: () => void
isRunning: boolean
}) {
if (ref && typeof ref === 'object' && 'current' in ref) {
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
submit: () => onSubmit(initialData),
}
}
return (
<form
data-testid="process-form"
onSubmit={(e) => {
e.preventDefault()
onSubmit(initialData)
}}
>
{configurations.map((config, index) => (
<div key={index} data-testid={`field-${config.variable}`}>
<label>{config.label}</label>
</div>
))}
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
Preview
</button>
</form>
)
}
})
// Mock ChunkPreview - has complex internal state and many dependencies
jest.mock('../../../create-from-pipeline/preview/chunk-preview', () => {
return function MockChunkPreview({
dataSourceType,
localFiles,
onlineDocuments,
websitePages,
onlineDriveFiles,
isIdle,
isPending,
estimateData,
}: {
dataSourceType: string
localFiles: unknown[]
onlineDocuments: unknown[]
websitePages: unknown[]
onlineDriveFiles: unknown[]
isIdle: boolean
isPending: boolean
estimateData: unknown
}) {
return (
<div data-testid="chunk-preview">
<span data-testid="datasource-type">{dataSourceType}</span>
<span data-testid="local-files-count">{localFiles.length}</span>
<span data-testid="online-documents-count">{onlineDocuments.length}</span>
<span data-testid="website-pages-count">{websitePages.length}</span>
<span data-testid="online-drive-files-count">{onlineDriveFiles.length}</span>
<span data-testid="is-idle">{String(isIdle)}</span>
<span data-testid="is-pending">{String(isPending)}</span>
<span data-testid="has-estimate-data">{String(!!estimateData)}</span>
</div>
)
}
})
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory functions for test data
const createMockExecutionLogResponse = (
overrides: Partial<PipelineExecutionLogResponse> = {},
): PipelineExecutionLogResponse => ({
datasource_type: DatasourceType.localFile,
input_data: { chunk_size: '100' },
datasource_node_id: 'datasource-node-1',
datasource_info: {
related_id: 'file-1',
name: 'test-file.pdf',
extension: 'pdf',
},
...overrides,
})
const createDefaultProps = () => ({
datasetId: 'dataset-123',
documentId: 'document-456',
})
describe('PipelineSettings', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPush.mockClear()
mockBack.mockClear()
mockMutateAsync.mockClear()
mockInvalidDocumentList.mockClear()
mockInvalidDocumentDetail.mockClear()
// Default: successful data fetch
mockUsePipelineExecutionLog.mockReturnValue({
data: createMockExecutionLogResponse(),
isFetching: false,
isError: false,
})
// Default: useRunPublishedPipeline mock
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: true,
isPending: false,
})
})
// ==================== Rendering Tests ====================
// Test basic rendering with real components
describe('Rendering', () => {
it('should render without crashing when data is loaded', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Real LeftHeader should render with correct content
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.addDocuments.steps.processDocuments')).toBeInTheDocument()
// Real ProcessDocuments should render
expect(screen.getByTestId('process-form')).toBeInTheDocument()
// ChunkPreview should render
expect(screen.getByTestId('chunk-preview')).toBeInTheDocument()
})
it('should render Loading component when fetching data', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: true,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Loading component should be rendered, not main content
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
it('should render AppUnavailable when there is an error', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: false,
isError: true,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - AppUnavailable should be rendered
expect(screen.queryByText('datasetPipeline.documentSettings.title')).not.toBeInTheDocument()
})
it('should render container with correct CSS classes', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = renderWithProviders(<PipelineSettings {...props} />)
// Assert
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('relative', 'flex', 'min-w-[1024px]')
})
})
// ==================== LeftHeader Integration ====================
// Test real LeftHeader component behavior
describe('LeftHeader Integration', () => {
it('should render LeftHeader with title prop', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - LeftHeader displays the title
expect(screen.getByText('datasetPipeline.documentSettings.title')).toBeInTheDocument()
})
it('should render back button in LeftHeader', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert - Back button should exist with proper aria-label
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
expect(backButton).toBeInTheDocument()
})
it('should call router.back when back button is clicked', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
const backButton = screen.getByRole('button', { name: 'common.operation.back' })
fireEvent.click(backButton)
// Assert
expect(mockBack).toHaveBeenCalledTimes(1)
})
})
// ==================== Props Testing ====================
describe('Props', () => {
it('should pass datasetId and documentId to usePipelineExecutionLog', () => {
// Arrange
const props = { datasetId: 'custom-dataset', documentId: 'custom-document' }
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(mockUsePipelineExecutionLog).toHaveBeenCalledWith({
dataset_id: 'custom-dataset',
document_id: 'custom-document',
})
})
})
// ==================== Memoization - Data Transformation ====================
describe('Memoization - Data Transformation', () => {
it('should transform localFile datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.localFile,
datasource_info: {
related_id: 'file-123',
name: 'document.pdf',
extension: 'pdf',
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('local-files-count')).toHaveTextContent('1')
expect(screen.getByTestId('datasource-type')).toHaveTextContent(DatasourceType.localFile)
})
it('should transform websiteCrawl datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.websiteCrawl,
datasource_info: {
content: 'Page content',
description: 'Page description',
source_url: 'https://example.com/page',
title: 'Page Title',
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('website-pages-count')).toHaveTextContent('1')
expect(screen.getByTestId('local-files-count')).toHaveTextContent('0')
})
it('should transform onlineDocument datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.onlineDocument,
datasource_info: {
workspace_id: 'workspace-1',
page: { page_id: 'page-1', page_name: 'Notion Page' },
},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('online-documents-count')).toHaveTextContent('1')
})
it('should transform onlineDrive datasource correctly', () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.onlineDrive,
datasource_info: { id: 'drive-1', type: 'doc', name: 'Google Doc', size: 1024 },
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('online-drive-files-count')).toHaveTextContent('1')
})
})
// ==================== User Interactions - Process ====================
describe('User Interactions - Process', () => {
it('should trigger form submit when process button is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Find the "Save and Process" button (from real ProcessDocuments > Actions)
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
fireEvent.click(processButton)
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should call handleProcess with is_preview=false', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
is_preview: false,
pipeline_id: mockPipelineId,
original_document_id: 'document-456',
}),
expect.any(Object),
)
})
})
it('should navigate to documents list after successful process', async () => {
// Arrange
mockMutateAsync.mockImplementation((_request, options) => {
options?.onSuccess?.()
return Promise.resolve({})
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents')
})
})
it('should invalidate document cache after successful process', async () => {
// Arrange
mockMutateAsync.mockImplementation((_request, options) => {
options?.onSuccess?.()
return Promise.resolve({})
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockInvalidDocumentList).toHaveBeenCalled()
expect(mockInvalidDocumentDetail).toHaveBeenCalled()
})
})
})
// ==================== User Interactions - Preview ====================
describe('User Interactions - Preview', () => {
it('should trigger preview when preview button is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
it('should call handlePreviewChunks with is_preview=true', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
is_preview: true,
pipeline_id: mockPipelineId,
}),
expect.any(Object),
)
})
})
it('should update estimateData on successful preview', async () => {
// Arrange
const mockOutputs = { chunks: [], total_tokens: 50 }
mockMutateAsync.mockImplementation((_req, opts) => {
opts?.onSuccess?.({ data: { outputs: mockOutputs } })
return Promise.resolve({ data: { outputs: mockOutputs } })
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
})
})
})
// ==================== API Integration ====================
describe('API Integration', () => {
it('should pass correct parameters for preview', async () => {
// Arrange
const mockData = createMockExecutionLogResponse({
datasource_type: DatasourceType.localFile,
datasource_node_id: 'node-xyz',
datasource_info: { related_id: 'file-1', name: 'test.pdf', extension: 'pdf' },
input_data: {},
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert - inputs come from initialData which is transformed by useInitialData
// Since usePublishedPipelineProcessingParams returns empty variables, inputs is {}
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
{
pipeline_id: mockPipelineId,
inputs: {},
start_node_id: 'node-xyz',
datasource_type: DatasourceType.localFile,
datasource_info_list: [{ related_id: 'file-1', name: 'test.pdf', extension: 'pdf' }],
is_preview: true,
},
expect.any(Object),
)
})
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it.each([
[DatasourceType.localFile, 'local-files-count', '1'],
[DatasourceType.websiteCrawl, 'website-pages-count', '1'],
[DatasourceType.onlineDocument, 'online-documents-count', '1'],
[DatasourceType.onlineDrive, 'online-drive-files-count', '1'],
])('should handle %s datasource type correctly', (datasourceType, testId, expectedCount) => {
// Arrange
const datasourceInfoMap: Record<DatasourceType, Record<string, unknown>> = {
[DatasourceType.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' },
[DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' },
[DatasourceType.onlineDocument]: { workspace_id: 'w1', page: { page_id: 'p1' } },
[DatasourceType.onlineDrive]: { id: 'd1', type: 'doc', name: 'n', size: 100 },
}
const mockData = createMockExecutionLogResponse({
datasource_type: datasourceType,
datasource_info: datasourceInfoMap[datasourceType],
})
mockUsePipelineExecutionLog.mockReturnValue({
data: mockData,
isFetching: false,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId(testId)).toHaveTextContent(expectedCount)
})
it('should show loading state during initial fetch', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: true,
isError: false,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
it('should show error state when API fails', () => {
// Arrange
mockUsePipelineExecutionLog.mockReturnValue({
data: undefined,
isFetching: false,
isError: true,
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.queryByTestId('process-form')).not.toBeInTheDocument()
})
})
// ==================== State Management ====================
describe('State Management', () => {
it('should initialize with undefined estimateData', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Assert
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('false')
})
it('should update estimateData after successful preview', async () => {
// Arrange
const mockEstimateData = { chunks: [], total_tokens: 50 }
mockMutateAsync.mockImplementation((_req, opts) => {
opts?.onSuccess?.({ data: { outputs: mockEstimateData } })
return Promise.resolve({ data: { outputs: mockEstimateData } })
})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true')
})
})
it('should set isPreview ref to false when process is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({})
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ is_preview: false }),
expect.any(Object),
)
})
})
it('should set isPreview ref to true when preview is clicked', async () => {
// Arrange
mockMutateAsync.mockResolvedValue({ data: { outputs: {} } })
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ is_preview: true }),
expect.any(Object),
)
})
})
it('should pass isPending=true to ChunkPreview when preview is pending', async () => {
// Arrange - Start with isPending=false so buttons are enabled
let isPendingState = false
mockUseRunPublishedPipeline.mockImplementation(() => ({
mutateAsync: mockMutateAsync,
isIdle: !isPendingState,
isPending: isPendingState,
}))
// A promise that never resolves to keep the pending state
const pendingPromise = new Promise<void>(() => undefined)
// When mutateAsync is called, set isPending to true and trigger rerender
mockMutateAsync.mockImplementation(() => {
isPendingState = true
return pendingPromise
})
const props = createDefaultProps()
const { rerender } = renderWithProviders(<PipelineSettings {...props} />)
// Act - Click preview button (sets isPreview.current = true and calls mutateAsync)
fireEvent.click(screen.getByTestId('preview-btn'))
// Update mock and rerender to reflect isPending=true state
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: false,
isPending: true,
})
rerender(
<QueryClientProvider client={createQueryClient()}>
<PipelineSettings {...props} />
</QueryClientProvider>,
)
// Assert - isPending && isPreview.current should both be true now
expect(screen.getByTestId('is-pending')).toHaveTextContent('true')
})
it('should pass isPending=false to ChunkPreview when process is pending (not preview)', async () => {
// Arrange - isPending is true but isPreview.current is false
mockUseRunPublishedPipeline.mockReturnValue({
mutateAsync: mockMutateAsync,
isIdle: false,
isPending: true,
})
mockMutateAsync.mockReturnValue(new Promise<void>(() => undefined))
const props = createDefaultProps()
// Act
renderWithProviders(<PipelineSettings {...props} />)
// Click process (not preview) to set isPreview.current = false
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert - isPending && isPreview.current should be false (true && false = false)
await waitFor(() => {
expect(screen.getByTestId('is-pending')).toHaveTextContent('false')
})
})
})
})

View File

@ -31,7 +31,6 @@ const LeftHeader = ({
variant='secondary-accent'
className='absolute -left-11 top-3.5 size-9 rounded-full p-0'
onClick={navigateBack}
aria-label={t('common.operation.back')}
>
<RiArrowLeftLine className='size-5 ' />
</Button>

View File

@ -1,573 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ProcessDocuments from './index'
import { PipelineInputVarType } from '@/models/pipeline'
import type { RAGPipelineVariable } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock dataset detail context - required for useInputVariables hook
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => string) =>
selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock API call for pipeline processing params
const mockParamsConfig = jest.fn()
jest.mock('@/service/use-pipeline', () => ({
usePublishedPipelineProcessingParams: () => ({
data: mockParamsConfig(),
isFetching: false,
}),
}))
// Mock Form component - internal dependencies (useAppForm, BaseField) are too complex
// Keep the mock minimal and focused on testing the integration
jest.mock('../../../../create-from-pipeline/process-documents/form', () => {
return function MockForm({
ref,
initialData,
configurations,
onSubmit,
onPreview,
isRunning,
}: {
ref: React.RefObject<{ submit: () => void }>
initialData: Record<string, unknown>
configurations: Array<{ variable: string; label: string; type: string }>
schema: unknown
onSubmit: (data: Record<string, unknown>) => void
onPreview: () => void
isRunning: boolean
}) {
// Expose submit method via ref for parent component control
if (ref && typeof ref === 'object' && 'current' in ref) {
(ref as React.MutableRefObject<{ submit: () => void }>).current = {
submit: () => onSubmit(initialData),
}
}
return (
<form
data-testid="process-form"
onSubmit={(e) => {
e.preventDefault()
onSubmit(initialData)
}}
>
{/* Render actual field labels from configurations */}
{configurations.map((config, index) => (
<div key={index} data-testid={`field-${config.variable}`}>
<label>{config.label}</label>
<input
name={config.variable}
defaultValue={String(initialData[config.variable] ?? '')}
data-testid={`input-${config.variable}`}
/>
</div>
))}
<button type="button" data-testid="preview-btn" onClick={onPreview} disabled={isRunning}>
Preview
</button>
</form>
)
}
})
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory function for creating mock variables - matches RAGPipelineVariable type
const createMockVariable = (overrides: Partial<RAGPipelineVariable> = {}): RAGPipelineVariable => ({
belong_to_node_id: 'node-123',
type: PipelineInputVarType.textInput,
variable: 'test_var',
label: 'Test Variable',
required: false,
...overrides,
})
// Default props factory
const createDefaultProps = (overrides: Partial<{
datasourceNodeId: string
lastRunInputData: Record<string, unknown>
isRunning: boolean
ref: React.RefObject<{ submit: () => void } | null>
onProcess: () => void
onPreview: () => void
onSubmit: (data: Record<string, unknown>) => void
}> = {}) => ({
datasourceNodeId: 'node-123',
lastRunInputData: {},
isRunning: false,
ref: { current: null } as React.RefObject<{ submit: () => void } | null>,
onProcess: jest.fn(),
onPreview: jest.fn(),
onSubmit: jest.fn(),
...overrides,
})
describe('ProcessDocuments', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default: return empty variables
mockParamsConfig.mockReturnValue({ variables: [] })
})
// ==================== Rendering Tests ====================
// Test basic rendering and component structure
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - verify both Form and Actions are rendered
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
})
it('should render with correct container structure', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'flex-col', 'gap-y-4', 'pt-4')
})
it('should render form fields based on variables configuration', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number }),
createMockVariable({ variable: 'separator', label: 'Separator', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - real hooks transform variables to configurations
expect(screen.getByTestId('field-chunk_size')).toBeInTheDocument()
expect(screen.getByTestId('field-separator')).toBeInTheDocument()
expect(screen.getByText('Chunk Size')).toBeInTheDocument()
expect(screen.getByText('Separator')).toBeInTheDocument()
})
})
// ==================== Props Testing ====================
// Test how component behaves with different prop values
describe('Props', () => {
describe('lastRunInputData', () => {
it('should use lastRunInputData as initial form values', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const lastRunInputData = { chunk_size: 500 }
const props = createDefaultProps({ lastRunInputData })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - lastRunInputData should override default_value
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
expect(input.defaultValue).toBe('500')
})
it('should use default_value when lastRunInputData is empty', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps({ lastRunInputData: {} })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-chunk_size') as HTMLInputElement
expect(input.value).toBe('100')
})
})
describe('isRunning', () => {
it('should enable Actions button when isRunning is false', () => {
// Arrange
const props = createDefaultProps({ isRunning: false })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
expect(processButton).not.toBeDisabled()
})
it('should disable Actions button when isRunning is true', () => {
// Arrange
const props = createDefaultProps({ isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })
expect(processButton).toBeDisabled()
})
it('should disable preview button when isRunning is true', () => {
// Arrange
const props = createDefaultProps({ isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('preview-btn')).toBeDisabled()
})
})
describe('ref', () => {
it('should expose submit method via ref', () => {
// Arrange
const ref = { current: null } as React.RefObject<{ submit: () => void } | null>
const onSubmit = jest.fn()
const props = createDefaultProps({ ref, onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(ref.current).not.toBeNull()
expect(typeof ref.current?.submit).toBe('function')
// Act - call submit via ref
ref.current?.submit()
// Assert - onSubmit should be called
expect(onSubmit).toHaveBeenCalled()
})
})
})
// ==================== User Interactions ====================
// Test event handlers and user interactions
describe('User Interactions', () => {
describe('onProcess', () => {
it('should call onProcess when Save and Process button is clicked', () => {
// Arrange
const onProcess = jest.fn()
const props = createDefaultProps({ onProcess })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
expect(onProcess).toHaveBeenCalledTimes(1)
})
it('should not call onProcess when button is disabled due to isRunning', () => {
// Arrange
const onProcess = jest.fn()
const props = createDefaultProps({ onProcess, isRunning: true })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert
expect(onProcess).not.toHaveBeenCalled()
})
})
describe('onPreview', () => {
it('should call onPreview when preview button is clicked', () => {
// Arrange
const onPreview = jest.fn()
const props = createDefaultProps({ onPreview })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.click(screen.getByTestId('preview-btn'))
// Assert
expect(onPreview).toHaveBeenCalledTimes(1)
})
})
describe('onSubmit', () => {
it('should call onSubmit with form data when form is submitted', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onSubmit = jest.fn()
const props = createDefaultProps({ onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.submit(screen.getByTestId('process-form'))
// Assert - should submit with initial data transformed by real hooks
// Note: default_value is string type, so the value remains as string
expect(onSubmit).toHaveBeenCalledWith({ chunk_size: '100' })
})
})
})
// ==================== Data Transformation Tests ====================
// Test real hooks transform data correctly
describe('Data Transformation', () => {
it('should transform text-input variable to string initial value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput, default_value: 'default' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-name') as HTMLInputElement
expect(input.defaultValue).toBe('default')
})
it('should transform number variable to number initial value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'count', label: 'Count', type: PipelineInputVarType.number, default_value: '42' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-count') as HTMLInputElement
expect(input.defaultValue).toBe('42')
})
it('should use empty string for text-input without default value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-name') as HTMLInputElement
expect(input.defaultValue).toBe('')
})
it('should prioritize lastRunInputData over default_value', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'size', label: 'Size', type: PipelineInputVarType.number, default_value: '100' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps({ lastRunInputData: { size: 999 } })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
const input = screen.getByTestId('input-size') as HTMLInputElement
expect(input.defaultValue).toBe('999')
})
})
// ==================== Edge Cases ====================
// Test boundary conditions and error handling
describe('Edge Cases', () => {
describe('Empty/Null data handling', () => {
it('should handle undefined paramsConfig.variables', () => {
// Arrange
mockParamsConfig.mockReturnValue({ variables: undefined })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - should render without fields
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
})
it('should handle null paramsConfig', () => {
// Arrange
mockParamsConfig.mockReturnValue(null)
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('process-form')).toBeInTheDocument()
})
it('should handle empty variables array', () => {
// Arrange
mockParamsConfig.mockReturnValue({ variables: [] })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument()
})
})
describe('Multiple variables', () => {
it('should handle multiple variables of different types', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'text_field', label: 'Text', type: PipelineInputVarType.textInput, default_value: 'hello' }),
createMockVariable({ variable: 'number_field', label: 'Number', type: PipelineInputVarType.number, default_value: '123' }),
createMockVariable({ variable: 'select_field', label: 'Select', type: PipelineInputVarType.select, default_value: 'option1' }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - all fields should be rendered
expect(screen.getByTestId('field-text_field')).toBeInTheDocument()
expect(screen.getByTestId('field-number_field')).toBeInTheDocument()
expect(screen.getByTestId('field-select_field')).toBeInTheDocument()
})
it('should submit all variables data correctly', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'field1', label: 'Field 1', type: PipelineInputVarType.textInput, default_value: 'value1' }),
createMockVariable({ variable: 'field2', label: 'Field 2', type: PipelineInputVarType.number, default_value: '42' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onSubmit = jest.fn()
const props = createDefaultProps({ onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
fireEvent.submit(screen.getByTestId('process-form'))
// Assert - default_value is string type, so values remain as strings
expect(onSubmit).toHaveBeenCalledWith({
field1: 'value1',
field2: '42',
})
})
})
describe('Variable with options (select type)', () => {
it('should handle select variable with options', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({
variable: 'mode',
label: 'Mode',
type: PipelineInputVarType.select,
options: ['auto', 'manual', 'custom'],
default_value: 'auto',
}),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert
expect(screen.getByTestId('field-mode')).toBeInTheDocument()
const input = screen.getByTestId('input-mode') as HTMLInputElement
expect(input.defaultValue).toBe('auto')
})
})
})
// ==================== Integration Tests ====================
// Test Form and Actions components work together with real hooks
describe('Integration', () => {
it('should coordinate form submission flow correctly', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'setting', label: 'Setting', type: PipelineInputVarType.textInput, default_value: 'initial' }),
]
mockParamsConfig.mockReturnValue({ variables })
const onProcess = jest.fn()
const onSubmit = jest.fn()
const props = createDefaultProps({ onProcess, onSubmit })
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - form is rendered with correct initial data
const input = screen.getByTestId('input-setting') as HTMLInputElement
expect(input.defaultValue).toBe('initial')
// Act - click process button
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }))
// Assert - onProcess is called
expect(onProcess).toHaveBeenCalled()
})
it('should render complete UI with all interactive elements', () => {
// Arrange
const variables: RAGPipelineVariable[] = [
createMockVariable({ variable: 'test', label: 'Test Field', type: PipelineInputVarType.textInput }),
]
mockParamsConfig.mockReturnValue({ variables })
const props = createDefaultProps()
// Act
renderWithProviders(<ProcessDocuments {...props} />)
// Assert - all UI elements are present
expect(screen.getByTestId('process-form')).toBeInTheDocument()
expect(screen.getByText('Test Field')).toBeInTheDocument()
expect(screen.getByTestId('preview-btn')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })).toBeInTheDocument()
})
})
})

View File

@ -1,968 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import StatusItem from './index'
import type { DocumentDisplayStatus } from '@/models/datasets'
// Mock i18n - required for translation
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock ToastContext - required to verify notifications
const mockNotify = jest.fn()
jest.mock('use-context-selector', () => ({
...jest.requireActual('use-context-selector'),
useContext: () => ({ notify: mockNotify }),
}))
// Mock document service hooks - required to avoid real API calls
const mockEnableDocument = jest.fn()
const mockDisableDocument = jest.fn()
const mockDeleteDocument = jest.fn()
jest.mock('@/service/knowledge/use-document', () => ({
useDocumentEnable: () => ({ mutateAsync: mockEnableDocument }),
useDocumentDisable: () => ({ mutateAsync: mockDisableDocument }),
useDocumentDelete: () => ({ mutateAsync: mockDeleteDocument }),
}))
// Mock useDebounceFn to execute immediately for testing
jest.mock('ahooks', () => ({
...jest.requireActual('ahooks'),
useDebounceFn: (fn: (...args: unknown[]) => void) => ({ run: fn }),
}))
// Test utilities
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
}
// Factory functions for test data
const createDetailProps = (overrides: Partial<{
enabled: boolean
archived: boolean
id: string
}> = {}) => ({
enabled: false,
archived: false,
id: 'doc-123',
...overrides,
})
describe('StatusItem', () => {
beforeEach(() => {
jest.clearAllMocks()
mockEnableDocument.mockResolvedValue({ result: 'success' })
mockDisableDocument.mockResolvedValue({ result: 'success' })
mockDeleteDocument.mockResolvedValue({ result: 'success' })
})
// ==================== Rendering Tests ====================
// Test basic rendering with different status values
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert - check indicator element exists (real Indicator component)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
})
it.each([
['queuing', 'bg-components-badge-status-light-warning-bg'],
['indexing', 'bg-components-badge-status-light-normal-bg'],
['paused', 'bg-components-badge-status-light-warning-bg'],
['error', 'bg-components-badge-status-light-error-bg'],
['available', 'bg-components-badge-status-light-success-bg'],
['enabled', 'bg-components-badge-status-light-success-bg'],
['disabled', 'bg-components-badge-status-light-disabled-bg'],
['archived', 'bg-components-badge-status-light-disabled-bg'],
] as const)('should render status "%s" with correct indicator background', (status, expectedBg) => {
// Arrange & Act
renderWithProviders(<StatusItem status={status} />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass(expectedBg)
})
it('should render status text from translation', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
expect(screen.getByText('datasetDocuments.list.status.available')).toBeInTheDocument()
})
it('should handle case-insensitive status', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status={'AVAILABLE' as DocumentDisplayStatus} />,
)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
})
})
// ==================== Props Testing ====================
// Test all prop variations and combinations
describe('Props', () => {
// reverse prop tests
describe('reverse prop', () => {
it('should apply default layout when reverse is false', () => {
// Arrange & Act
const { container } = renderWithProviders(<StatusItem status="available" reverse={false} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('flex-row-reverse')
})
it('should apply reversed layout when reverse is true', () => {
// Arrange & Act
const { container } = renderWithProviders(<StatusItem status="available" reverse />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex-row-reverse')
})
it('should apply ml-2 to indicator when reversed', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" reverse />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('ml-2')
})
it('should apply mr-2 to indicator when not reversed', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" reverse={false} />)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('mr-2')
})
})
// scene prop tests
describe('scene prop', () => {
it('should not render switch in list scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="list"
detail={createDetailProps()}
/>,
)
// Assert - Switch renders as a button element
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('should render switch in detail scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should default to list scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
detail={createDetailProps()}
/>,
)
// Assert
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
})
// textCls prop tests
describe('textCls prop', () => {
it('should apply custom text class', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="available" textCls="custom-text-class" />,
)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('custom-text-class')
})
it('should default to empty string', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('text-sm')
})
})
// errorMessage prop tests
describe('errorMessage prop', () => {
it('should render tooltip trigger when errorMessage is provided', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="error" errorMessage="Something went wrong" />,
)
// Assert - tooltip trigger element should exist
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
expect(tooltipTrigger).toBeInTheDocument()
})
it('should show error message on hover', async () => {
// Arrange
renderWithProviders(
<StatusItem status="error" errorMessage="Something went wrong" />,
)
// Act - hover the tooltip trigger
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert - wait for tooltip content to appear
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
})
it('should not render tooltip trigger when errorMessage is not provided', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" />)
// Assert - tooltip trigger should not exist
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
expect(tooltipTrigger).not.toBeInTheDocument()
})
it('should not render tooltip trigger when errorMessage is empty', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" errorMessage="" />)
// Assert - tooltip trigger should not exist
const tooltipTrigger = screen.queryByTestId('error-tooltip-trigger')
expect(tooltipTrigger).not.toBeInTheDocument()
})
})
// detail prop tests
describe('detail prop', () => {
it('should use default values when detail is undefined', () => {
// Arrange & Act
renderWithProviders(
<StatusItem status="available" scene="detail" />,
)
// Assert - switch should be unchecked (defaultValue = false when archived = false and enabled = false)
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should use enabled value from detail', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ enabled: true })}
/>,
)
// Assert
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
})
it('should set switch to false when archived regardless of enabled', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ enabled: true, archived: true })}
/>,
)
// Assert - archived overrides enabled, defaultValue becomes false
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
})
})
// ==================== Memoization Tests ====================
// Test useMemo logic for embedding status (disables switch)
describe('Memoization', () => {
it.each([
['queuing', true],
['indexing', true],
['paused', true],
['available', false],
['enabled', false],
['disabled', false],
['archived', false],
['error', false],
] as const)('should correctly identify embedding status for "%s" - disabled: %s', (status, isEmbedding) => {
// Arrange & Act
renderWithProviders(
<StatusItem
status={status}
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - check if switch is visually disabled (via CSS classes)
// The Switch component uses CSS classes for disabled state, not the native disabled attribute
const switchEl = screen.getByRole('switch')
if (isEmbedding)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
else
expect(switchEl).not.toHaveClass('!cursor-not-allowed')
})
it('should disable switch when archived', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ archived: true })}
/>,
)
// Assert - visually disabled via CSS classes
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
it('should disable switch when both embedding and archived', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="indexing"
scene="detail"
detail={createDetailProps({ archived: true })}
/>,
)
// Assert - visually disabled via CSS classes
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
})
// ==================== Switch Toggle Tests ====================
// Test Switch toggle interactions
describe('Switch Toggle', () => {
it('should call enable operation when switch is toggled on', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-123',
})
})
})
it('should call disable operation when switch is toggled off', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockDisableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-123',
})
})
})
it('should not call any operation when archived', () => {
// Arrange
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps({ archived: true })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
expect(mockEnableDocument).not.toHaveBeenCalled()
expect(mockDisableDocument).not.toHaveBeenCalled()
})
it('should render switch as checked when enabled is true', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
/>,
)
// Assert - verify switch shows checked state
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
})
it('should render switch as unchecked when enabled is false', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Assert - verify switch shows unchecked state
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should skip enable operation when props.enabled is true (guard branch)', () => {
// Covers guard condition: if (operationName === 'enable' && enabled) return
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
// This prevents redundant API calls when the UI toggles back to a state
// that already matches the server-side data (props haven't been updated yet).
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
const switchEl = screen.getByRole('switch')
// First click: Switch UI toggles OFF, calls disable (props.enabled=true, so allowed)
fireEvent.click(switchEl)
// Second click: Switch UI toggles ON, tries to call enable
// BUT props.enabled is still true (not updated), so guard skips the API call
fireEvent.click(switchEl)
// Assert - disable was called once, enable was skipped because props.enabled=true
expect(mockDisableDocument).toHaveBeenCalledTimes(1)
expect(mockEnableDocument).not.toHaveBeenCalled()
})
it('should skip disable operation when props.enabled is false (guard branch)', () => {
// Covers guard condition: if (operationName === 'disable' && !enabled) return
// Note: The guard checks props.enabled, NOT the Switch's internal UI state.
// This prevents redundant API calls when the UI toggles back to a state
// that already matches the server-side data (props haven't been updated yet).
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
const switchEl = screen.getByRole('switch')
// First click: Switch UI toggles ON, calls enable (props.enabled=false, so allowed)
fireEvent.click(switchEl)
// Second click: Switch UI toggles OFF, tries to call disable
// BUT props.enabled is still false (not updated), so guard skips the API call
fireEvent.click(switchEl)
// Assert - enable was called once, disable was skipped because props.enabled=false
expect(mockEnableDocument).toHaveBeenCalledTimes(1)
expect(mockDisableDocument).not.toHaveBeenCalled()
})
})
// ==================== onUpdate Callback Tests ====================
// Test onUpdate callback behavior
describe('onUpdate Callback', () => {
it('should call onUpdate with operation name on successful enable', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalledWith('enable')
})
})
it('should call onUpdate with operation name on successful disable', async () => {
// Arrange
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalledWith('disable')
})
})
it('should not call onUpdate when operation fails', async () => {
// Arrange
mockEnableDocument.mockRejectedValue(new Error('API Error'))
const mockOnUpdate = jest.fn()
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
onUpdate={mockOnUpdate}
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
expect(mockOnUpdate).not.toHaveBeenCalled()
})
it('should not throw when onUpdate is not provided', () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
// Assert - should not throw
expect(() => fireEvent.click(switchEl)).not.toThrow()
})
})
// ==================== API Calls ====================
// Test API operations and toast notifications
describe('API Operations', () => {
it('should show success toast on successful operation', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
})
})
it('should show error toast on failed operation', async () => {
// Arrange
mockDisableDocument.mockRejectedValue(new Error('Network error'))
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
})
it('should pass correct parameters to enable API', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false, id: 'test-doc-id' })}
datasetId="test-dataset-id"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'test-dataset-id',
documentId: 'test-doc-id',
})
})
})
it('should pass correct parameters to disable API', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="enabled"
scene="detail"
detail={createDetailProps({ enabled: true, id: 'test-doc-456' })}
datasetId="test-dataset-456"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockDisableDocument).toHaveBeenCalledWith({
datasetId: 'test-dataset-456',
documentId: 'test-doc-456',
})
})
})
})
// ==================== Edge Cases ====================
// Test boundary conditions and unusual inputs
describe('Edge Cases', () => {
it('should handle empty datasetId', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - should render without errors
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should handle undefined detail gracefully', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={undefined}
/>,
)
// Assert
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should handle empty string id in detail', async () => {
// Arrange
renderWithProviders(
<StatusItem
status="disabled"
scene="detail"
detail={createDetailProps({ enabled: false, id: '' })}
datasetId="dataset-123"
/>,
)
// Act
const switchEl = screen.getByRole('switch')
fireEvent.click(switchEl)
// Assert
await waitFor(() => {
expect(mockEnableDocument).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: '',
})
})
})
it('should handle very long error messages', async () => {
// Arrange
const longErrorMessage = 'A'.repeat(500)
renderWithProviders(
<StatusItem status="error" errorMessage={longErrorMessage} />,
)
// Act - hover to show tooltip
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(longErrorMessage)).toBeInTheDocument()
})
})
it('should handle special characters in error message', async () => {
// Arrange
const specialChars = '<script>alert("xss")</script> & < > " \''
renderWithProviders(
<StatusItem status="error" errorMessage={specialChars} />,
)
// Act - hover to show tooltip
const tooltipTrigger = screen.getByTestId('error-tooltip-trigger')
fireEvent.mouseEnter(tooltipTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(specialChars)).toBeInTheDocument()
})
})
it('should handle all status types in sequence', () => {
// Arrange
const statuses: DocumentDisplayStatus[] = [
'queuing', 'indexing', 'paused', 'error',
'available', 'enabled', 'disabled', 'archived',
]
// Act & Assert
statuses.forEach((status) => {
const { unmount } = renderWithProviders(<StatusItem status={status} />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
unmount()
})
})
})
// ==================== Component Memoization ====================
// Test React.memo behavior
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(StatusItem).toHaveProperty('$$typeof', Symbol.for('react.memo'))
})
it('should render correctly with same props', () => {
// Arrange
const props = {
status: 'available' as const,
scene: 'detail' as const,
detail: createDetailProps(),
}
// Act
const { rerender } = renderWithProviders(<StatusItem {...props} />)
rerender(
<QueryClientProvider client={createQueryClient()}>
<StatusItem {...props} />
</QueryClientProvider>,
)
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
})
it('should update when status prop changes', () => {
// Arrange
const { rerender } = renderWithProviders(<StatusItem status="available" />)
// Assert initial - green/success background
let indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
// Act
rerender(
<QueryClientProvider client={createQueryClient()}>
<StatusItem status="error" />
</QueryClientProvider>,
)
// Assert updated - red/error background
indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-error-bg')
})
})
// ==================== Styling Tests ====================
// Test CSS classes and styling
describe('Styling', () => {
it('should apply correct status text color for green status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="available" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.available')
expect(statusText).toHaveClass('text-util-colors-green-green-600')
})
it('should apply correct status text color for red status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="error" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.error')
expect(statusText).toHaveClass('text-util-colors-red-red-600')
})
it('should apply correct status text color for orange status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="queuing" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.queuing')
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
})
it('should apply correct status text color for blue status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="indexing" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.indexing')
expect(statusText).toHaveClass('text-util-colors-blue-light-blue-light-600')
})
it('should apply correct status text color for gray status', () => {
// Arrange & Act
renderWithProviders(<StatusItem status="disabled" />)
// Assert
const statusText = screen.getByText('datasetDocuments.list.status.disabled')
expect(statusText).toHaveClass('text-text-tertiary')
})
it('should render switch with md size in detail scene', () => {
// Arrange & Act
renderWithProviders(
<StatusItem
status="available"
scene="detail"
detail={createDetailProps()}
/>,
)
// Assert - check switch has the md size class (h-4 w-7)
const switchEl = screen.getByRole('switch')
expect(switchEl).toHaveClass('h-4', 'w-7')
})
})
})

View File

@ -105,7 +105,6 @@ const StatusItem = ({
<div className='max-w-[260px] break-all'>{errorMessage}</div>
}
triggerClassName='ml-1 w-4 h-4'
triggerTestId='error-tooltip-trigger'
/>
)
}

View File

@ -47,7 +47,6 @@ export default function Indicator({
}: IndicatorProps) {
return (
<div
data-testid="status-indicator"
className={classNames(
'h-2 w-2 rounded-[3px] border border-solid',
BACKGROUND_MAP[color],

View File

@ -61,8 +61,7 @@ export const pluginManifestInMarketToPluginProps = (pluginManifest: PluginManife
}
export const parseGitHubUrl = (url: string): GitHubUrlInfo => {
const githubUrlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/
const match = githubUrlRegex.exec(url)
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/?$/)
return match ? { isValid: true, owner: match[1], repo: match[2] } : { isValid: false }
}

View File

@ -1,116 +0,0 @@
import {
memo,
useState,
} from 'react'
import {
RiAddLine,
RiKey2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import AddOAuthButton from './authorize/add-oauth-button'
import AddApiKeyButton from './authorize/add-api-key-button'
import type { PluginPayload } from './types'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type CredentialConfigHeaderProps = {
pluginPayload: PluginPayload
canOAuth?: boolean
canApiKey?: boolean
hasOAuthClientConfigured?: boolean
disabled?: boolean
onCredentialAdded?: () => void
onAddMenuOpenChange?: (open: boolean) => void
}
const CredentialConfigHeader = ({
pluginPayload,
canOAuth,
canApiKey,
hasOAuthClientConfigured,
disabled,
onCredentialAdded,
onAddMenuOpenChange,
}: CredentialConfigHeaderProps) => {
const { t } = useTranslation()
const [showAddMenu, setShowAddMenu] = useState(false)
const handleAddMenuOpenChange = (open: boolean) => {
setShowAddMenu(open)
onAddMenuOpenChange?.(open)
}
const addButtonDisabled = disabled || (!canOAuth && !canApiKey && !hasOAuthClientConfigured)
return (
<div className='flex items-start justify-between gap-2'>
<div className='flex items-start gap-2'>
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
<div className='space-y-0.5'>
<div className='system-md-semibold text-text-primary'>
{t('plugin.auth.configuredCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.configuredCredentials.desc')}
</div>
</div>
</div>
<PortalToFollowElem
open={showAddMenu}
onOpenChange={handleAddMenuOpenChange}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
addButtonDisabled && 'pointer-events-none opacity-50',
)}
onClick={() => handleAddMenuOpenChange(!showAddMenu)}
>
<RiAddLine className='h-5 w-5' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
onCredentialAdded?.()
}}
/>
)}
{canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
onCredentialAdded?.()
}}
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default memo(CredentialConfigHeader)

View File

@ -1,176 +0,0 @@
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import type { ReactNode } from 'react'
import {
RiArrowDownSLine,
RiEqualizer2Line,
RiKey2Line,
RiUserStarLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import AddOAuthButton from './authorize/add-oauth-button'
import AddApiKeyButton from './authorize/add-api-key-button'
import type { PluginPayload } from './types'
import cn from '@/utils/classnames'
import Switch from '@/app/components/base/switch'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
export type EndUserCredentialSectionProps = {
pluginPayload: PluginPayload
canOAuth?: boolean
canApiKey?: boolean
disabled?: boolean
useEndUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialChange?: (enabled: boolean) => void
onEndUserCredentialTypeChange?: (type: string) => void
onCredentialAdded?: () => void
className?: string
}
const EndUserCredentialSection = ({
pluginPayload,
canOAuth,
canApiKey,
disabled,
useEndUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialChange,
onEndUserCredentialTypeChange,
onCredentialAdded,
className,
}: EndUserCredentialSectionProps) => {
const { t } = useTranslation()
const [showEndUserTypeMenu, setShowEndUserTypeMenu] = useState(false)
const availableEndUserTypes = useMemo(() => {
const list: { value: string; label: string; icon: ReactNode }[] = []
if (canOAuth) {
list.push({
value: 'oauth2',
label: t('plugin.auth.endUserCredentials.optionOAuth'),
icon: <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />,
})
}
if (canApiKey) {
list.push({
value: 'api-key',
label: t('plugin.auth.endUserCredentials.optionApiKey'),
icon: <RiKey2Line className='h-4 w-4 text-text-tertiary' />,
})
}
return list
}, [canOAuth, canApiKey, t])
const endUserCredentialLabel = useMemo(() => {
const found = availableEndUserTypes.find(item => item.value === endUserCredentialType)
return found?.label || availableEndUserTypes[0]?.label || '-'
}, [availableEndUserTypes, endUserCredentialType])
useEffect(() => {
if (!useEndUserCredentialEnabled)
return
if (!availableEndUserTypes.length)
return
const isValid = availableEndUserTypes.some(item => item.value === endUserCredentialType)
if (!isValid)
onEndUserCredentialTypeChange?.(availableEndUserTypes[0].value)
}, [useEndUserCredentialEnabled, endUserCredentialType, availableEndUserTypes, onEndUserCredentialTypeChange])
const handleSelectEndUserType = useCallback((value: string) => {
onEndUserCredentialTypeChange?.(value)
setShowEndUserTypeMenu(false)
}, [onEndUserCredentialTypeChange])
return (
<div className={cn('flex items-start gap-3', className)}>
<RiUserStarLine className='mt-0.5 h-4 w-4 shrink-0 text-text-tertiary' />
<div className='flex-1 space-y-3'>
<div className='flex items-center justify-between gap-3'>
<div className='space-y-1'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.auth.endUserCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.endUserCredentials.desc')}
</div>
</div>
<Switch
size='md'
defaultValue={!!useEndUserCredentialEnabled}
onChange={onEndUserCredentialChange}
disabled={disabled}
/>
</div>
{
useEndUserCredentialEnabled && availableEndUserTypes.length > 0 && (
<div className='flex items-center justify-between gap-3'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.auth.endUserCredentials.typeLabel')}
</div>
<PortalToFollowElem
open={showEndUserTypeMenu}
onOpenChange={setShowEndUserTypeMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='border-components-input-border flex h-9 min-w-[190px] items-center justify-between rounded-lg border bg-components-input-bg-normal px-3 text-left text-text-primary shadow-xs hover:bg-components-input-bg-hover'
onClick={() => setShowEndUserTypeMenu(v => !v)}
>
<span className='system-sm-semibold'>{endUserCredentialLabel}</span>
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('oauth2')
onCredentialAdded?.()
}}
/>
)}
{canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('api-key')
onCredentialAdded?.()
}}
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
</div>
</div>
)
}
export default memo(EndUserCredentialSection)

View File

@ -13,7 +13,6 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
const hasOAuthClientConfigured = data?.is_oauth_custom_client_enabled
return {
isAuthorized,
@ -23,6 +22,5 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
disabled: !isCurrentWorkspaceManager,
notAllowCustomCredential: data?.allow_custom_token === false,
invalidPluginCredentialInfo,
hasOAuthClientConfigured: !!hasOAuthClientConfigured,
}
}

View File

@ -2,10 +2,6 @@ export { default as PluginAuth } from './plugin-auth'
export { default as Authorized } from './authorized'
export { default as AuthorizedInNode } from './authorized-in-node'
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
export { default as CredentialConfigHeader } from './credential-config-header'
export type { CredentialConfigHeaderProps } from './credential-config-header'
export { default as EndUserCredentialSection } from './end-user-credential-section'
export type { EndUserCredentialSectionProps } from './end-user-credential-section'
export { usePluginAuth } from './hooks/use-plugin-auth'
export { default as PluginAuthInDataSourceNode } from './plugin-auth-in-datasource-node'
export { default as AuthorizedInDataSourceNode } from './authorized-in-data-source-node'

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