diff --git a/api/controllers/console/datasets/data_source.py b/api/controllers/console/datasets/data_source.py index cd958bbb36..d05e726dcb 100644 --- a/api/controllers/console/datasets/data_source.py +++ b/api/controllers/console/datasets/data_source.py @@ -36,6 +36,16 @@ class NotionEstimatePayload(BaseModel): doc_language: str = Field(default="English") +class DataSourceNotionListQuery(BaseModel): + dataset_id: str | None = Field(default=None, description="Dataset ID") + credential_id: str = Field(..., description="Credential ID", min_length=1) + datasource_parameters: dict[str, Any] | None = Field(default=None, description="Datasource parameters JSON string") + + +class DataSourceNotionPreviewQuery(BaseModel): + credential_id: str = Field(..., description="Credential ID", min_length=1) + + register_schema_model(console_ns, NotionEstimatePayload) @@ -136,26 +146,15 @@ class DataSourceNotionListApi(Resource): def get(self): current_user, current_tenant_id = current_account_with_tenant() - dataset_id = request.args.get("dataset_id", default=None, type=str) - credential_id = request.args.get("credential_id", default=None, type=str) - if not credential_id: - raise ValueError("Credential id is required.") + query = DataSourceNotionListQuery.model_validate(request.args.to_dict()) # 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_parameters = query.datasource_parameters or {} datasource_provider_service = DatasourceProviderService() credential = datasource_provider_service.get_datasource_credentials( tenant_id=current_tenant_id, - credential_id=credential_id, + credential_id=query.credential_id, provider="notion_datasource", plugin_id="langgenius/notion_datasource", ) @@ -164,8 +163,8 @@ class DataSourceNotionListApi(Resource): exist_page_ids = [] with Session(db.engine) as session: # import notion in the exist dataset - if dataset_id: - dataset = DatasetService.get_dataset(dataset_id) + if query.dataset_id: + dataset = DatasetService.get_dataset(query.dataset_id) if not dataset: raise NotFound("Dataset not found.") if dataset.data_source_type != "notion_import": @@ -173,7 +172,7 @@ class DataSourceNotionListApi(Resource): documents = session.scalars( select(Document).filter_by( - dataset_id=dataset_id, + dataset_id=query.dataset_id, tenant_id=current_tenant_id, data_source_type="notion_import", enabled=True, @@ -240,13 +239,12 @@ class DataSourceNotionApi(Resource): def get(self, page_id, page_type): _, current_tenant_id = current_account_with_tenant() - credential_id = request.args.get("credential_id", default=None, type=str) - if not credential_id: - raise ValueError("Credential id is required.") + query = DataSourceNotionPreviewQuery.model_validate(request.args.to_dict()) + datasource_provider_service = DatasourceProviderService() credential = datasource_provider_service.get_datasource_credentials( tenant_id=current_tenant_id, - credential_id=credential_id, + credential_id=query.credential_id, provider="notion_datasource", plugin_id="langgenius/notion_datasource", ) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 8ceb896d4f..e9371b608c 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -176,7 +176,18 @@ class IndexingEstimatePayload(BaseModel): return result -register_schema_models(console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload) +class ConsoleDatasetListQuery(BaseModel): + page: int = Field(default=1, description="Page number") + limit: int = Field(default=20, description="Number of items per page") + keyword: str | None = Field(default=None, description="Search keyword") + include_all: bool = Field(default=False, description="Include all datasets") + ids: list[str] = Field(default_factory=list, description="Filter by dataset IDs") + tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs") + + +register_schema_models( + console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload, ConsoleDatasetListQuery +) def _get_retrieval_methods_by_vector_type(vector_type: str | None, is_mock: bool = False) -> dict[str, list[str]]: @@ -275,18 +286,19 @@ class DatasetListApi(Resource): @enterprise_license_required def get(self): current_user, current_tenant_id = current_account_with_tenant() - page = request.args.get("page", default=1, type=int) - limit = request.args.get("limit", default=20, type=int) - ids = request.args.getlist("ids") + query = ConsoleDatasetListQuery.model_validate(request.args.to_dict(flat=False)) # provider = request.args.get("provider", default="vendor") - search = request.args.get("keyword", default=None, type=str) - tag_ids = request.args.getlist("tag_ids") - include_all = request.args.get("include_all", default="false").lower() == "true" - if ids: - datasets, total = DatasetService.get_datasets_by_ids(ids, current_tenant_id) + if query.ids: + datasets, total = DatasetService.get_datasets_by_ids(query.ids, current_tenant_id) else: datasets, total = DatasetService.get_datasets( - page, limit, current_tenant_id, current_user, search, tag_ids, include_all + query.page, + query.limit, + current_tenant_id, + current_user, + query.keyword, + query.tag_ids, + query.include_all, ) # check embedding setting @@ -318,7 +330,13 @@ class DatasetListApi(Resource): else: item.update({"partial_member_list": []}) - response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page} + response = { + "data": data, + "has_more": len(datasets) == query.limit, + "limit": query.limit, + "total": total, + "page": query.page, + } return response, 200 @console_ns.doc("create_dataset") diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index a70a7ce480..588eb6e1b8 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -98,12 +98,19 @@ class BedrockRetrievalPayload(BaseModel): knowledge_id: str +class ExternalApiTemplateListQuery(BaseModel): + page: int = Field(default=1, description="Page number") + limit: int = Field(default=20, description="Number of items per page") + keyword: str | None = Field(default=None, description="Search keyword") + + register_schema_models( console_ns, ExternalKnowledgeApiPayload, ExternalDatasetCreatePayload, ExternalHitTestingPayload, BedrockRetrievalPayload, + ExternalApiTemplateListQuery, ) @@ -124,19 +131,17 @@ class ExternalApiTemplateListApi(Resource): @account_initialization_required def get(self): _, current_tenant_id = current_account_with_tenant() - page = request.args.get("page", default=1, type=int) - limit = request.args.get("limit", default=20, type=int) - search = request.args.get("keyword", default=None, type=str) + query = ExternalApiTemplateListQuery.model_validate(request.args.to_dict()) external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis( - page, limit, current_tenant_id, search + query.page, query.limit, current_tenant_id, query.keyword ) response = { "data": [item.to_dict() for item in external_knowledge_apis], - "has_more": len(external_knowledge_apis) == limit, - "limit": limit, + "has_more": len(external_knowledge_apis) == query.limit, + "limit": query.limit, "total": total, - "page": page, + "page": query.page, } return response, 200 diff --git a/api/controllers/console/explore/installed_app.py b/api/controllers/console/explore/installed_app.py index e42db10ba6..b77eac605e 100644 --- a/api/controllers/console/explore/installed_app.py +++ b/api/controllers/console/explore/installed_app.py @@ -3,7 +3,7 @@ from typing import Any from flask import request from flask_restx import Resource, marshal_with -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import and_, select from werkzeug.exceptions import BadRequest, Forbidden, NotFound @@ -28,6 +28,10 @@ class InstalledAppUpdatePayload(BaseModel): is_pinned: bool | None = None +class InstalledAppsListQuery(BaseModel): + app_id: str | None = Field(default=None, description="App ID to filter by") + + logger = logging.getLogger(__name__) @@ -37,13 +41,13 @@ class InstalledAppsListApi(Resource): @account_initialization_required @marshal_with(installed_app_list_fields) def get(self): - app_id = request.args.get("app_id", default=None, type=str) + query = InstalledAppsListQuery.model_validate(request.args.to_dict()) current_user, current_tenant_id = current_account_with_tenant() - if app_id: + if query.app_id: installed_apps = db.session.scalars( select(InstalledApp).where( - and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == app_id) + and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == query.app_id) ) ).all() else: diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 023ffc991a..9988524a80 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -40,6 +40,7 @@ register_schema_models( TagBasePayload, TagBindingPayload, TagBindingRemovePayload, + TagListQueryParam, ) diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 94faf8dd42..b036a71f18 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -87,6 +87,14 @@ class TagUnbindingPayload(BaseModel): target_id: str +class DatasetListQuery(BaseModel): + page: int = Field(default=1, description="Page number") + limit: int = Field(default=20, description="Number of items per page") + keyword: str | None = Field(default=None, description="Search keyword") + include_all: bool = Field(default=False, description="Include all datasets") + tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs") + + register_schema_models( service_api_ns, DatasetCreatePayload, @@ -96,6 +104,7 @@ register_schema_models( TagDeletePayload, TagBindingPayload, TagUnbindingPayload, + DatasetListQuery, ) @@ -113,15 +122,11 @@ class DatasetListApi(DatasetApiResource): ) def get(self, tenant_id): """Resource for getting datasets.""" - page = request.args.get("page", default=1, type=int) - limit = request.args.get("limit", default=20, type=int) + query = DatasetListQuery.model_validate(request.args.to_dict(flat=False)) # provider = request.args.get("provider", default="vendor") - search = request.args.get("keyword", default=None, type=str) - tag_ids = request.args.getlist("tag_ids") - include_all = request.args.get("include_all", default="false").lower() == "true" datasets, total = DatasetService.get_datasets( - page, limit, tenant_id, current_user, search, tag_ids, include_all + query.page, query.limit, tenant_id, current_user, query.keyword, query.tag_ids, query.include_all ) # check embedding setting provider_manager = ProviderManager() @@ -147,7 +152,13 @@ class DatasetListApi(DatasetApiResource): item["embedding_available"] = False else: item["embedding_available"] = True - response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page} + response = { + "data": data, + "has_more": len(datasets) == query.limit, + "limit": query.limit, + "total": total, + "page": query.page, + } return response, 200 @service_api_ns.expect(service_api_ns.models[DatasetCreatePayload.__name__]) diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 49ff4f57dc..1260645624 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -69,7 +69,14 @@ class DocumentTextUpdate(BaseModel): return self -for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate]: +class DocumentListQuery(BaseModel): + page: int = Field(default=1, description="Page number") + limit: int = Field(default=20, description="Number of items per page") + keyword: str | None = Field(default=None, description="Search keyword") + status: str | None = Field(default=None, description="Document status filter") + + +for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate, DocumentListQuery]: service_api_ns.schema_model(m.__name__, m.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) # type: ignore @@ -460,34 +467,33 @@ class DocumentListApi(DatasetApiResource): def get(self, tenant_id, dataset_id): dataset_id = str(dataset_id) tenant_id = str(tenant_id) - page = request.args.get("page", default=1, type=int) - limit = request.args.get("limit", default=20, type=int) - search = request.args.get("keyword", default=None, type=str) - status = request.args.get("status", default=None, type=str) + query_params = DocumentListQuery.model_validate(request.args.to_dict()) dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first() if not dataset: raise NotFound("Dataset not found.") query = select(Document).filter_by(dataset_id=str(dataset_id), tenant_id=tenant_id) - if status: - query = DocumentService.apply_display_status_filter(query, status) + if query_params.status: + query = DocumentService.apply_display_status_filter(query, query_params.status) - if search: - search = f"%{search}%" + if query_params.keyword: + search = f"%{query_params.keyword}%" query = query.where(Document.name.like(search)) query = query.order_by(desc(Document.created_at), desc(Document.position)) - paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False) + paginated_documents = db.paginate( + select=query, page=query_params.page, per_page=query_params.limit, max_per_page=100, error_out=False + ) documents = paginated_documents.items response = { "data": marshal(documents, document_fields), - "has_more": len(documents) == limit, - "limit": limit, + "has_more": len(documents) == query_params.limit, + "limit": query_params.limit, "total": paginated_documents.total, - "page": page, + "page": query_params.page, } return response diff --git a/api/core/datasource/online_document/online_document_plugin.py b/api/core/datasource/online_document/online_document_plugin.py index 98ea15e3fc..ce23da1e09 100644 --- a/api/core/datasource/online_document/online_document_plugin.py +++ b/api/core/datasource/online_document/online_document_plugin.py @@ -1,4 +1,4 @@ -from collections.abc import Generator, Mapping +from collections.abc import Generator from typing import Any from core.datasource.__base.datasource_plugin import DatasourcePlugin @@ -34,7 +34,7 @@ class OnlineDocumentDatasourcePlugin(DatasourcePlugin): def get_online_document_pages( self, user_id: str, - datasource_parameters: Mapping[str, Any], + datasource_parameters: dict[str, Any], provider_type: str, ) -> Generator[OnlineDocumentPagesMessage, None, None]: manager = PluginDatasourceManager() diff --git a/api/pyproject.toml b/api/pyproject.toml index 9f9bd11fa6..575c1434c5 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -64,7 +64,7 @@ dependencies = [ "pandas[excel,output-formatting,performance]~=2.2.2", "psycogreen~=1.0.2", "psycopg2-binary~=2.9.6", - "pycryptodome==3.19.1", + "pycryptodome==3.23.0", "pydantic~=2.11.4", "pydantic-extra-types~=2.10.3", "pydantic-settings~=2.11.0", diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 26ce8cad33..946b8cdfdb 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -131,7 +131,7 @@ class BillingService: headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key} url = f"{cls.base_url}{endpoint}" - response = httpx.request(method, url, json=json, params=params, headers=headers) + response = httpx.request(method, url, json=json, params=params, headers=headers, follow_redirects=True) if method == "GET" and response.status_code != httpx.codes.OK: raise ValueError("Unable to retrieve billing information. Please try again later or contact support.") if method == "PUT": @@ -143,6 +143,9 @@ class BillingService: raise ValueError("Invalid arguments.") if method == "POST" and response.status_code != httpx.codes.OK: raise ValueError(f"Unable to send request to {url}. Please try again later or contact support.") + if method == "DELETE" and response.status_code != httpx.codes.OK: + logger.error("billing_service: DELETE response: %s %s", response.status_code, response.text) + raise ValueError(f"Unable to process delete request {url}. Please try again later or contact support.") return response.json() @staticmethod @@ -165,7 +168,7 @@ class BillingService: def delete_account(cls, account_id: str): """Delete account.""" params = {"account_id": account_id} - return cls._send_request("DELETE", "/account/", params=params) + return cls._send_request("DELETE", "/account", params=params) @classmethod def is_email_in_freeze(cls, email: str) -> bool: diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index d00743278e..eecb3c7672 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -171,22 +171,26 @@ class TestBillingServiceSendRequest: "status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND] ) def test_delete_request_non_200_with_valid_json(self, mock_httpx_request, mock_billing_config, status_code): - """Test DELETE request with non-200 status code but valid JSON response. + """Test DELETE request with non-200 status code raises ValueError. - DELETE doesn't check status code, so it returns the error JSON. + DELETE now checks status code and raises ValueError for non-200 responses. """ # Arrange error_response = {"detail": "Error message"} mock_response = MagicMock() mock_response.status_code = status_code + mock_response.text = "Error message" mock_response.json.return_value = error_response mock_httpx_request.return_value = mock_response - # Act - result = BillingService._send_request("DELETE", "/test", json={"key": "value"}) - - # Assert - assert result == error_response + # Act & Assert + with patch("services.billing_service.logger") as mock_logger: + with pytest.raises(ValueError) as exc_info: + BillingService._send_request("DELETE", "/test", json={"key": "value"}) + assert "Unable to process delete request" in str(exc_info.value) + # Verify error logging + mock_logger.error.assert_called_once() + assert "DELETE response" in str(mock_logger.error.call_args) @pytest.mark.parametrize( "status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND] @@ -210,9 +214,9 @@ class TestBillingServiceSendRequest: "status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND] ) def test_delete_request_non_200_with_invalid_json(self, mock_httpx_request, mock_billing_config, status_code): - """Test DELETE request with non-200 status code and invalid JSON response raises exception. + """Test DELETE request with non-200 status code raises ValueError before JSON parsing. - DELETE doesn't check status code, so it calls response.json() which raises JSONDecodeError + DELETE now checks status code before calling response.json(), so ValueError is raised when the response cannot be parsed as JSON (e.g., empty response). """ # Arrange @@ -223,8 +227,13 @@ class TestBillingServiceSendRequest: mock_httpx_request.return_value = mock_response # Act & Assert - with pytest.raises(json.JSONDecodeError): - BillingService._send_request("DELETE", "/test", json={"key": "value"}) + with patch("services.billing_service.logger") as mock_logger: + with pytest.raises(ValueError) as exc_info: + BillingService._send_request("DELETE", "/test", json={"key": "value"}) + assert "Unable to process delete request" in str(exc_info.value) + # Verify error logging + mock_logger.error.assert_called_once() + assert "DELETE response" in str(mock_logger.error.call_args) def test_retry_on_request_error(self, mock_httpx_request, mock_billing_config): """Test that _send_request retries on httpx.RequestError.""" @@ -789,7 +798,7 @@ class TestBillingServiceAccountManagement: # Assert assert result == expected_response - mock_send_request.assert_called_once_with("DELETE", "/account/", params={"account_id": account_id}) + mock_send_request.assert_called_once_with("DELETE", "/account", params={"account_id": account_id}) def test_is_email_in_freeze_true(self, mock_send_request): """Test checking if email is frozen (returns True).""" diff --git a/api/uv.lock b/api/uv.lock index 7853d06bf6..7808c16a8c 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1633,7 +1633,7 @@ requires-dist = [ { name = "pandas", extras = ["excel", "output-formatting", "performance"], specifier = "~=2.2.2" }, { name = "psycogreen", specifier = "~=1.0.2" }, { name = "psycopg2-binary", specifier = "~=2.9.6" }, - { name = "pycryptodome", specifier = "==3.19.1" }, + { name = "pycryptodome", specifier = "==3.23.0" }, { name = "pydantic", specifier = "~=2.11.4" }, { name = "pydantic-extra-types", specifier = "~=2.10.3" }, { name = "pydantic-settings", specifier = "~=2.11.0" }, @@ -4796,20 +4796,21 @@ wheels = [ [[package]] name = "pycryptodome" -version = "3.19.1" +version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/38/42a8855ff1bf568c61ca6557e2203f318fb7afeadaf2eb8ecfdbde107151/pycryptodome-3.19.1.tar.gz", hash = "sha256:8ae0dd1bcfada451c35f9e29a3e5db385caabc190f98e4a80ad02a61098fb776", size = 4782144, upload-time = "2023-12-28T06:52:40.741Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/ef/4931bc30674f0de0ca0e827b58c8b0c17313a8eae2754976c610b866118b/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:67939a3adbe637281c611596e44500ff309d547e932c449337649921b17b6297", size = 2417027, upload-time = "2023-12-28T06:51:50.138Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/238c53267fd8d223029c0a0d3730cb1b6594d60f62e40c4184703dc490b1/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:11ddf6c9b52116b62223b6a9f4741bc4f62bb265392a4463282f7f34bb287180", size = 1579728, upload-time = "2023-12-28T06:51:52.385Z" }, - { url = "https://files.pythonhosted.org/packages/7c/87/7181c42c8d5ba89822a4b824830506d0aeec02959bb893614767e3279846/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e6f89480616781d2a7f981472d0cdb09b9da9e8196f43c1234eff45c915766", size = 2051440, upload-time = "2023-12-28T06:51:55.751Z" }, - { url = "https://files.pythonhosted.org/packages/34/dd/332c4c0055527d17dac317ed9f9c864fc047b627d82f4b9a56c110afc6fc/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e1efcb68993b7ce5d1d047a46a601d41281bba9f1971e6be4aa27c69ab8065", size = 2125379, upload-time = "2023-12-28T06:51:58.567Z" }, - { url = "https://files.pythonhosted.org/packages/24/9e/320b885ea336c218ff54ec2b276cd70ba6904e4f5a14a771ed39a2c47d59/pycryptodome-3.19.1-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c6273ca5a03b672e504995529b8bae56da0ebb691d8ef141c4aa68f60765700", size = 2153951, upload-time = "2023-12-28T06:52:01.699Z" }, - { url = "https://files.pythonhosted.org/packages/f4/54/8ae0c43d1257b41bc9d3277c3f875174fd8ad86b9567f0b8609b99c938ee/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b0bfe61506795877ff974f994397f0c862d037f6f1c0bfc3572195fc00833b96", size = 2044041, upload-time = "2023-12-28T06:52:03.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/93/f8450a92cc38541c3ba1f4cb4e267e15ae6d6678ca617476d52c3a3764d4/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:f34976c5c8eb79e14c7d970fb097482835be8d410a4220f86260695ede4c3e17", size = 2182446, upload-time = "2023-12-28T06:52:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ed6e429fb0792ce368f66e83246264dd3a7a045b0b1e63043ed22a063ce5/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7c9e222d0976f68d0cf6409cfea896676ddc1d98485d601e9508f90f60e2b0a2", size = 2144914, upload-time = "2023-12-28T06:52:07.44Z" }, - { url = "https://files.pythonhosted.org/packages/f6/23/b064bd4cfbf2cc5f25afcde0e7c880df5b20798172793137ba4b62d82e72/pycryptodome-3.19.1-cp35-abi3-win32.whl", hash = "sha256:4805e053571140cb37cf153b5c72cd324bb1e3e837cbe590a19f69b6cf85fd03", size = 1713105, upload-time = "2023-12-28T06:52:09.585Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e0/ded1968a5257ab34216a0f8db7433897a2337d59e6d03be113713b346ea2/pycryptodome-3.19.1-cp35-abi3-win_amd64.whl", hash = "sha256:a470237ee71a1efd63f9becebc0ad84b88ec28e6784a2047684b693f458f41b7", size = 1749222, upload-time = "2023-12-28T06:52:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] [[package]] @@ -5003,11 +5004,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.6.0" +version = "6.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/f4/801632a8b62a805378b6af2b5a3fcbfd8923abf647e0ed1af846a83433b2/pypdf-6.6.0.tar.gz", hash = "sha256:4c887ef2ea38d86faded61141995a3c7d068c9d6ae8477be7ae5de8a8e16592f", size = 5281063, upload-time = "2026-01-09T11:20:11.786Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/ba/96f99276194f720e74ed99905a080f6e77810558874e8935e580331b46de/pypdf-6.6.0-py3-none-any.whl", hash = "sha256:bca9091ef6de36c7b1a81e09327c554b7ce51e88dad68f5890c2b4a4417f1fd7", size = 328963, upload-time = "2026-01-09T11:20:09.278Z" }, + { url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" }, ] [[package]] diff --git a/web/app/components/tools/mcp/create-card.spec.tsx b/web/app/components/tools/mcp/create-card.spec.tsx new file mode 100644 index 0000000000..9ddee00460 --- /dev/null +++ b/web/app/components/tools/mcp/create-card.spec.tsx @@ -0,0 +1,221 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NewMCPCard from './create-card' + +// Track the mock functions +const mockCreateMCP = vi.fn().mockResolvedValue({ id: 'new-mcp-id', name: 'New MCP' }) + +// Mock the service +vi.mock('@/service/use-tools', () => ({ + useCreateMCP: () => ({ + mutateAsync: mockCreateMCP, + }), +})) + +// Mock the MCP Modal +type MockMCPModalProps = { + show: boolean + onConfirm: (info: { name: string, server_url: string }) => void + onHide: () => void +} + +vi.mock('./modal', () => ({ + default: ({ show, onConfirm, onHide }: MockMCPModalProps) => { + if (!show) + return null + return ( +
+ tools.mcp.modal.title + + +
+ ) + }, +})) + +// Mutable workspace manager state +let mockIsCurrentWorkspaceManager = true + +// Mock the app context +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager, + isCurrentWorkspaceEditor: true, + }), +})) + +// Mock the plugins service +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => ({ + data: { pages: [] }, + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + isLoading: false, + isSuccess: true, + }), +})) + +// Mock common service +vi.mock('@/service/common', () => ({ + uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }), +})) + +describe('NewMCPCard', () => { + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + } + + const defaultProps = { + handleCreate: vi.fn(), + } + + beforeEach(() => { + mockCreateMCP.mockClear() + mockIsCurrentWorkspaceManager = true + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument() + }) + + it('should render card title', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.create.cardTitle')).toBeInTheDocument() + }) + + it('should render documentation link', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.create.cardLink')).toBeInTheDocument() + }) + + it('should render add icon', () => { + render(, { wrapper: createWrapper() }) + const svgElements = document.querySelectorAll('svg') + expect(svgElements.length).toBeGreaterThan(0) + }) + }) + + describe('User Interactions', () => { + it('should open modal when card is clicked', async () => { + render(, { wrapper: createWrapper() }) + + const cardTitle = screen.getByText('tools.mcp.create.cardTitle') + const clickableArea = cardTitle.closest('.group') + + if (clickableArea) { + fireEvent.click(clickableArea) + + await waitFor(() => { + expect(screen.getByText('tools.mcp.modal.title')).toBeInTheDocument() + }) + } + }) + + it('should have documentation link with correct target', () => { + render(, { wrapper: createWrapper() }) + + const docLink = screen.getByText('tools.mcp.create.cardLink').closest('a') + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + describe('Non-Manager User', () => { + it('should not render card when user is not workspace manager', () => { + mockIsCurrentWorkspaceManager = false + + render(, { wrapper: createWrapper() }) + + expect(screen.queryByText('tools.mcp.create.cardTitle')).not.toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should have correct card structure', () => { + render(, { wrapper: createWrapper() }) + + const card = document.querySelector('.rounded-xl') + expect(card).toBeInTheDocument() + }) + + it('should have clickable cursor style', () => { + render(, { wrapper: createWrapper() }) + + const card = document.querySelector('.cursor-pointer') + expect(card).toBeInTheDocument() + }) + }) + + describe('Modal Interactions', () => { + it('should call create function when modal confirms', async () => { + const handleCreate = vi.fn() + render(, { wrapper: createWrapper() }) + + // Open the modal + const cardTitle = screen.getByText('tools.mcp.create.cardTitle') + const clickableArea = cardTitle.closest('.group') + + if (clickableArea) { + fireEvent.click(clickableArea) + + await waitFor(() => { + expect(screen.getByTestId('mcp-modal')).toBeInTheDocument() + }) + + // Click confirm + const confirmBtn = screen.getByTestId('confirm-btn') + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(mockCreateMCP).toHaveBeenCalledWith({ + name: 'Test MCP', + server_url: 'https://test.com', + }) + expect(handleCreate).toHaveBeenCalled() + }) + } + }) + + it('should close modal when close button is clicked', async () => { + render(, { wrapper: createWrapper() }) + + // Open the modal + const cardTitle = screen.getByText('tools.mcp.create.cardTitle') + const clickableArea = cardTitle.closest('.group') + + if (clickableArea) { + fireEvent.click(clickableArea) + + await waitFor(() => { + expect(screen.getByTestId('mcp-modal')).toBeInTheDocument() + }) + + // Click close + const closeBtn = screen.getByTestId('close-btn') + fireEvent.click(closeBtn) + + await waitFor(() => { + expect(screen.queryByTestId('mcp-modal')).not.toBeInTheDocument() + }) + } + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/content.spec.tsx b/web/app/components/tools/mcp/detail/content.spec.tsx new file mode 100644 index 0000000000..fe3fbd2bc3 --- /dev/null +++ b/web/app/components/tools/mcp/detail/content.spec.tsx @@ -0,0 +1,855 @@ +import type { ReactNode } from 'react' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MCPDetailContent from './content' + +// Mutable mock functions +const mockUpdateTools = vi.fn().mockResolvedValue({}) +const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' }) +const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' }) +const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' }) +const mockInvalidateMCPTools = vi.fn() +const mockOpenOAuthPopup = vi.fn() + +// Mutable mock state +type MockTool = { + id: string + name: string + description?: string +} + +let mockToolsData: { tools: MockTool[] } = { tools: [] } +let mockIsFetching = false +let mockIsUpdating = false +let mockIsAuthorizing = false + +// Mock the services +vi.mock('@/service/use-tools', () => ({ + useMCPTools: () => ({ + data: mockToolsData, + isFetching: mockIsFetching, + }), + useInvalidateMCPTools: () => mockInvalidateMCPTools, + useUpdateMCPTools: () => ({ + mutateAsync: mockUpdateTools, + isPending: mockIsUpdating, + }), + useAuthorizeMCP: () => ({ + mutateAsync: mockAuthorizeMcp, + isPending: mockIsAuthorizing, + }), + useUpdateMCP: () => ({ + mutateAsync: mockUpdateMCP, + }), + useDeleteMCP: () => ({ + mutateAsync: mockDeleteMCP, + }), +})) + +// Mock OAuth hook +type OAuthArgs = readonly unknown[] +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: (...args: OAuthArgs) => mockOpenOAuthPopup(...args), +})) + +// Mock MCPModal +type MCPModalData = { + name: string + server_url: string +} + +type MCPModalProps = { + show: boolean + onConfirm: (data: MCPModalData) => void + onHide: () => void +} + +vi.mock('../modal', () => ({ + default: ({ show, onConfirm, onHide }: MCPModalProps) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, +})) + +// Mock Confirm dialog +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onConfirm, onCancel, title }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, title: string }) => { + if (!isShow) + return null + return ( +
+ + +
+ ) + }, +})) + +// Mock OperationDropdown +vi.mock('./operation-dropdown', () => ({ + default: ({ onEdit, onRemove }: { onEdit: () => void, onRemove: () => void }) => ( +
+ + +
+ ), +})) + +// Mock ToolItem +type ToolItemData = { + name: string +} + +vi.mock('./tool-item', () => ({ + default: ({ tool }: { tool: ToolItemData }) => ( +
{tool.name}
+ ), +})) + +// Mutable workspace manager state +let mockIsCurrentWorkspaceManager = true + +// Mock the app context +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager, + isCurrentWorkspaceEditor: true, + }), +})) + +// Mock the plugins service +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => ({ + data: { pages: [] }, + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + isLoading: false, + isSuccess: true, + }), +})) + +// Mock common service +vi.mock('@/service/common', () => ({ + uploadRemoteFileInfo: vi.fn().mockResolvedValue({ url: 'https://example.com/icon.png' }), +})) + +// Mock copy-to-clipboard +vi.mock('copy-to-clipboard', () => ({ + default: vi.fn(), +})) + +describe('MCPDetailContent', () => { + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + } + + const createMockDetail = (overrides = {}): ToolWithProvider => ({ + id: 'mcp-1', + name: 'Test MCP Server', + server_identifier: 'test-mcp', + server_url: 'https://example.com/mcp', + icon: { content: '🔧', background: '#FF0000' }, + tools: [], + is_team_authorization: false, + ...overrides, + } as unknown as ToolWithProvider) + + const defaultProps = { + detail: createMockDetail(), + onUpdate: vi.fn(), + onHide: vi.fn(), + isTriggerAuthorize: false, + onFirstCreate: vi.fn(), + } + + beforeEach(() => { + // Reset mocks + mockUpdateTools.mockClear() + mockAuthorizeMcp.mockClear() + mockUpdateMCP.mockClear() + mockDeleteMCP.mockClear() + mockInvalidateMCPTools.mockClear() + mockOpenOAuthPopup.mockClear() + + // Reset mock return values + mockUpdateTools.mockResolvedValue({}) + mockAuthorizeMcp.mockResolvedValue({ result: 'success' }) + mockUpdateMCP.mockResolvedValue({ result: 'success' }) + mockDeleteMCP.mockResolvedValue({ result: 'success' }) + + // Reset state + mockToolsData = { tools: [] } + mockIsFetching = false + mockIsUpdating = false + mockIsAuthorizing = false + mockIsCurrentWorkspaceManager = true + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Test MCP Server')).toBeInTheDocument() + }) + + it('should display MCP name', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Test MCP Server')).toBeInTheDocument() + }) + + it('should display server identifier', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('test-mcp')).toBeInTheDocument() + }) + + it('should display server URL', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('https://example.com/mcp')).toBeInTheDocument() + }) + + it('should render close button', () => { + render(, { wrapper: createWrapper() }) + // Close button should be present + const closeButtons = document.querySelectorAll('button') + expect(closeButtons.length).toBeGreaterThan(0) + }) + + it('should render operation dropdown', () => { + render(, { wrapper: createWrapper() }) + // Operation dropdown trigger should be present + expect(document.querySelector('button')).toBeInTheDocument() + }) + }) + + describe('Authorization State', () => { + it('should show authorize button when not authorized', () => { + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.authorize')).toBeInTheDocument() + }) + + it('should show authorized button when authorized', () => { + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument() + }) + + it('should show authorization required message when not authorized', () => { + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.authorizingRequired')).toBeInTheDocument() + }) + + it('should show authorization tip', () => { + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.authorizeTip')).toBeInTheDocument() + }) + }) + + describe('Empty Tools State', () => { + it('should show empty message when authorized but no tools', () => { + const detail = createMockDetail({ is_team_authorization: true, tools: [] }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.toolsEmpty')).toBeInTheDocument() + }) + + it('should show get tools button when empty', () => { + const detail = createMockDetail({ is_team_authorization: true, tools: [] }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.getTools')).toBeInTheDocument() + }) + }) + + describe('Icon Display', () => { + it('should render MCP icon', () => { + render(, { wrapper: createWrapper() }) + // Icon container should be present + const iconContainer = document.querySelector('[class*="rounded-xl"][class*="border"]') + expect(iconContainer).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty server URL', () => { + const detail = createMockDetail({ server_url: '' }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('Test MCP Server')).toBeInTheDocument() + }) + + it('should handle long MCP name', () => { + const longName = 'A'.repeat(100) + const detail = createMockDetail({ name: longName }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText(longName)).toBeInTheDocument() + }) + }) + + describe('Tools List', () => { + it('should show tools list when authorized and has tools', () => { + mockToolsData = { + tools: [ + { id: 'tool1', name: 'tool1', description: 'Tool 1' }, + { id: 'tool2', name: 'tool2', description: 'Tool 2' }, + ], + } + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tool1')).toBeInTheDocument() + expect(screen.getByText('tool2')).toBeInTheDocument() + }) + + it('should show single tool label when only one tool', () => { + mockToolsData = { + tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], + } + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.onlyTool')).toBeInTheDocument() + }) + + it('should show tools count when multiple tools', () => { + mockToolsData = { + tools: [ + { id: 'tool1', name: 'tool1', description: 'Tool 1' }, + { id: 'tool2', name: 'tool2', description: 'Tool 2' }, + ], + } + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText(/tools.mcp.toolsNum/)).toBeInTheDocument() + }) + }) + + describe('Loading States', () => { + it('should show loading state when fetching tools', () => { + mockIsFetching = true + mockToolsData = { + tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], + } + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.gettingTools')).toBeInTheDocument() + }) + + it('should show updating state when updating tools', () => { + mockIsUpdating = true + mockToolsData = { + tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], + } + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.mcp.updateTools')).toBeInTheDocument() + }) + + it('should show authorizing button when authorizing', () => { + mockIsAuthorizing = true + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + // Multiple elements show authorizing text - use getAllByText + const authorizingElements = screen.getAllByText('tools.mcp.authorizing') + expect(authorizingElements.length).toBeGreaterThan(0) + }) + }) + + describe('Authorize Flow', () => { + it('should call authorizeMcp when authorize button is clicked', async () => { + const onFirstCreate = vi.fn() + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + + const authorizeBtn = screen.getByText('tools.mcp.authorize') + fireEvent.click(authorizeBtn) + + await waitFor(() => { + expect(onFirstCreate).toHaveBeenCalled() + expect(mockAuthorizeMcp).toHaveBeenCalledWith({ provider_id: 'mcp-1' }) + }) + }) + + it('should open OAuth popup when authorization_url is returned', async () => { + mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' }) + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + + const authorizeBtn = screen.getByText('tools.mcp.authorize') + fireEvent.click(authorizeBtn) + + await waitFor(() => { + expect(mockOpenOAuthPopup).toHaveBeenCalledWith( + 'https://oauth.example.com', + expect.any(Function), + ) + }) + }) + + it('should trigger authorize on mount when isTriggerAuthorize is true', async () => { + const onFirstCreate = vi.fn() + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(onFirstCreate).toHaveBeenCalled() + expect(mockAuthorizeMcp).toHaveBeenCalled() + }) + }) + + it('should disable authorize button when not workspace manager', () => { + mockIsCurrentWorkspaceManager = false + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + + const authorizeBtn = screen.getByText('tools.mcp.authorize') + expect(authorizeBtn.closest('button')).toBeDisabled() + }) + }) + + describe('Update Tools Flow', () => { + it('should show update confirm dialog when update button is clicked', async () => { + mockToolsData = { + tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], + } + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + + const updateBtn = screen.getByText('tools.mcp.update') + fireEvent.click(updateBtn) + + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + }) + + it('should call updateTools when update is confirmed', async () => { + mockToolsData = { + tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], + } + const onUpdate = vi.fn() + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + + // Open confirm dialog + const updateBtn = screen.getByText('tools.mcp.update') + fireEvent.click(updateBtn) + + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + + // Confirm the update + const confirmBtn = screen.getByTestId('confirm-btn') + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') + expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1') + expect(onUpdate).toHaveBeenCalled() + }) + }) + + it('should call handleUpdateTools when get tools button is clicked', async () => { + const onUpdate = vi.fn() + const detail = createMockDetail({ is_team_authorization: true, tools: [] }) + render( + , + { wrapper: createWrapper() }, + ) + + const getToolsBtn = screen.getByText('tools.mcp.getTools') + fireEvent.click(getToolsBtn) + + await waitFor(() => { + expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') + }) + }) + }) + + describe('Update MCP Modal', () => { + it('should open update modal when edit button is clicked', async () => { + render(, { wrapper: createWrapper() }) + + const editBtn = screen.getByTestId('edit-btn') + fireEvent.click(editBtn) + + await waitFor(() => { + expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument() + }) + }) + + it('should close update modal when close button is clicked', async () => { + render(, { wrapper: createWrapper() }) + + // Open modal + const editBtn = screen.getByTestId('edit-btn') + fireEvent.click(editBtn) + + await waitFor(() => { + expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument() + }) + + // Close modal + const closeBtn = screen.getByTestId('modal-close-btn') + fireEvent.click(closeBtn) + + await waitFor(() => { + expect(screen.queryByTestId('mcp-update-modal')).not.toBeInTheDocument() + }) + }) + + it('should call updateMCP when form is confirmed', async () => { + const onUpdate = vi.fn() + render(, { wrapper: createWrapper() }) + + // Open modal + const editBtn = screen.getByTestId('edit-btn') + fireEvent.click(editBtn) + + await waitFor(() => { + expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument() + }) + + // Confirm form + const confirmBtn = screen.getByTestId('modal-confirm-btn') + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(mockUpdateMCP).toHaveBeenCalledWith({ + name: 'Updated MCP', + server_url: 'https://updated.com', + provider_id: 'mcp-1', + }) + expect(onUpdate).toHaveBeenCalled() + }) + }) + + it('should not call onUpdate when updateMCP fails', async () => { + mockUpdateMCP.mockResolvedValue({ result: 'error' }) + const onUpdate = vi.fn() + render(, { wrapper: createWrapper() }) + + // Open modal + const editBtn = screen.getByTestId('edit-btn') + fireEvent.click(editBtn) + + await waitFor(() => { + expect(screen.getByTestId('mcp-update-modal')).toBeInTheDocument() + }) + + // Confirm form + const confirmBtn = screen.getByTestId('modal-confirm-btn') + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(mockUpdateMCP).toHaveBeenCalled() + }) + + expect(onUpdate).not.toHaveBeenCalled() + }) + }) + + describe('Delete MCP Flow', () => { + it('should open delete confirm when remove button is clicked', async () => { + render(, { wrapper: createWrapper() }) + + const removeBtn = screen.getByTestId('remove-btn') + fireEvent.click(removeBtn) + + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + }) + + it('should close delete confirm when cancel is clicked', async () => { + render(, { wrapper: createWrapper() }) + + // Open confirm + const removeBtn = screen.getByTestId('remove-btn') + fireEvent.click(removeBtn) + + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + + // Cancel + const cancelBtn = screen.getByTestId('cancel-btn') + fireEvent.click(cancelBtn) + + await waitFor(() => { + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) + }) + + it('should call deleteMCP when delete is confirmed', async () => { + const onUpdate = vi.fn() + render(, { wrapper: createWrapper() }) + + // Open confirm + const removeBtn = screen.getByTestId('remove-btn') + fireEvent.click(removeBtn) + + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + + // Confirm delete + const confirmBtn = screen.getByTestId('confirm-btn') + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(mockDeleteMCP).toHaveBeenCalledWith('mcp-1') + expect(onUpdate).toHaveBeenCalledWith(true) + }) + }) + + it('should not call onUpdate when deleteMCP fails', async () => { + mockDeleteMCP.mockResolvedValue({ result: 'error' }) + const onUpdate = vi.fn() + render(, { wrapper: createWrapper() }) + + // Open confirm + const removeBtn = screen.getByTestId('remove-btn') + fireEvent.click(removeBtn) + + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + + // Confirm delete + const confirmBtn = screen.getByTestId('confirm-btn') + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect(mockDeleteMCP).toHaveBeenCalled() + }) + + expect(onUpdate).not.toHaveBeenCalled() + }) + }) + + describe('Close Button', () => { + it('should call onHide when close button is clicked', () => { + const onHide = vi.fn() + render(, { wrapper: createWrapper() }) + + // Find the close button (ActionButton with RiCloseLine) + const buttons = screen.getAllByRole('button') + const closeButton = buttons.find(btn => + btn.querySelector('svg.h-4.w-4'), + ) + + if (closeButton) { + fireEvent.click(closeButton) + expect(onHide).toHaveBeenCalled() + } + }) + }) + + describe('Copy Server Identifier', () => { + it('should copy server identifier when clicked', async () => { + const { default: copy } = await import('copy-to-clipboard') + render(, { wrapper: createWrapper() }) + + // Find the server identifier element + const serverIdentifier = screen.getByText('test-mcp') + fireEvent.click(serverIdentifier) + + expect(copy).toHaveBeenCalledWith('test-mcp') + }) + }) + + describe('OAuth Callback', () => { + it('should call handleUpdateTools on OAuth callback when authorized', async () => { + // Simulate OAuth flow with authorization_url + mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' }) + const onUpdate = vi.fn() + const detail = createMockDetail({ is_team_authorization: false }) + render( + , + { wrapper: createWrapper() }, + ) + + // Click authorize to trigger OAuth popup + const authorizeBtn = screen.getByText('tools.mcp.authorize') + fireEvent.click(authorizeBtn) + + await waitFor(() => { + expect(mockOpenOAuthPopup).toHaveBeenCalled() + }) + + // Get the callback function and call it + const oauthCallback = mockOpenOAuthPopup.mock.calls[0][1] + oauthCallback() + + await waitFor(() => { + expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') + }) + }) + + it('should not call handleUpdateTools if not workspace manager', async () => { + mockIsCurrentWorkspaceManager = false + mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' }) + const detail = createMockDetail({ is_team_authorization: false }) + + // OAuth callback should not trigger update for non-manager + // The button is disabled, so we simulate a scenario where OAuth was already started + render( + , + { wrapper: createWrapper() }, + ) + + // Button should be disabled + const authorizeBtn = screen.getByText('tools.mcp.authorize') + expect(authorizeBtn.closest('button')).toBeDisabled() + }) + }) + + describe('Authorized Button', () => { + it('should show authorized button when team is authorized', () => { + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('tools.auth.authorized')).toBeInTheDocument() + }) + + it('should call handleAuthorize when authorized button is clicked', async () => { + const onFirstCreate = vi.fn() + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + + const authorizedBtn = screen.getByText('tools.auth.authorized') + fireEvent.click(authorizedBtn) + + await waitFor(() => { + expect(onFirstCreate).toHaveBeenCalled() + expect(mockAuthorizeMcp).toHaveBeenCalled() + }) + }) + + it('should disable authorized button when not workspace manager', () => { + mockIsCurrentWorkspaceManager = false + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + + const authorizedBtn = screen.getByText('tools.auth.authorized') + expect(authorizedBtn.closest('button')).toBeDisabled() + }) + }) + + describe('Cancel Update Confirm', () => { + it('should close update confirm when cancel is clicked', async () => { + mockToolsData = { + tools: [{ id: 'tool1', name: 'tool1', description: 'Tool 1' }], + } + const detail = createMockDetail({ is_team_authorization: true }) + render( + , + { wrapper: createWrapper() }, + ) + + // Open confirm dialog + const updateBtn = screen.getByText('tools.mcp.update') + fireEvent.click(updateBtn) + + await waitFor(() => { + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + + // Cancel the update + const cancelBtn = screen.getByTestId('cancel-btn') + fireEvent.click(cancelBtn) + + await waitFor(() => { + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/list-loading.spec.tsx b/web/app/components/tools/mcp/detail/list-loading.spec.tsx new file mode 100644 index 0000000000..679d4322d9 --- /dev/null +++ b/web/app/components/tools/mcp/detail/list-loading.spec.tsx @@ -0,0 +1,71 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ListLoading from './list-loading' + +describe('ListLoading', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('should render 5 skeleton items', () => { + render() + const skeletonItems = document.querySelectorAll('[class*="bg-components-panel-on-panel-item-bg-hover"]') + expect(skeletonItems.length).toBe(5) + }) + + it('should have rounded-xl class on skeleton items', () => { + render() + const skeletonItems = document.querySelectorAll('.rounded-xl') + expect(skeletonItems.length).toBeGreaterThanOrEqual(5) + }) + + it('should have proper spacing', () => { + render() + const container = document.querySelector('.space-y-2') + expect(container).toBeInTheDocument() + }) + + it('should render placeholder bars with different widths', () => { + render() + const bar180 = document.querySelector('.w-\\[180px\\]') + const bar148 = document.querySelector('.w-\\[148px\\]') + const bar196 = document.querySelector('.w-\\[196px\\]') + + expect(bar180).toBeInTheDocument() + expect(bar148).toBeInTheDocument() + expect(bar196).toBeInTheDocument() + }) + + it('should have opacity styling on skeleton bars', () => { + render() + const opacity20Bars = document.querySelectorAll('.opacity-20') + const opacity10Bars = document.querySelectorAll('.opacity-10') + + expect(opacity20Bars.length).toBeGreaterThan(0) + expect(opacity10Bars.length).toBeGreaterThan(0) + }) + }) + + describe('Structure', () => { + it('should have correct nested structure', () => { + render() + const items = document.querySelectorAll('.space-y-3') + expect(items.length).toBe(5) + }) + + it('should render padding on skeleton items', () => { + render() + const paddedItems = document.querySelectorAll('.p-4') + expect(paddedItems.length).toBe(5) + }) + + it('should render height-2 skeleton bars', () => { + render() + const h2Bars = document.querySelectorAll('.h-2') + // 3 bars per skeleton item * 5 items = 15 + expect(h2Bars.length).toBe(15) + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx b/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx new file mode 100644 index 0000000000..077bdc3efe --- /dev/null +++ b/web/app/components/tools/mcp/detail/operation-dropdown.spec.tsx @@ -0,0 +1,193 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import OperationDropdown from './operation-dropdown' + +describe('OperationDropdown', () => { + const defaultProps = { + onEdit: vi.fn(), + onRemove: vi.fn(), + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(document.querySelector('button')).toBeInTheDocument() + }) + + it('should render trigger button with more icon', () => { + render() + const button = document.querySelector('button') + expect(button).toBeInTheDocument() + const svg = button?.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render medium size by default', () => { + render() + const icon = document.querySelector('.h-4.w-4') + expect(icon).toBeInTheDocument() + }) + + it('should render large size when inCard is true', () => { + render() + const icon = document.querySelector('.h-5.w-5') + expect(icon).toBeInTheDocument() + }) + }) + + describe('Dropdown Behavior', () => { + it('should open dropdown when trigger is clicked', async () => { + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + + // Dropdown content should be rendered + expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument() + expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument() + } + }) + + it('should call onOpenChange when opened', () => { + const onOpenChange = vi.fn() + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + expect(onOpenChange).toHaveBeenCalledWith(true) + } + }) + + it('should close dropdown when trigger is clicked again', async () => { + const onOpenChange = vi.fn() + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + fireEvent.click(trigger) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + } + }) + }) + + describe('Menu Actions', () => { + it('should call onEdit when edit option is clicked', () => { + const onEdit = vi.fn() + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + + const editOption = screen.getByText('tools.mcp.operation.edit') + fireEvent.click(editOption) + + expect(onEdit).toHaveBeenCalledTimes(1) + } + }) + + it('should call onRemove when remove option is clicked', () => { + const onRemove = vi.fn() + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + + const removeOption = screen.getByText('tools.mcp.operation.remove') + fireEvent.click(removeOption) + + expect(onRemove).toHaveBeenCalledTimes(1) + } + }) + + it('should close dropdown after edit is clicked', () => { + const onOpenChange = vi.fn() + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + onOpenChange.mockClear() + + const editOption = screen.getByText('tools.mcp.operation.edit') + fireEvent.click(editOption) + + expect(onOpenChange).toHaveBeenCalledWith(false) + } + }) + + it('should close dropdown after remove is clicked', () => { + const onOpenChange = vi.fn() + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + onOpenChange.mockClear() + + const removeOption = screen.getByText('tools.mcp.operation.remove') + fireEvent.click(removeOption) + + expect(onOpenChange).toHaveBeenCalledWith(false) + } + }) + }) + + describe('Styling', () => { + it('should have correct dropdown width', () => { + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + + const dropdown = document.querySelector('.w-\\[160px\\]') + expect(dropdown).toBeInTheDocument() + } + }) + + it('should have rounded-xl on dropdown', () => { + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + + const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]') + expect(dropdown).toBeInTheDocument() + } + }) + + it('should show destructive hover style on remove option', () => { + render() + + const trigger = document.querySelector('button') + if (trigger) { + fireEvent.click(trigger) + + // The text is in a div, and the hover style is on the parent div with group class + const removeOptionText = screen.getByText('tools.mcp.operation.remove') + const removeOptionContainer = removeOptionText.closest('.group') + expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover') + } + }) + }) + + describe('inCard prop', () => { + it('should adjust offset when inCard is false', () => { + render() + // Component renders with different offset values + expect(document.querySelector('button')).toBeInTheDocument() + }) + + it('should adjust offset when inCard is true', () => { + render() + // Component renders with different offset values + expect(document.querySelector('button')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/provider-detail.spec.tsx b/web/app/components/tools/mcp/detail/provider-detail.spec.tsx new file mode 100644 index 0000000000..dc8a427498 --- /dev/null +++ b/web/app/components/tools/mcp/detail/provider-detail.spec.tsx @@ -0,0 +1,153 @@ +import type { ReactNode } from 'react' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import MCPDetailPanel from './provider-detail' + +// Mock the drawer component +vi.mock('@/app/components/base/drawer', () => ({ + default: ({ children, isOpen }: { children: ReactNode, isOpen: boolean }) => { + if (!isOpen) + return null + return
{children}
+ }, +})) + +// Mock the content component to expose onUpdate callback +vi.mock('./content', () => ({ + default: ({ detail, onUpdate }: { detail: ToolWithProvider, onUpdate: (isDelete?: boolean) => void }) => ( +
+ {detail.name} + + +
+ ), +})) + +describe('MCPDetailPanel', () => { + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + } + + const createMockDetail = (): ToolWithProvider => ({ + id: 'mcp-1', + name: 'Test MCP', + server_identifier: 'test-mcp', + server_url: 'https://example.com/mcp', + icon: { content: '🔧', background: '#FF0000' }, + tools: [], + is_team_authorization: true, + } as unknown as ToolWithProvider) + + const defaultProps = { + onUpdate: vi.fn(), + onHide: vi.fn(), + isTriggerAuthorize: false, + onFirstCreate: vi.fn(), + } + + describe('Rendering', () => { + it('should render nothing when detail is undefined', () => { + const { container } = render( + , + { wrapper: createWrapper() }, + ) + expect(container.innerHTML).toBe('') + }) + + it('should render drawer when detail is provided', () => { + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByTestId('drawer')).toBeInTheDocument() + }) + + it('should render content when detail is provided', () => { + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument() + }) + + it('should pass detail to content component', () => { + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('Test MCP')).toBeInTheDocument() + }) + }) + + describe('Callbacks', () => { + it('should call onUpdate when update is triggered', () => { + const onUpdate = vi.fn() + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + // The update callback is passed to content component + expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument() + }) + + it('should accept isTriggerAuthorize prop', () => { + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByTestId('mcp-detail-content')).toBeInTheDocument() + }) + }) + + describe('handleUpdate', () => { + it('should call onUpdate but not onHide when isDelete is false (default)', () => { + const onUpdate = vi.fn() + const onHide = vi.fn() + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + + // Click update button which calls onUpdate() without isDelete parameter + const updateBtn = screen.getByTestId('update-btn') + fireEvent.click(updateBtn) + + expect(onUpdate).toHaveBeenCalledTimes(1) + expect(onHide).not.toHaveBeenCalled() + }) + + it('should call both onHide and onUpdate when isDelete is true', () => { + const onUpdate = vi.fn() + const onHide = vi.fn() + const detail = createMockDetail() + render( + , + { wrapper: createWrapper() }, + ) + + // Click delete button which calls onUpdate(true) + const deleteBtn = screen.getByTestId('delete-btn') + fireEvent.click(deleteBtn) + + expect(onHide).toHaveBeenCalledTimes(1) + expect(onUpdate).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/tools/mcp/detail/tool-item.spec.tsx b/web/app/components/tools/mcp/detail/tool-item.spec.tsx new file mode 100644 index 0000000000..aa04422b48 --- /dev/null +++ b/web/app/components/tools/mcp/detail/tool-item.spec.tsx @@ -0,0 +1,126 @@ +import type { Tool } from '@/app/components/tools/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import MCPToolItem from './tool-item' + +describe('MCPToolItem', () => { + const createMockTool = (overrides = {}): Tool => ({ + name: 'test-tool', + label: { + en_US: 'Test Tool', + zh_Hans: '测试工具', + }, + description: { + en_US: 'A test tool description', + zh_Hans: '测试工具描述', + }, + parameters: [], + ...overrides, + } as unknown as Tool) + + describe('Rendering', () => { + it('should render without crashing', () => { + const tool = createMockTool() + render() + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) + + it('should display tool label', () => { + const tool = createMockTool() + render() + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) + + it('should display tool description', () => { + const tool = createMockTool() + render() + expect(screen.getByText('A test tool description')).toBeInTheDocument() + }) + }) + + describe('With Parameters', () => { + it('should not show parameters section when no parameters', () => { + const tool = createMockTool({ parameters: [] }) + render() + expect(screen.queryByText('tools.mcp.toolItem.parameters')).not.toBeInTheDocument() + }) + + it('should render with parameters', () => { + const tool = createMockTool({ + parameters: [ + { + name: 'param1', + type: 'string', + human_description: { + en_US: 'A parameter description', + }, + }, + ], + }) + render() + // Tooltip content is rendered in portal, may not be visible immediately + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should have cursor-pointer class', () => { + const tool = createMockTool() + render() + const toolElement = document.querySelector('.cursor-pointer') + expect(toolElement).toBeInTheDocument() + }) + + it('should have rounded-xl class', () => { + const tool = createMockTool() + render() + const toolElement = document.querySelector('.rounded-xl') + expect(toolElement).toBeInTheDocument() + }) + + it('should have hover styles', () => { + const tool = createMockTool() + render() + const toolElement = document.querySelector('[class*="hover:bg-components-panel-on-panel-item-bg-hover"]') + expect(toolElement).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty label', () => { + const tool = createMockTool({ + label: { en_US: '', zh_Hans: '' }, + }) + render() + // Should render without crashing + expect(document.querySelector('.cursor-pointer')).toBeInTheDocument() + }) + + it('should handle empty description', () => { + const tool = createMockTool({ + description: { en_US: '', zh_Hans: '' }, + }) + render() + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) + + it('should handle long description with line clamp', () => { + const longDescription = 'This is a very long description '.repeat(20) + const tool = createMockTool({ + description: { en_US: longDescription, zh_Hans: longDescription }, + }) + render() + const descElement = document.querySelector('.line-clamp-2') + expect(descElement).toBeInTheDocument() + }) + + it('should handle special characters in tool name', () => { + const tool = createMockTool({ + name: 'special-tool_v2.0', + label: { en_US: 'Special Tool ', zh_Hans: '特殊工具' }, + }) + render() + expect(screen.getByText('Special Tool ')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/mcp/headers-input.spec.tsx b/web/app/components/tools/mcp/headers-input.spec.tsx new file mode 100644 index 0000000000..c271268f5f --- /dev/null +++ b/web/app/components/tools/mcp/headers-input.spec.tsx @@ -0,0 +1,245 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import HeadersInput from './headers-input' + +describe('HeadersInput', () => { + const defaultProps = { + headersItems: [], + onChange: vi.fn(), + } + + describe('Empty State', () => { + it('should render no headers message when empty', () => { + render() + expect(screen.getByText('tools.mcp.modal.noHeaders')).toBeInTheDocument() + }) + + it('should render add header button when empty and not readonly', () => { + render() + expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument() + }) + + it('should not render add header button when empty and readonly', () => { + render() + expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument() + }) + + it('should call onChange with new item when add button is clicked', () => { + const onChange = vi.fn() + render() + + const addButton = screen.getByText('tools.mcp.modal.addHeader') + fireEvent.click(addButton) + + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ + key: '', + value: '', + }), + ]) + }) + }) + + describe('With Headers', () => { + const headersItems = [ + { id: '1', key: 'Authorization', value: 'Bearer token123' }, + { id: '2', key: 'Content-Type', value: 'application/json' }, + ] + + it('should render header items', () => { + render() + expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument() + expect(screen.getByDisplayValue('Bearer token123')).toBeInTheDocument() + expect(screen.getByDisplayValue('Content-Type')).toBeInTheDocument() + expect(screen.getByDisplayValue('application/json')).toBeInTheDocument() + }) + + it('should render table headers', () => { + render() + expect(screen.getByText('tools.mcp.modal.headerKey')).toBeInTheDocument() + expect(screen.getByText('tools.mcp.modal.headerValue')).toBeInTheDocument() + }) + + it('should render delete buttons for each item when not readonly', () => { + render() + // Should have delete buttons for each header + const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]') + expect(deleteButtons.length).toBe(headersItems.length) + }) + + it('should not render delete buttons when readonly', () => { + render() + const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]') + expect(deleteButtons.length).toBe(0) + }) + + it('should render add button at bottom when not readonly', () => { + render() + expect(screen.getByText('tools.mcp.modal.addHeader')).toBeInTheDocument() + }) + + it('should not render add button when readonly', () => { + render() + expect(screen.queryByText('tools.mcp.modal.addHeader')).not.toBeInTheDocument() + }) + }) + + describe('Masked Headers', () => { + const headersItems = [{ id: '1', key: 'Secret', value: '***' }] + + it('should show masked headers tip when isMasked is true', () => { + render() + expect(screen.getByText('tools.mcp.modal.maskedHeadersTip')).toBeInTheDocument() + }) + + it('should not show masked headers tip when isMasked is false', () => { + render() + expect(screen.queryByText('tools.mcp.modal.maskedHeadersTip')).not.toBeInTheDocument() + }) + }) + + describe('Item Interactions', () => { + const headersItems = [ + { id: '1', key: 'Header1', value: 'Value1' }, + ] + + it('should call onChange when key is changed', () => { + const onChange = vi.fn() + render() + + const keyInput = screen.getByDisplayValue('Header1') + fireEvent.change(keyInput, { target: { value: 'NewHeader' } }) + + expect(onChange).toHaveBeenCalledWith([ + { id: '1', key: 'NewHeader', value: 'Value1' }, + ]) + }) + + it('should call onChange when value is changed', () => { + const onChange = vi.fn() + render() + + const valueInput = screen.getByDisplayValue('Value1') + fireEvent.change(valueInput, { target: { value: 'NewValue' } }) + + expect(onChange).toHaveBeenCalledWith([ + { id: '1', key: 'Header1', value: 'NewValue' }, + ]) + }) + + it('should remove item when delete button is clicked', () => { + const onChange = vi.fn() + render() + + const deleteButton = document.querySelector('[class*="text-text-destructive"]')?.closest('button') + if (deleteButton) { + fireEvent.click(deleteButton) + expect(onChange).toHaveBeenCalledWith([]) + } + }) + + it('should add new item when add button is clicked', () => { + const onChange = vi.fn() + render() + + const addButton = screen.getByText('tools.mcp.modal.addHeader') + fireEvent.click(addButton) + + expect(onChange).toHaveBeenCalledWith([ + { id: '1', key: 'Header1', value: 'Value1' }, + expect.objectContaining({ key: '', value: '' }), + ]) + }) + }) + + describe('Multiple Headers', () => { + const headersItems = [ + { id: '1', key: 'Header1', value: 'Value1' }, + { id: '2', key: 'Header2', value: 'Value2' }, + { id: '3', key: 'Header3', value: 'Value3' }, + ] + + it('should render all headers', () => { + render() + expect(screen.getByDisplayValue('Header1')).toBeInTheDocument() + expect(screen.getByDisplayValue('Header2')).toBeInTheDocument() + expect(screen.getByDisplayValue('Header3')).toBeInTheDocument() + }) + + it('should update correct item when changed', () => { + const onChange = vi.fn() + render() + + const header2Input = screen.getByDisplayValue('Header2') + fireEvent.change(header2Input, { target: { value: 'UpdatedHeader2' } }) + + expect(onChange).toHaveBeenCalledWith([ + { id: '1', key: 'Header1', value: 'Value1' }, + { id: '2', key: 'UpdatedHeader2', value: 'Value2' }, + { id: '3', key: 'Header3', value: 'Value3' }, + ]) + }) + + it('should remove correct item when deleted', () => { + const onChange = vi.fn() + render() + + // Find all delete buttons and click the second one + const deleteButtons = document.querySelectorAll('[class*="text-text-destructive"]') + const secondDeleteButton = deleteButtons[1]?.closest('button') + if (secondDeleteButton) { + fireEvent.click(secondDeleteButton) + expect(onChange).toHaveBeenCalledWith([ + { id: '1', key: 'Header1', value: 'Value1' }, + { id: '3', key: 'Header3', value: 'Value3' }, + ]) + } + }) + }) + + describe('Readonly Mode', () => { + const headersItems = [{ id: '1', key: 'ReadOnly', value: 'Value' }] + + it('should make inputs readonly when readonly is true', () => { + render() + + const keyInput = screen.getByDisplayValue('ReadOnly') + const valueInput = screen.getByDisplayValue('Value') + + expect(keyInput).toHaveAttribute('readonly') + expect(valueInput).toHaveAttribute('readonly') + }) + + it('should not make inputs readonly when readonly is false', () => { + render() + + const keyInput = screen.getByDisplayValue('ReadOnly') + const valueInput = screen.getByDisplayValue('Value') + + expect(keyInput).not.toHaveAttribute('readonly') + expect(valueInput).not.toHaveAttribute('readonly') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty key and value', () => { + const headersItems = [{ id: '1', key: '', value: '' }] + render() + + const inputs = screen.getAllByRole('textbox') + expect(inputs.length).toBe(2) + }) + + it('should handle special characters in header key', () => { + const headersItems = [{ id: '1', key: 'X-Custom-Header', value: 'value' }] + render() + expect(screen.getByDisplayValue('X-Custom-Header')).toBeInTheDocument() + }) + + it('should handle JSON value', () => { + const headersItems = [{ id: '1', key: 'Data', value: '{"key":"value"}' }] + render() + expect(screen.getByDisplayValue('{"key":"value"}')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts new file mode 100644 index 0000000000..72520e11d1 --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.spec.ts @@ -0,0 +1,500 @@ +import type { AppIconEmojiSelection, AppIconImageSelection } from '@/app/components/base/app-icon-picker' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { MCPAuthMethod } from '@/app/components/tools/types' +import { isValidServerID, isValidUrl, useMCPModalForm } from './use-mcp-modal-form' + +// Mock the API service +vi.mock('@/service/common', () => ({ + uploadRemoteFileInfo: vi.fn(), +})) + +describe('useMCPModalForm', () => { + describe('Utility Functions', () => { + describe('isValidUrl', () => { + it('should return true for valid http URL', () => { + expect(isValidUrl('http://example.com')).toBe(true) + }) + + it('should return true for valid https URL', () => { + expect(isValidUrl('https://example.com')).toBe(true) + }) + + it('should return true for URL with path', () => { + expect(isValidUrl('https://example.com/path/to/resource')).toBe(true) + }) + + it('should return true for URL with query params', () => { + expect(isValidUrl('https://example.com?foo=bar')).toBe(true) + }) + + it('should return false for invalid URL', () => { + expect(isValidUrl('not-a-url')).toBe(false) + }) + + it('should return false for ftp URL', () => { + expect(isValidUrl('ftp://example.com')).toBe(false) + }) + + it('should return false for empty string', () => { + expect(isValidUrl('')).toBe(false) + }) + + it('should return false for file URL', () => { + expect(isValidUrl('file:///path/to/file')).toBe(false) + }) + }) + + describe('isValidServerID', () => { + it('should return true for lowercase letters', () => { + expect(isValidServerID('myserver')).toBe(true) + }) + + it('should return true for numbers', () => { + expect(isValidServerID('123')).toBe(true) + }) + + it('should return true for alphanumeric with hyphens', () => { + expect(isValidServerID('my-server-123')).toBe(true) + }) + + it('should return true for alphanumeric with underscores', () => { + expect(isValidServerID('my_server_123')).toBe(true) + }) + + it('should return true for max length (24 chars)', () => { + expect(isValidServerID('abcdefghijklmnopqrstuvwx')).toBe(true) + }) + + it('should return false for uppercase letters', () => { + expect(isValidServerID('MyServer')).toBe(false) + }) + + it('should return false for spaces', () => { + expect(isValidServerID('my server')).toBe(false) + }) + + it('should return false for special characters', () => { + expect(isValidServerID('my@server')).toBe(false) + }) + + it('should return false for empty string', () => { + expect(isValidServerID('')).toBe(false) + }) + + it('should return false for string longer than 24 chars', () => { + expect(isValidServerID('abcdefghijklmnopqrstuvwxy')).toBe(false) + }) + }) + }) + + describe('Hook Initialization', () => { + describe('Create Mode (no data)', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useMCPModalForm()) + + expect(result.current.isCreate).toBe(true) + expect(result.current.formKey).toBe('create') + expect(result.current.state.url).toBe('') + expect(result.current.state.name).toBe('') + expect(result.current.state.serverIdentifier).toBe('') + expect(result.current.state.timeout).toBe(30) + expect(result.current.state.sseReadTimeout).toBe(300) + expect(result.current.state.headers).toEqual([]) + expect(result.current.state.authMethod).toBe(MCPAuthMethod.authentication) + expect(result.current.state.isDynamicRegistration).toBe(true) + expect(result.current.state.clientID).toBe('') + expect(result.current.state.credentials).toBe('') + }) + + it('should initialize with default emoji icon', () => { + const { result } = renderHook(() => useMCPModalForm()) + + expect(result.current.state.appIcon).toEqual({ + type: 'emoji', + icon: '🔗', + background: '#6366F1', + }) + }) + }) + + describe('Edit Mode (with data)', () => { + const mockData: ToolWithProvider = { + id: 'test-id-123', + name: 'Test MCP Server', + server_url: 'https://example.com/mcp', + server_identifier: 'test-server', + icon: { content: '🚀', background: '#FF0000' }, + configuration: { + timeout: 60, + sse_read_timeout: 600, + }, + masked_headers: { + 'Authorization': '***', + 'X-Custom': 'value', + }, + is_dynamic_registration: false, + authentication: { + client_id: 'client-123', + client_secret: 'secret-456', + }, + } as unknown as ToolWithProvider + + it('should initialize with data values', () => { + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.isCreate).toBe(false) + expect(result.current.formKey).toBe('test-id-123') + expect(result.current.state.url).toBe('https://example.com/mcp') + expect(result.current.state.name).toBe('Test MCP Server') + expect(result.current.state.serverIdentifier).toBe('test-server') + expect(result.current.state.timeout).toBe(60) + expect(result.current.state.sseReadTimeout).toBe(600) + expect(result.current.state.isDynamicRegistration).toBe(false) + expect(result.current.state.clientID).toBe('client-123') + expect(result.current.state.credentials).toBe('secret-456') + }) + + it('should initialize headers from masked_headers', () => { + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.state.headers).toHaveLength(2) + expect(result.current.state.headers[0].key).toBe('Authorization') + expect(result.current.state.headers[0].value).toBe('***') + expect(result.current.state.headers[1].key).toBe('X-Custom') + expect(result.current.state.headers[1].value).toBe('value') + }) + + it('should initialize emoji icon from data', () => { + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.state.appIcon.type).toBe('emoji') + expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🚀') + expect(((result.current.state.appIcon) as AppIconEmojiSelection).background).toBe('#FF0000') + }) + + it('should store original server URL and ID', () => { + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.originalServerUrl).toBe('https://example.com/mcp') + expect(result.current.originalServerID).toBe('test-server') + }) + }) + + describe('Edit Mode with string icon', () => { + const mockDataWithImageIcon: ToolWithProvider = { + id: 'test-id', + name: 'Test', + icon: 'https://example.com/files/abc123/file-preview/icon.png', + } as unknown as ToolWithProvider + + it('should initialize image icon from string URL', () => { + const { result } = renderHook(() => useMCPModalForm(mockDataWithImageIcon)) + + expect(result.current.state.appIcon.type).toBe('image') + expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/abc123/file-preview/icon.png') + expect(((result.current.state.appIcon) as AppIconImageSelection).fileId).toBe('abc123') + }) + }) + }) + + describe('Actions', () => { + it('should update url', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setUrl('https://new-url.com') + }) + + expect(result.current.state.url).toBe('https://new-url.com') + }) + + it('should update name', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setName('New Server Name') + }) + + expect(result.current.state.name).toBe('New Server Name') + }) + + it('should update serverIdentifier', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setServerIdentifier('new-server-id') + }) + + expect(result.current.state.serverIdentifier).toBe('new-server-id') + }) + + it('should update timeout', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setTimeout(120) + }) + + expect(result.current.state.timeout).toBe(120) + }) + + it('should update sseReadTimeout', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setSseReadTimeout(900) + }) + + expect(result.current.state.sseReadTimeout).toBe(900) + }) + + it('should update headers', () => { + const { result } = renderHook(() => useMCPModalForm()) + const newHeaders = [{ id: '1', key: 'X-New', value: 'new-value' }] + + act(() => { + result.current.actions.setHeaders(newHeaders) + }) + + expect(result.current.state.headers).toEqual(newHeaders) + }) + + it('should update authMethod', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setAuthMethod(MCPAuthMethod.headers) + }) + + expect(result.current.state.authMethod).toBe(MCPAuthMethod.headers) + }) + + it('should update isDynamicRegistration', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setIsDynamicRegistration(false) + }) + + expect(result.current.state.isDynamicRegistration).toBe(false) + }) + + it('should update clientID', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setClientID('new-client-id') + }) + + expect(result.current.state.clientID).toBe('new-client-id') + }) + + it('should update credentials', () => { + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setCredentials('new-secret') + }) + + expect(result.current.state.credentials).toBe('new-secret') + }) + + it('should update appIcon', () => { + const { result } = renderHook(() => useMCPModalForm()) + const newIcon = { type: 'emoji' as const, icon: '🎉', background: '#00FF00' } + + act(() => { + result.current.actions.setAppIcon(newIcon) + }) + + expect(result.current.state.appIcon).toEqual(newIcon) + }) + + it('should toggle showAppIconPicker', () => { + const { result } = renderHook(() => useMCPModalForm()) + + expect(result.current.state.showAppIconPicker).toBe(false) + + act(() => { + result.current.actions.setShowAppIconPicker(true) + }) + + expect(result.current.state.showAppIconPicker).toBe(true) + }) + + it('should reset icon to default', () => { + const { result } = renderHook(() => useMCPModalForm()) + + // Change icon first + act(() => { + result.current.actions.setAppIcon({ type: 'emoji', icon: '🎉', background: '#00FF00' }) + }) + + expect(((result.current.state.appIcon) as AppIconEmojiSelection).icon).toBe('🎉') + + // Reset icon + act(() => { + result.current.actions.resetIcon() + }) + + expect(result.current.state.appIcon).toEqual({ + type: 'emoji', + icon: '🔗', + background: '#6366F1', + }) + }) + }) + + describe('handleUrlBlur', () => { + it('should not fetch icon in edit mode (when data is provided)', async () => { + const mockData = { + id: 'test', + name: 'Test', + icon: { content: '🔗', background: '#6366F1' }, + } as unknown as ToolWithProvider + const { result } = renderHook(() => useMCPModalForm(mockData)) + + await act(async () => { + await result.current.actions.handleUrlBlur('https://example.com') + }) + + // In edit mode, handleUrlBlur should return early + expect(result.current.state.isFetchingIcon).toBe(false) + }) + + it('should not fetch icon for invalid URL', async () => { + const { result } = renderHook(() => useMCPModalForm()) + + await act(async () => { + await result.current.actions.handleUrlBlur('not-a-valid-url') + }) + + expect(result.current.state.isFetchingIcon).toBe(false) + }) + + it('should handle error when icon fetch fails with error code', async () => { + const { uploadRemoteFileInfo } = await import('@/service/common') + const mockError = { + json: vi.fn().mockResolvedValue({ code: 'UPLOAD_ERROR' }), + } + vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError) + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { result } = renderHook(() => useMCPModalForm()) + + await act(async () => { + await result.current.actions.handleUrlBlur('https://example.com/mcp') + }) + + // Should have called console.error + expect(consoleErrorSpy).toHaveBeenCalled() + // isFetchingIcon should be reset to false after error + expect(result.current.state.isFetchingIcon).toBe(false) + + consoleErrorSpy.mockRestore() + }) + + it('should handle error when icon fetch fails without error code', async () => { + const { uploadRemoteFileInfo } = await import('@/service/common') + const mockError = { + json: vi.fn().mockResolvedValue({}), + } + vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(mockError) + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { result } = renderHook(() => useMCPModalForm()) + + await act(async () => { + await result.current.actions.handleUrlBlur('https://example.com/mcp') + }) + + // Should have called console.error + expect(consoleErrorSpy).toHaveBeenCalled() + // isFetchingIcon should be reset to false after error + expect(result.current.state.isFetchingIcon).toBe(false) + + consoleErrorSpy.mockRestore() + }) + + it('should fetch icon successfully for valid URL in create mode', async () => { + vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({ + id: 'file123', + name: 'icon.png', + size: 1024, + mime_type: 'image/png', + url: 'https://example.com/files/file123/file-preview/icon.png', + } as unknown as { id: string, name: string, size: number, mime_type: string, url: string }) + + const { result } = renderHook(() => useMCPModalForm()) + + await act(async () => { + await result.current.actions.handleUrlBlur('https://example.com/mcp') + }) + + // Icon should be set to image type + expect(result.current.state.appIcon.type).toBe('image') + expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/files/file123/file-preview/icon.png') + expect(result.current.state.isFetchingIcon).toBe(false) + }) + }) + + describe('Edge Cases', () => { + // Base mock data with required icon field + const baseMockData = { + id: 'test', + name: 'Test', + icon: { content: '🔗', background: '#6366F1' }, + } + + it('should handle undefined configuration', () => { + const mockData = { ...baseMockData } as unknown as ToolWithProvider + + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.state.timeout).toBe(30) + expect(result.current.state.sseReadTimeout).toBe(300) + }) + + it('should handle undefined authentication', () => { + const mockData = { ...baseMockData } as unknown as ToolWithProvider + + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.state.clientID).toBe('') + expect(result.current.state.credentials).toBe('') + }) + + it('should handle undefined masked_headers', () => { + const mockData = { ...baseMockData } as unknown as ToolWithProvider + + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.state.headers).toEqual([]) + }) + + it('should handle undefined is_dynamic_registration (defaults to true)', () => { + const mockData = { ...baseMockData } as unknown as ToolWithProvider + + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.state.isDynamicRegistration).toBe(true) + }) + + it('should handle string icon URL', () => { + const mockData = { + id: 'test', + name: 'Test', + icon: 'https://example.com/icon.png', + } as unknown as ToolWithProvider + + const { result } = renderHook(() => useMCPModalForm(mockData)) + + expect(result.current.state.appIcon.type).toBe('image') + expect(((result.current.state.appIcon) as AppIconImageSelection).url).toBe('https://example.com/icon.png') + }) + }) +}) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts new file mode 100644 index 0000000000..286e2bf2e8 --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts @@ -0,0 +1,203 @@ +'use client' +import type { HeaderItem } from '../headers-input' +import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { useCallback, useMemo, useRef, useState } from 'react' +import { getDomain } from 'tldts' +import { v4 as uuid } from 'uuid' +import Toast from '@/app/components/base/toast' +import { MCPAuthMethod } from '@/app/components/tools/types' +import { uploadRemoteFileInfo } from '@/service/common' + +const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' } + +const extractFileId = (url: string) => { + const match = url.match(/files\/(.+?)\/file-preview/) + return match ? match[1] : null +} + +const getIcon = (data?: ToolWithProvider): AppIconSelection => { + if (!data) + return DEFAULT_ICON as AppIconSelection + if (typeof data.icon === 'string') + return { type: 'image', url: data.icon, fileId: extractFileId(data.icon) } as AppIconSelection + return { + ...data.icon, + icon: data.icon.content, + type: 'emoji', + } as unknown as AppIconSelection +} + +const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => { + return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })) +} + +export const isValidUrl = (string: string) => { + try { + const url = new URL(string) + return url.protocol === 'http:' || url.protocol === 'https:' + } + catch { + return false + } +} + +export const isValidServerID = (str: string) => { + return /^[a-z0-9_-]{1,24}$/.test(str) +} + +export type MCPModalFormState = { + url: string + name: string + appIcon: AppIconSelection + showAppIconPicker: boolean + serverIdentifier: string + timeout: number + sseReadTimeout: number + headers: HeaderItem[] + isFetchingIcon: boolean + authMethod: MCPAuthMethod + isDynamicRegistration: boolean + clientID: string + credentials: string +} + +export type MCPModalFormActions = { + setUrl: (url: string) => void + setName: (name: string) => void + setAppIcon: (icon: AppIconSelection) => void + setShowAppIconPicker: (show: boolean) => void + setServerIdentifier: (id: string) => void + setTimeout: (timeout: number) => void + setSseReadTimeout: (timeout: number) => void + setHeaders: (headers: HeaderItem[]) => void + setAuthMethod: (method: string) => void + setIsDynamicRegistration: (value: boolean) => void + setClientID: (id: string) => void + setCredentials: (credentials: string) => void + handleUrlBlur: (url: string) => Promise + resetIcon: () => void +} + +/** + * Custom hook for MCP Modal form state management. + * + * Note: This hook uses a `formKey` (data ID or 'create') to reset form state when + * switching between edit and create modes. All useState initializers read from `data` + * directly, and the key change triggers a remount of the consumer component. + */ +export const useMCPModalForm = (data?: ToolWithProvider) => { + const isCreate = !data + const originalServerUrl = data?.server_url + const originalServerID = data?.server_identifier + + // Form key for resetting state - changes when data changes + const formKey = useMemo(() => data?.id ?? 'create', [data?.id]) + + // Form state - initialized from data + const [url, setUrl] = useState(() => data?.server_url || '') + const [name, setName] = useState(() => data?.name || '') + const [appIcon, setAppIcon] = useState(() => getIcon(data)) + const [showAppIconPicker, setShowAppIconPicker] = useState(false) + const [serverIdentifier, setServerIdentifier] = useState(() => data?.server_identifier || '') + const [timeout, setMcpTimeout] = useState(() => data?.configuration?.timeout || 30) + const [sseReadTimeout, setSseReadTimeout] = useState(() => data?.configuration?.sse_read_timeout || 300) + const [headers, setHeaders] = useState(() => getInitialHeaders(data)) + const [isFetchingIcon, setIsFetchingIcon] = useState(false) + const appIconRef = useRef(null) + + // Auth state + const [authMethod, setAuthMethod] = useState(MCPAuthMethod.authentication) + const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true)) + const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '') + const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '') + + const handleUrlBlur = useCallback(async (urlValue: string) => { + if (data) + return + if (!isValidUrl(urlValue)) + return + const domain = getDomain(urlValue) + const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128` + setIsFetchingIcon(true) + try { + const res = await uploadRemoteFileInfo(remoteIcon, undefined, true) + setAppIcon({ type: 'image', url: res.url, fileId: extractFileId(res.url) || '' }) + } + catch (e) { + let errorMessage = 'Failed to fetch remote icon' + if (e instanceof Response) { + try { + const errorData = await e.json() + if (errorData?.code) + errorMessage = `Upload failed: ${errorData.code}` + } + catch { + // Ignore JSON parsing errors + } + } + else if (e instanceof Error) { + errorMessage = e.message + } + console.error('Failed to fetch remote icon:', e) + Toast.notify({ type: 'warning', message: errorMessage }) + } + finally { + setIsFetchingIcon(false) + } + }, [data]) + + const resetIcon = useCallback(() => { + setAppIcon(getIcon(data)) + }, [data]) + + const handleAuthMethodChange = useCallback((value: string) => { + setAuthMethod(value as MCPAuthMethod) + }, []) + + return { + // Key for form reset (use as React key on parent) + formKey, + + // Metadata + isCreate, + originalServerUrl, + originalServerID, + appIconRef, + + // State + state: { + url, + name, + appIcon, + showAppIconPicker, + serverIdentifier, + timeout, + sseReadTimeout, + headers, + isFetchingIcon, + authMethod, + isDynamicRegistration, + clientID, + credentials, + } satisfies MCPModalFormState, + + // Actions + actions: { + setUrl, + setName, + setAppIcon, + setShowAppIconPicker, + setServerIdentifier, + setTimeout: setMcpTimeout, + setSseReadTimeout, + setHeaders, + setAuthMethod: handleAuthMethodChange, + setIsDynamicRegistration, + setClientID, + setCredentials, + handleUrlBlur, + resetIcon, + } satisfies MCPModalFormActions, + } +} diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts new file mode 100644 index 0000000000..b36f724857 --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.spec.ts @@ -0,0 +1,451 @@ +import type { ReactNode } from 'react' +import type { AppDetailResponse } from '@/models/app' +import type { AppSSO } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AppModeEnum } from '@/types/app' +import { useMCPServiceCardState } from './use-mcp-service-card' + +// Mutable mock data for MCP server detail +let mockMCPServerDetailData: { + id: string + status: string + server_code: string + description: string + parameters: Record +} | undefined = { + id: 'server-123', + status: 'active', + server_code: 'abc123', + description: 'Test server', + parameters: {}, +} + +// Mock service hooks +vi.mock('@/service/use-tools', () => ({ + useUpdateMCPServer: () => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + }), + useRefreshMCPServerCode: () => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + }), + useMCPServerDetail: () => ({ + data: mockMCPServerDetailData, + }), + useInvalidateMCPServerDetail: () => vi.fn(), +})) + +// Mock workflow hook +vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: (appId: string) => ({ + data: appId + ? { + graph: { + nodes: [ + { data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } }, + ], + }, + } + : undefined, + }), +})) + +// Mock app context +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + isCurrentWorkspaceEditor: true, + }), +})) + +// Mock apps service +vi.mock('@/service/apps', () => ({ + fetchAppDetail: vi.fn().mockResolvedValue({ + model_config: { + updated_at: '2024-01-01', + user_input_form: [], + }, + }), +})) + +describe('useMCPServiceCardState', () => { + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + } + + const createMockAppInfo = (mode: AppModeEnum = AppModeEnum.CHAT): AppDetailResponse & Partial => ({ + id: 'app-123', + name: 'Test App', + mode, + api_base_url: 'https://api.example.com/v1', + } as AppDetailResponse & Partial) + + beforeEach(() => { + // Reset mock data to default (published server) + mockMCPServerDetailData = { + id: 'server-123', + status: 'active', + server_code: 'abc123', + description: 'Test server', + parameters: {}, + } + }) + + describe('Initialization', () => { + it('should initialize with correct default values for basic app', () => { + const appInfo = createMockAppInfo(AppModeEnum.CHAT) + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.serverPublished).toBe(true) + expect(result.current.serverActivated).toBe(true) + expect(result.current.showConfirmDelete).toBe(false) + expect(result.current.showMCPServerModal).toBe(false) + }) + + it('should initialize with correct values for workflow app', () => { + const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.isLoading).toBe(false) + }) + + it('should initialize with correct values for advanced chat app', () => { + const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT) + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.isLoading).toBe(false) + }) + }) + + describe('Server URL Generation', () => { + it('should generate correct server URL when published', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp') + }) + }) + + describe('Permission Flags', () => { + it('should have isCurrentWorkspaceManager as true', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.isCurrentWorkspaceManager).toBe(true) + }) + + it('should have toggleDisabled false when editor has permissions', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + // Toggle is not disabled when user has permissions and app is published + expect(typeof result.current.toggleDisabled).toBe('boolean') + }) + + it('should have toggleDisabled true when triggerModeDisabled is true', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, true), + { wrapper: createWrapper() }, + ) + + expect(result.current.toggleDisabled).toBe(true) + }) + }) + + describe('UI State Actions', () => { + it('should open confirm delete modal', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.showConfirmDelete).toBe(false) + + act(() => { + result.current.openConfirmDelete() + }) + + expect(result.current.showConfirmDelete).toBe(true) + }) + + it('should close confirm delete modal', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.openConfirmDelete() + }) + expect(result.current.showConfirmDelete).toBe(true) + + act(() => { + result.current.closeConfirmDelete() + }) + expect(result.current.showConfirmDelete).toBe(false) + }) + + it('should open server modal', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.showMCPServerModal).toBe(false) + + act(() => { + result.current.openServerModal() + }) + + expect(result.current.showMCPServerModal).toBe(true) + }) + + it('should handle server modal hide', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.openServerModal() + }) + expect(result.current.showMCPServerModal).toBe(true) + + let hideResult: { shouldDeactivate: boolean } | undefined + act(() => { + hideResult = result.current.handleServerModalHide(false) + }) + + expect(result.current.showMCPServerModal).toBe(false) + expect(hideResult?.shouldDeactivate).toBe(true) + }) + + it('should not deactivate when wasActivated is true', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + let hideResult: { shouldDeactivate: boolean } | undefined + act(() => { + hideResult = result.current.handleServerModalHide(true) + }) + + expect(hideResult?.shouldDeactivate).toBe(false) + }) + }) + + describe('Handler Functions', () => { + it('should have handleGenCode function', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(typeof result.current.handleGenCode).toBe('function') + }) + + it('should call handleGenCode and invalidate server detail', async () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + await act(async () => { + await result.current.handleGenCode() + }) + + // handleGenCode should complete without error + expect(result.current.genLoading).toBe(false) + }) + + it('should have handleStatusChange function', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(typeof result.current.handleStatusChange).toBe('function') + }) + + it('should have invalidateBasicAppConfig function', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(typeof result.current.invalidateBasicAppConfig).toBe('function') + }) + + it('should call invalidateBasicAppConfig', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + // Call the function - should not throw + act(() => { + result.current.invalidateBasicAppConfig() + }) + + // Function should exist and be callable + expect(typeof result.current.invalidateBasicAppConfig).toBe('function') + }) + }) + + describe('Status Change', () => { + it('should return activated state when status change succeeds', async () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + let statusResult: { activated: boolean } | undefined + await act(async () => { + statusResult = await result.current.handleStatusChange(true) + }) + + expect(statusResult?.activated).toBe(true) + }) + + it('should return deactivated state when disabling', async () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + let statusResult: { activated: boolean } | undefined + await act(async () => { + statusResult = await result.current.handleStatusChange(false) + }) + + expect(statusResult?.activated).toBe(false) + }) + }) + + describe('Unpublished Server', () => { + it('should open modal and return not activated when enabling unpublished server', async () => { + // Set mock to return undefined (unpublished server) + mockMCPServerDetailData = undefined + + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + // Verify server is not published + expect(result.current.serverPublished).toBe(false) + + let statusResult: { activated: boolean } | undefined + await act(async () => { + statusResult = await result.current.handleStatusChange(true) + }) + + // Should open modal and return not activated + expect(result.current.showMCPServerModal).toBe(true) + expect(statusResult?.activated).toBe(false) + }) + }) + + describe('Loading States', () => { + it('should have genLoading state', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(typeof result.current.genLoading).toBe('boolean') + }) + + it('should have isLoading state for basic app', () => { + const appInfo = createMockAppInfo(AppModeEnum.CHAT) + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + // Basic app doesn't need workflow, so isLoading should be false + expect(result.current.isLoading).toBe(false) + }) + }) + + describe('Detail Data', () => { + it('should return detail data when available', () => { + const appInfo = createMockAppInfo() + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(result.current.detail).toBeDefined() + expect(result.current.detail?.id).toBe('server-123') + expect(result.current.detail?.status).toBe('active') + }) + }) + + describe('Latest Params', () => { + it('should return latestParams for workflow app', () => { + const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(Array.isArray(result.current.latestParams)).toBe(true) + }) + + it('should return latestParams for basic app', () => { + const appInfo = createMockAppInfo(AppModeEnum.CHAT) + const { result } = renderHook( + () => useMCPServiceCardState(appInfo, false), + { wrapper: createWrapper() }, + ) + + expect(Array.isArray(result.current.latestParams)).toBe(true) + }) + }) +}) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts new file mode 100644 index 0000000000..dfb1c75a2a --- /dev/null +++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts @@ -0,0 +1,179 @@ +'use client' +import type { AppDetailResponse } from '@/models/app' +import type { AppSSO } from '@/types/app' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback, useMemo, useState } from 'react' +import { BlockEnum } from '@/app/components/workflow/types' +import { useAppContext } from '@/context/app-context' +import { fetchAppDetail } from '@/service/apps' +import { + useInvalidateMCPServerDetail, + useMCPServerDetail, + useRefreshMCPServerCode, + useUpdateMCPServer, +} from '@/service/use-tools' +import { useAppWorkflow } from '@/service/use-workflow' +import { AppModeEnum } from '@/types/app' + +const BASIC_APP_CONFIG_KEY = 'basicAppConfig' + +type AppInfo = AppDetailResponse & Partial + +type BasicAppConfig = { + updated_at?: string + user_input_form?: Array> +} + +export const useMCPServiceCardState = ( + appInfo: AppInfo, + triggerModeDisabled: boolean, +) => { + const appId = appInfo.id + const queryClient = useQueryClient() + + // API hooks + const { mutateAsync: updateMCPServer } = useUpdateMCPServer() + const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode() + const invalidateMCPServerDetail = useInvalidateMCPServerDetail() + + // Context + const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() + + // UI state + const [showConfirmDelete, setShowConfirmDelete] = useState(false) + const [showMCPServerModal, setShowMCPServerModal] = useState(false) + + // Derived app type values + const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW + const isBasicApp = !isAdvancedApp + const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW + + // Workflow data for advanced apps + const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '') + + // Basic app config fetch using React Query + const { data: basicAppConfig = {} } = useQuery({ + queryKey: [BASIC_APP_CONFIG_KEY, appId], + queryFn: async () => { + const res = await fetchAppDetail({ url: '/apps', id: appId }) + return (res?.model_config as BasicAppConfig) || {} + }, + enabled: isBasicApp && !!appId, + }) + + // MCP server detail + const { data: detail } = useMCPServerDetail(appId) + const { id, status, server_code } = detail ?? {} + + // Server state + const serverPublished = !!id + const serverActivated = status === 'active' + const serverURL = serverPublished + ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` + : '***********' + + // App state checks + const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at + const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start) + const missingStartNode = isWorkflowApp && !hasStartNode + const hasInsufficientPermissions = !isCurrentWorkspaceEditor + const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled + const isMinimalState = appUnpublished || missingStartNode + + // Basic app input form + const basicAppInputForm = useMemo(() => { + if (!isBasicApp || !basicAppConfig?.user_input_form) + return [] + return (basicAppConfig.user_input_form as Array>).map((item) => { + const type = Object.keys(item)[0] + return { + ...(item[type] as object), + type: type || 'text-input', + } + }) + }, [basicAppConfig?.user_input_form, isBasicApp]) + + // Latest params for modal + const latestParams = useMemo(() => { + if (isAdvancedApp) { + if (!currentWorkflow?.graph) + return [] + type StartNodeData = { type: string, variables?: Array<{ variable: string, label: string }> } + const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as { data: StartNodeData } | undefined + return startNode?.data.variables || [] + } + return basicAppInputForm + }, [currentWorkflow, basicAppInputForm, isAdvancedApp]) + + // Handlers + const handleGenCode = useCallback(async () => { + await refreshMCPServerCode(detail?.id || '') + invalidateMCPServerDetail(appId) + }, [refreshMCPServerCode, detail?.id, invalidateMCPServerDetail, appId]) + + const handleStatusChange = useCallback(async (state: boolean) => { + if (state && !serverPublished) { + setShowMCPServerModal(true) + return { activated: false } + } + + await updateMCPServer({ + appID: appId, + id: id || '', + description: detail?.description || '', + parameters: detail?.parameters || {}, + status: state ? 'active' : 'inactive', + }) + invalidateMCPServerDetail(appId) + return { activated: state } + }, [serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail]) + + const handleServerModalHide = useCallback((wasActivated: boolean) => { + setShowMCPServerModal(false) + // If server wasn't activated before opening modal, keep it deactivated + return { shouldDeactivate: !wasActivated } + }, []) + + const openConfirmDelete = useCallback(() => setShowConfirmDelete(true), []) + const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), []) + const openServerModal = useCallback(() => setShowMCPServerModal(true), []) + + const invalidateBasicAppConfig = useCallback(() => { + queryClient.invalidateQueries({ queryKey: [BASIC_APP_CONFIG_KEY, appId] }) + }, [queryClient, appId]) + + return { + // Loading states + genLoading, + isLoading: isAdvancedApp ? !currentWorkflow : false, + + // Server state + serverPublished, + serverActivated, + serverURL, + detail, + + // Permission & validation flags + isCurrentWorkspaceManager, + toggleDisabled, + isMinimalState, + appUnpublished, + missingStartNode, + + // UI state + showConfirmDelete, + showMCPServerModal, + + // Data + latestParams, + + // Handlers + handleGenCode, + handleStatusChange, + handleServerModalHide, + openConfirmDelete, + closeConfirmDelete, + openServerModal, + invalidateBasicAppConfig, + } +} diff --git a/web/app/components/tools/mcp/mcp-server-modal.spec.tsx b/web/app/components/tools/mcp/mcp-server-modal.spec.tsx new file mode 100644 index 0000000000..62eabd0690 --- /dev/null +++ b/web/app/components/tools/mcp/mcp-server-modal.spec.tsx @@ -0,0 +1,361 @@ +import type { ReactNode } from 'react' +import type { MCPServerDetail } from '@/app/components/tools/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import MCPServerModal from './mcp-server-modal' + +// Mock the services +vi.mock('@/service/use-tools', () => ({ + useCreateMCPServer: () => ({ + mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }), + isPending: false, + }), + useUpdateMCPServer: () => ({ + mutateAsync: vi.fn().mockResolvedValue({ result: 'success' }), + isPending: false, + }), + useInvalidateMCPServerDetail: () => vi.fn(), +})) + +describe('MCPServerModal', () => { + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + } + + const defaultProps = { + appID: 'app-123', + show: true, + onHide: vi.fn(), + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument() + }) + + it('should render add title when no data is provided', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.addTitle')).toBeInTheDocument() + }) + + it('should render edit title when data is provided', () => { + const mockData = { + id: 'server-1', + description: 'Existing description', + parameters: {}, + } as unknown as MCPServerDetail + + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.editTitle')).toBeInTheDocument() + }) + + it('should render description label', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.description')).toBeInTheDocument() + }) + + it('should render required indicator', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('should render description textarea', () => { + render(, { wrapper: createWrapper() }) + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + expect(textarea).toBeInTheDocument() + }) + + it('should render cancel button', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.modal.cancel')).toBeInTheDocument() + }) + + it('should render confirm button in add mode', () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.confirm')).toBeInTheDocument() + }) + + it('should render save button in edit mode', () => { + const mockData = { + id: 'server-1', + description: 'Existing description', + parameters: {}, + } as unknown as MCPServerDetail + + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.modal.save')).toBeInTheDocument() + }) + + it('should render close icon', () => { + render(, { wrapper: createWrapper() }) + const closeButton = document.querySelector('.cursor-pointer svg') + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('Parameters Section', () => { + it('should not render parameters section when no latestParams', () => { + render(, { wrapper: createWrapper() }) + expect(screen.queryByText('tools.mcp.server.modal.parameters')).not.toBeInTheDocument() + }) + + it('should render parameters section when latestParams is provided', () => { + const latestParams = [ + { variable: 'param1', label: 'Parameter 1', type: 'string' }, + ] + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.parameters')).toBeInTheDocument() + }) + + it('should render parameters tip', () => { + const latestParams = [ + { variable: 'param1', label: 'Parameter 1', type: 'string' }, + ] + render(, { wrapper: createWrapper() }) + expect(screen.getByText('tools.mcp.server.modal.parametersTip')).toBeInTheDocument() + }) + + it('should render parameter items', () => { + const latestParams = [ + { variable: 'param1', label: 'Parameter 1', type: 'string' }, + { variable: 'param2', label: 'Parameter 2', type: 'number' }, + ] + render(, { wrapper: createWrapper() }) + expect(screen.getByText('Parameter 1')).toBeInTheDocument() + expect(screen.getByText('Parameter 2')).toBeInTheDocument() + }) + }) + + describe('Form Interactions', () => { + it('should update description when typing', () => { + render(, { wrapper: createWrapper() }) + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + fireEvent.change(textarea, { target: { value: 'New description' } }) + + expect(textarea).toHaveValue('New description') + }) + + it('should call onHide when cancel button is clicked', () => { + const onHide = vi.fn() + render(, { wrapper: createWrapper() }) + + const cancelButton = screen.getByText('tools.mcp.modal.cancel') + fireEvent.click(cancelButton) + + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should call onHide when close icon is clicked', () => { + const onHide = vi.fn() + render(, { wrapper: createWrapper() }) + + const closeButton = document.querySelector('.cursor-pointer') + if (closeButton) { + fireEvent.click(closeButton) + expect(onHide).toHaveBeenCalled() + } + }) + + it('should disable confirm button when description is empty', () => { + render(, { wrapper: createWrapper() }) + + const confirmButton = screen.getByText('tools.mcp.server.modal.confirm') + expect(confirmButton).toBeDisabled() + }) + + it('should enable confirm button when description is filled', () => { + render(, { wrapper: createWrapper() }) + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + fireEvent.change(textarea, { target: { value: 'Valid description' } }) + + const confirmButton = screen.getByText('tools.mcp.server.modal.confirm') + expect(confirmButton).not.toBeDisabled() + }) + }) + + describe('Edit Mode', () => { + const mockData = { + id: 'server-1', + description: 'Existing description', + parameters: { param1: 'existing value' }, + } as unknown as MCPServerDetail + + it('should populate description with existing value', () => { + render(, { wrapper: createWrapper() }) + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + expect(textarea).toHaveValue('Existing description') + }) + + it('should populate parameters with existing values', () => { + const latestParams = [ + { variable: 'param1', label: 'Parameter 1', type: 'string' }, + ] + render( + , + { wrapper: createWrapper() }, + ) + + const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + expect(paramInput).toHaveValue('existing value') + }) + }) + + describe('Form Submission', () => { + it('should submit form with description', async () => { + const onHide = vi.fn() + render(, { wrapper: createWrapper() }) + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + fireEvent.change(textarea, { target: { value: 'Test description' } }) + + const confirmButton = screen.getByText('tools.mcp.server.modal.confirm') + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(onHide).toHaveBeenCalled() + }) + }) + }) + + describe('With App Info', () => { + it('should use appInfo description as default when no data', () => { + const appInfo = { description: 'App default description' } + render(, { wrapper: createWrapper() }) + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + expect(textarea).toHaveValue('App default description') + }) + + it('should prefer data description over appInfo description', () => { + const appInfo = { description: 'App default description' } + const mockData = { + id: 'server-1', + description: 'Data description', + parameters: {}, + } as unknown as MCPServerDetail + + render( + , + { wrapper: createWrapper() }, + ) + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + expect(textarea).toHaveValue('Data description') + }) + }) + + describe('Not Shown State', () => { + it('should not render modal content when show is false', () => { + render(, { wrapper: createWrapper() }) + expect(screen.queryByText('tools.mcp.server.modal.addTitle')).not.toBeInTheDocument() + }) + }) + + describe('Update Mode Submission', () => { + it('should submit update when data is provided', async () => { + const onHide = vi.fn() + const mockData = { + id: 'server-1', + description: 'Existing description', + parameters: { param1: 'value1' }, + } as unknown as MCPServerDetail + + render( + , + { wrapper: createWrapper() }, + ) + + // Change description + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + fireEvent.change(textarea, { target: { value: 'Updated description' } }) + + // Click save button + const saveButton = screen.getByText('tools.mcp.modal.save') + fireEvent.click(saveButton) + + await waitFor(() => { + expect(onHide).toHaveBeenCalled() + }) + }) + }) + + describe('Parameter Handling', () => { + it('should update parameter value when changed', async () => { + const latestParams = [ + { variable: 'param1', label: 'Parameter 1', type: 'string' }, + { variable: 'param2', label: 'Parameter 2', type: 'string' }, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + // Fill description first + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + fireEvent.change(textarea, { target: { value: 'Test description' } }) + + // Get all parameter inputs + const paramInputs = screen.getAllByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + + // Change the first parameter value + fireEvent.change(paramInputs[0], { target: { value: 'new param value' } }) + + expect(paramInputs[0]).toHaveValue('new param value') + }) + + it('should submit with parameter values', async () => { + const onHide = vi.fn() + const latestParams = [ + { variable: 'param1', label: 'Parameter 1', type: 'string' }, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + // Fill description + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + fireEvent.change(textarea, { target: { value: 'Test description' } }) + + // Fill parameter + const paramInput = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + fireEvent.change(paramInput, { target: { value: 'param value' } }) + + // Submit + const confirmButton = screen.getByText('tools.mcp.server.modal.confirm') + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(onHide).toHaveBeenCalled() + }) + }) + + it('should handle empty description submission', async () => { + const onHide = vi.fn() + render(, { wrapper: createWrapper() }) + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.descriptionPlaceholder') + fireEvent.change(textarea, { target: { value: '' } }) + + // Button should be disabled + const confirmButton = screen.getByText('tools.mcp.server.modal.confirm') + expect(confirmButton).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx b/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx new file mode 100644 index 0000000000..6e3a48e330 --- /dev/null +++ b/web/app/components/tools/mcp/mcp-server-param-item.spec.tsx @@ -0,0 +1,165 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import MCPServerParamItem from './mcp-server-param-item' + +describe('MCPServerParamItem', () => { + const defaultProps = { + data: { + label: 'Test Label', + variable: 'test_variable', + type: 'string', + }, + value: '', + onChange: vi.fn(), + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should display label', () => { + render() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should display variable name', () => { + render() + expect(screen.getByText('test_variable')).toBeInTheDocument() + }) + + it('should display type', () => { + render() + expect(screen.getByText('string')).toBeInTheDocument() + }) + + it('should display separator dot', () => { + render() + expect(screen.getByText('·')).toBeInTheDocument() + }) + + it('should render textarea with placeholder', () => { + render() + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Value Display', () => { + it('should display empty value by default', () => { + render() + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + expect(textarea).toHaveValue('') + }) + + it('should display provided value', () => { + render() + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + expect(textarea).toHaveValue('test value') + }) + + it('should display long text value', () => { + const longValue = 'This is a very long text value that might span multiple lines' + render() + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + expect(textarea).toHaveValue(longValue) + }) + }) + + describe('User Interactions', () => { + it('should call onChange when text is entered', () => { + const onChange = vi.fn() + render() + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + fireEvent.change(textarea, { target: { value: 'new value' } }) + + expect(onChange).toHaveBeenCalledWith('new value') + }) + + it('should call onChange with empty string when cleared', () => { + const onChange = vi.fn() + render() + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + fireEvent.change(textarea, { target: { value: '' } }) + + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should handle multiple changes', () => { + const onChange = vi.fn() + render() + + const textarea = screen.getByPlaceholderText('tools.mcp.server.modal.parametersPlaceholder') + + fireEvent.change(textarea, { target: { value: 'first' } }) + fireEvent.change(textarea, { target: { value: 'second' } }) + fireEvent.change(textarea, { target: { value: 'third' } }) + + expect(onChange).toHaveBeenCalledTimes(3) + expect(onChange).toHaveBeenLastCalledWith('third') + }) + }) + + describe('Different Data Types', () => { + it('should display number type', () => { + const props = { + ...defaultProps, + data: { label: 'Count', variable: 'count', type: 'number' }, + } + render() + expect(screen.getByText('number')).toBeInTheDocument() + }) + + it('should display boolean type', () => { + const props = { + ...defaultProps, + data: { label: 'Enabled', variable: 'enabled', type: 'boolean' }, + } + render() + expect(screen.getByText('boolean')).toBeInTheDocument() + }) + + it('should display array type', () => { + const props = { + ...defaultProps, + data: { label: 'Items', variable: 'items', type: 'array' }, + } + render() + expect(screen.getByText('array')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters in label', () => { + const props = { + ...defaultProps, + data: { label: 'Test