Merge branch 'main' into feat/hitl-frontend

This commit is contained in:
twwu 2026-01-20 10:45:27 +08:00
commit 1014852ebd
84 changed files with 7771 additions and 424 deletions

View File

@ -0,0 +1,52 @@
## Purpose
`api/controllers/console/datasets/datasets_document.py` contains the console (authenticated) APIs for managing dataset documents (list/create/update/delete, processing controls, estimates, etc.).
## Storage model (uploaded files)
- For local file uploads into a knowledge base, the binary is stored via `extensions.ext_storage.storage` under the key:
- `upload_files/<tenant_id>/<uuid>.<ext>`
- File metadata is stored in the `upload_files` table (`UploadFile` model), keyed by `UploadFile.id`.
- Dataset `Document` records reference the uploaded file via:
- `Document.data_source_info.upload_file_id`
## Download endpoint
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
- Only supported when `Document.data_source_type == "upload_file"`.
- Performs dataset permission + tenant checks via `DocumentResource.get_document(...)`.
- Delegates `Document -> UploadFile` validation and signed URL generation to `DocumentService.get_document_download_url(...)`.
- Applies `cloud_edition_billing_rate_limit_check("knowledge")` to match other KB operations.
- Response body is **only**: `{ "url": "<signed-url>" }`.
- `POST /datasets/<dataset_id>/documents/download-zip`
- Accepts `{ "document_ids": ["..."] }` (upload-file only).
- Returns `application/zip` as a single attachment download.
- Rationale: browsers often block multiple automatic downloads; a ZIP avoids that limitation.
- Applies `cloud_edition_billing_rate_limit_check("knowledge")`.
- Delegates dataset permission checks, document/upload-file validation, and download-name generation to
`DocumentService.prepare_document_batch_download_zip(...)` before streaming the ZIP.
## Verification plan
- Upload a document from a local file into a dataset.
- Call the download endpoint and confirm it returns a signed URL.
- Open the URL and confirm:
- Response headers force download (`Content-Disposition`), and
- Downloaded bytes match the uploaded file.
- Select multiple uploaded-file documents and download as ZIP; confirm all selected files exist in the archive.
## Shared helper
- `DocumentService.get_document_download_url(document)` resolves the `UploadFile` and signs a download URL.
- `DocumentService.prepare_document_batch_download_zip(...)` performs dataset permission checks, batches
document + upload file lookups, preserves request order, and generates the client-visible ZIP filename.
- Internal helpers now live in `DocumentService` (`_get_upload_file_id_for_upload_file_document(...)`,
`_get_upload_file_for_upload_file_document(...)`, `_get_upload_files_by_document_id_for_zip_download(...)`).
- ZIP packing is handled by `FileService.build_upload_files_zip_tempfile(...)`, which also:
- sanitizes entry names to avoid path traversal, and
- deduplicates names while preserving extensions (e.g., `doc.txt``doc (1).txt`).
Streaming the response and deferring cleanup is handled by the route via `send_file(path, ...)` + `ExitStack` +
`response.call_on_close(...)` (the file is deleted when the response is closed).

View File

@ -0,0 +1,18 @@
## Purpose
`api/services/dataset_service.py` hosts dataset/document service logic used by console and API controllers.
## Batch document operations
- Batch document workflows should avoid N+1 database queries by using set-based lookups.
- Tenant checks must be enforced consistently across dataset/document operations.
- `DocumentService.get_documents_by_ids(...)` fetches documents for a dataset using `id.in_(...)`.
- `FileService.get_upload_files_by_ids(...)` performs tenant-scoped batch lookup for `UploadFile` (dedupes ids with `set(...)`).
- `DocumentService.get_document_download_url(...)` and `prepare_document_batch_download_zip(...)` handle
dataset/document permission checks plus `Document -> UploadFile` validation for download endpoints.
## Verification plan
- Exercise document list and download endpoints that use the service helpers.
- Confirm batch download uses constant query count for documents + upload files.
- Request a ZIP with a missing document id and confirm a 404 is returned.

View File

@ -0,0 +1,35 @@
## Purpose
`api/services/file_service.py` owns business logic around `UploadFile` objects: upload validation, storage persistence,
previews/generators, and deletion.
## Key invariants
- All storage I/O goes through `extensions.ext_storage.storage`.
- Uploaded file keys follow: `upload_files/<tenant_id>/<uuid>.<ext>`.
- Upload validation is enforced in `FileService.upload_file(...)` (blocked extensions, size limits, dataset-only types).
## Batch lookup helpers
- `FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)` is the canonical tenant-scoped batch loader for
`UploadFile`.
## Dataset document download helpers
The dataset document download/ZIP endpoints now delegate “Document → UploadFile” validation and permission checks to
`DocumentService` (`api/services/dataset_service.py`). `FileService` stays focused on generic `UploadFile` operations
(uploading, previews, deletion), plus generic ZIP serving.
### ZIP serving
- `FileService.build_upload_files_zip_tempfile(...)` builds a ZIP from `UploadFile` objects and yields a seeked
tempfile **path** so callers can stream it (e.g., `send_file(path, ...)`) without hitting "read of closed file"
issues from file-handle lifecycle during streamed responses.
- Flask `send_file(...)` and the `ExitStack`/`call_on_close(...)` cleanup pattern are handled in the route layer.
## Verification plan
- Unit: `api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py`
- Verify signed URL generation for upload-file documents and ZIP download behavior for multiple documents.
- Unit: `api/tests/unit_tests/services/test_file_service_zip_and_lookup.py`
- Verify ZIP packing produces a valid, openable archive and preserves file content.

View File

@ -0,0 +1,28 @@
## Purpose
Unit tests for the console dataset document download endpoint:
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
## Testing approach
- Uses `Flask.test_request_context()` and calls the `Resource.get(...)` method directly.
- Monkeypatches console decorators (`login_required`, `setup_required`, rate limit) to no-ops to keep the test focused.
- Mocks:
- `DatasetService.get_dataset` / `check_dataset_permission`
- `DocumentService.get_document` for single-file download tests
- `DocumentService.get_documents_by_ids` + `FileService.get_upload_files_by_ids` for ZIP download tests
- `FileService.get_upload_files_by_ids` for `UploadFile` lookups in single-file tests
- `services.dataset_service.file_helpers.get_signed_file_url` to return a deterministic URL
- Document mocks include `id` fields so batch lookups can map documents by id.
## Covered cases
- Success returns `{ "url": "<signed>" }` for upload-file documents.
- 404 when document is not `upload_file`.
- 404 when `upload_file_id` is missing.
- 404 when referenced `UploadFile` row does not exist.
- 403 when document tenant does not match current tenant.
- Batch ZIP download returns `application/zip` for upload-file documents.
- Batch ZIP download rejects non-upload-file documents.
- Batch ZIP download uses a random `.zip` attachment name (`download_name`), so tests only assert the suffix.

View File

@ -0,0 +1,18 @@
## Purpose
Unit tests for `api/services/file_service.py` helper methods that are not covered by higher-level controller tests.
## Whats covered
- `FileService.build_upload_files_zip_tempfile(...)`
- ZIP entry name sanitization (no directory components / traversal)
- name deduplication while preserving extensions
- writing streamed bytes from `storage.load(...)` into ZIP entries
- yields a tempfile path so callers can open/stream the ZIP without holding a live file handle
- `FileService.get_upload_files_by_ids(...)`
- returns `{}` for empty id lists
- returns an id-keyed mapping for non-empty lists
## Notes
- These tests intentionally stub `storage.load` and `db.session.scalars(...).all()` to avoid needing a real DB/storage.

View File

@ -1,4 +1,3 @@
import re
import uuid
from datetime import datetime
from typing import Any, Literal, TypeAlias
@ -68,48 +67,6 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
# XSS prevention: patterns that could lead to XSS attacks
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
_XSS_PATTERNS = [
r"<script[^>]*>.*?</script>", # Script tags
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
r"javascript:", # JavaScript protocol
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
r"<embed[^>]*>", # Embed tags (self-closing)
r"<link[^>]*>", # Link tags with javascript
]
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
"""
Validate that a string value doesn't contain potential XSS payloads.
Args:
value: The string value to validate
field_name: Name of the field for error messages
Returns:
The original value if safe
Raises:
ValueError: If the value contains XSS patterns
"""
if value is None:
return None
value_lower = value.lower()
for pattern in _XSS_PATTERNS:
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
raise ValueError(
f"{field_name} contains invalid characters or patterns. "
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
)
return value
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@ -118,11 +75,6 @@ class CreateAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
@ -133,11 +85,6 @@ class UpdateAppPayload(BaseModel):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
max_active_requests: int | None = Field(default=None, description="Maximum active requests")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app")
@ -146,11 +93,6 @@ class CopyAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class AppExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Include secrets in export")

View File

@ -2,10 +2,12 @@ import json
import logging
from argparse import ArgumentTypeError
from collections.abc import Sequence
from typing import Literal, cast
from contextlib import ExitStack
from typing import Any, Literal, cast
from uuid import UUID
import sqlalchemy as sa
from flask import request
from flask import request, send_file
from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, Field
from sqlalchemy import asc, desc, select
@ -42,6 +44,7 @@ from models import DatasetProcessRule, Document, DocumentSegment, UploadFile
from models.dataset import DocumentPipelineExecutionLog
from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
from services.file_service import FileService
from ..app.error import (
ProviderModelCurrentlyNotSupportError,
@ -65,6 +68,9 @@ from ..wraps import (
logger = logging.getLogger(__name__)
# NOTE: Keep constants near the top of the module for discoverability.
DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
def _get_or_create_model(model_name: str, field_def):
existing = console_ns.models.get(model_name)
@ -104,6 +110,12 @@ class DocumentRenamePayload(BaseModel):
name: str
class DocumentBatchDownloadZipPayload(BaseModel):
"""Request payload for bulk downloading documents as a zip archive."""
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
class DocumentDatasetListParam(BaseModel):
page: int = Field(1, title="Page", description="Page number.")
limit: int = Field(20, title="Limit", description="Page size.")
@ -120,6 +132,7 @@ register_schema_models(
RetrievalModel,
DocumentRetryPayload,
DocumentRenamePayload,
DocumentBatchDownloadZipPayload,
)
@ -853,6 +866,62 @@ class DocumentApi(DocumentResource):
return {"result": "success"}, 204
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/download")
class DocumentDownloadApi(DocumentResource):
"""Return a signed download URL for a dataset document's original uploaded file."""
@console_ns.doc("get_dataset_document_download_url")
@console_ns.doc(description="Get a signed download URL for a dataset document's original uploaded file")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
def get(self, dataset_id: str, document_id: str) -> dict[str, Any]:
# Reuse the shared permission/tenant checks implemented in DocumentResource.
document = self.get_document(str(dataset_id), str(document_id))
return {"url": DocumentService.get_document_download_url(document)}
@console_ns.route("/datasets/<uuid:dataset_id>/documents/download-zip")
class DocumentBatchDownloadZipApi(DocumentResource):
"""Download multiple uploaded-file documents as a single ZIP (avoids browser multi-download limits)."""
@console_ns.doc("download_dataset_documents_as_zip")
@console_ns.doc(description="Download selected dataset documents as a single ZIP archive (upload-file only)")
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_rate_limit_check("knowledge")
@console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__])
def post(self, dataset_id: str):
"""Stream a ZIP archive containing the requested uploaded documents."""
# Parse and validate request payload.
payload = DocumentBatchDownloadZipPayload.model_validate(console_ns.payload or {})
current_user, current_tenant_id = current_account_with_tenant()
dataset_id = str(dataset_id)
document_ids: list[str] = [str(document_id) for document_id in payload.document_ids]
upload_files, download_name = DocumentService.prepare_document_batch_download_zip(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=current_tenant_id,
current_user=current_user,
)
# Delegate ZIP packing to FileService, but keep Flask response+cleanup in the route.
with ExitStack() as stack:
zip_path = stack.enter_context(FileService.build_upload_files_zip_tempfile(upload_files=upload_files))
response = send_file(
zip_path,
mimetype="application/zip",
as_attachment=True,
download_name=download_name,
)
cleanup = stack.pop_all()
response.call_on_close(cleanup.close)
return response
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/processing/<string:action>")
class DocumentProcessingApi(DocumentResource):
@console_ns.doc("update_document_processing")

View File

@ -320,18 +320,17 @@ class BasePluginClient:
case PluginInvokeError.__name__:
error_object = json.loads(message)
invoke_error_type = error_object.get("error_type")
args = error_object.get("args")
match invoke_error_type:
case InvokeRateLimitError.__name__:
raise InvokeRateLimitError(description=args.get("description"))
raise InvokeRateLimitError(description=error_object.get("message"))
case InvokeAuthorizationError.__name__:
raise InvokeAuthorizationError(description=args.get("description"))
raise InvokeAuthorizationError(description=error_object.get("message"))
case InvokeBadRequestError.__name__:
raise InvokeBadRequestError(description=args.get("description"))
raise InvokeBadRequestError(description=error_object.get("message"))
case InvokeConnectionError.__name__:
raise InvokeConnectionError(description=args.get("description"))
raise InvokeConnectionError(description=error_object.get("message"))
case InvokeServerUnavailableError.__name__:
raise InvokeServerUnavailableError(description=args.get("description"))
raise InvokeServerUnavailableError(description=error_object.get("message"))
case CredentialsValidateFailedError.__name__:
raise CredentialsValidateFailedError(error_object.get("message"))
case EndpointSetupFailedError.__name__:
@ -339,11 +338,11 @@ class BasePluginClient:
case TriggerProviderCredentialValidationError.__name__:
raise TriggerProviderCredentialValidationError(error_object.get("message"))
case TriggerPluginInvokeError.__name__:
raise TriggerPluginInvokeError(description=error_object.get("description"))
raise TriggerPluginInvokeError(description=error_object.get("message"))
case TriggerInvokeError.__name__:
raise TriggerInvokeError(error_object.get("message"))
case EventIgnoreError.__name__:
raise EventIgnoreError(description=error_object.get("description"))
raise EventIgnoreError(description=error_object.get("message"))
case _:
raise PluginInvokeError(description=message)
case PluginDaemonInternalServerError.__name__:

View File

@ -13,10 +13,11 @@ import sqlalchemy as sa
from redis.exceptions import LockNotOwnedError
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.file import helpers as file_helpers
from core.helper.name_generator import generate_incremental_name
from core.model_manager import ModelManager
from core.model_runtime.entities.model_entities import ModelFeature, ModelType
@ -73,6 +74,7 @@ from services.errors.document import DocumentIndexingError
from services.errors.file import FileNotExistsError
from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureModel, FeatureService
from services.file_service import FileService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.tag_service import TagService
from services.vector_service import VectorService
@ -1162,6 +1164,7 @@ class DocumentService:
Document.archived.is_(True),
),
}
DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip"
@classmethod
def normalize_display_status(cls, status: str | None) -> str | None:
@ -1288,6 +1291,143 @@ class DocumentService:
else:
return None
@staticmethod
def get_documents_by_ids(dataset_id: str, document_ids: Sequence[str]) -> Sequence[Document]:
"""Fetch documents for a dataset in a single batch query."""
if not document_ids:
return []
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
# Fetch all requested documents in one query to avoid N+1 lookups.
documents: Sequence[Document] = db.session.scalars(
select(Document).where(
Document.dataset_id == dataset_id,
Document.id.in_(document_id_list),
)
).all()
return documents
@staticmethod
def get_document_download_url(document: Document) -> str:
"""
Return a signed download URL for an upload-file document.
"""
upload_file = DocumentService._get_upload_file_for_upload_file_document(document)
return file_helpers.get_signed_file_url(upload_file_id=upload_file.id, as_attachment=True)
@staticmethod
def prepare_document_batch_download_zip(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
current_user: Account,
) -> tuple[list[UploadFile], str]:
"""
Resolve upload files for batch ZIP downloads and generate a client-visible filename.
"""
dataset = DatasetService.get_dataset(dataset_id)
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
except NoPermissionError as e:
raise Forbidden(str(e))
upload_files_by_document_id = DocumentService._get_upload_files_by_document_id_for_zip_download(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=tenant_id,
)
upload_files = [upload_files_by_document_id[document_id] for document_id in document_ids]
download_name = DocumentService._generate_document_batch_download_zip_filename()
return upload_files, download_name
@staticmethod
def _generate_document_batch_download_zip_filename() -> str:
"""
Generate a random attachment filename for the batch download ZIP.
"""
return f"{uuid.uuid4().hex}{DocumentService.DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION}"
@staticmethod
def _get_upload_file_id_for_upload_file_document(
document: Document,
*,
invalid_source_message: str,
missing_file_message: str,
) -> str:
"""
Normalize and validate `Document -> UploadFile` linkage for download flows.
"""
if document.data_source_type != "upload_file":
raise NotFound(invalid_source_message)
data_source_info: dict[str, Any] = document.data_source_info_dict or {}
upload_file_id: str | None = data_source_info.get("upload_file_id")
if not upload_file_id:
raise NotFound(missing_file_message)
return str(upload_file_id)
@staticmethod
def _get_upload_file_for_upload_file_document(document: Document) -> UploadFile:
"""
Load the `UploadFile` row for an upload-file document.
"""
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Document does not have an uploaded file to download.",
missing_file_message="Uploaded file not found.",
)
upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id])
upload_file = upload_files_by_id.get(upload_file_id)
if not upload_file:
raise NotFound("Uploaded file not found.")
return upload_file
@staticmethod
def _get_upload_files_by_document_id_for_zip_download(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
) -> dict[str, UploadFile]:
"""
Batch load upload files keyed by document id for ZIP downloads.
"""
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
documents = DocumentService.get_documents_by_ids(dataset_id, document_id_list)
documents_by_id: dict[str, Document] = {str(document.id): document for document in documents}
missing_document_ids: set[str] = set(document_id_list) - set(documents_by_id.keys())
if missing_document_ids:
raise NotFound("Document not found.")
upload_file_ids: list[str] = []
upload_file_ids_by_document_id: dict[str, str] = {}
for document_id, document in documents_by_id.items():
if document.tenant_id != tenant_id:
raise Forbidden("No permission.")
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Only uploaded-file documents can be downloaded as ZIP.",
missing_file_message="Only uploaded-file documents can be downloaded as ZIP.",
)
upload_file_ids.append(upload_file_id)
upload_file_ids_by_document_id[document_id] = upload_file_id
upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)
missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys())
if missing_upload_file_ids:
raise NotFound("Only uploaded-file documents can be downloaded as ZIP.")
return {
document_id: upload_files_by_id[upload_file_id]
for document_id, upload_file_id in upload_file_ids_by_document_id.items()
}
@staticmethod
def get_document_by_id(document_id: str) -> Document | None:
document = db.session.query(Document).where(Document.id == document_id).first()

View File

@ -2,7 +2,11 @@ import base64
import hashlib
import os
import uuid
from collections.abc import Iterator, Sequence
from contextlib import contextmanager, suppress
from tempfile import NamedTemporaryFile
from typing import Literal, Union
from zipfile import ZIP_DEFLATED, ZipFile
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
@ -17,6 +21,7 @@ from constants import (
)
from core.file import helpers as file_helpers
from core.rag.extractor.extract_processor import ExtractProcessor
from extensions.ext_database import db
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
from libs.helper import extract_tenant_id
@ -167,6 +172,9 @@ class FileService:
return upload_file
def get_file_preview(self, file_id: str):
"""
Return a short text preview extracted from a document file.
"""
with self._session_maker(expire_on_commit=False) as session:
upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first()
@ -253,3 +261,101 @@ class FileService:
return
storage.delete(upload_file.key)
session.delete(upload_file)
@staticmethod
def get_upload_files_by_ids(tenant_id: str, upload_file_ids: Sequence[str]) -> dict[str, UploadFile]:
"""
Fetch `UploadFile` rows for a tenant in a single batch query.
This is a generic `UploadFile` lookup helper (not dataset/document specific), so it lives in `FileService`.
"""
if not upload_file_ids:
return {}
# Normalize and deduplicate ids before using them in the IN clause.
upload_file_id_list: list[str] = [str(upload_file_id) for upload_file_id in upload_file_ids]
unique_upload_file_ids: list[str] = list(set(upload_file_id_list))
# Fetch upload files in one query for efficient batch access.
upload_files: Sequence[UploadFile] = db.session.scalars(
select(UploadFile).where(
UploadFile.tenant_id == tenant_id,
UploadFile.id.in_(unique_upload_file_ids),
)
).all()
return {str(upload_file.id): upload_file for upload_file in upload_files}
@staticmethod
def _sanitize_zip_entry_name(name: str) -> str:
"""
Sanitize a ZIP entry name to avoid path traversal and weird separators.
We keep this conservative: the upload flow already rejects `/` and `\\`, but older rows (or imported data)
could still contain unsafe names.
"""
# Drop any directory components and prevent empty names.
base = os.path.basename(name).strip() or "file"
# ZIP uses forward slashes as separators; remove any residual separator characters.
return base.replace("/", "_").replace("\\", "_")
@staticmethod
def _dedupe_zip_entry_name(original_name: str, used_names: set[str]) -> str:
"""
Return a unique ZIP entry name, inserting suffixes before the extension.
"""
# Keep the original name when it's not already used.
if original_name not in used_names:
return original_name
# Insert suffixes before the extension (e.g., "doc.txt" -> "doc (1).txt").
stem, extension = os.path.splitext(original_name)
suffix = 1
while True:
candidate = f"{stem} ({suffix}){extension}"
if candidate not in used_names:
return candidate
suffix += 1
@staticmethod
@contextmanager
def build_upload_files_zip_tempfile(
*,
upload_files: Sequence[UploadFile],
) -> Iterator[str]:
"""
Build a ZIP from `UploadFile`s and yield a tempfile path.
We yield a path (rather than an open file handle) to avoid "read of closed file" issues when Flask/Werkzeug
streams responses. The caller is expected to keep this context open until the response is fully sent, then
close it (e.g., via `response.call_on_close(...)`) to delete the tempfile.
"""
used_names: set[str] = set()
# Build a ZIP in a temp file and keep it on disk until the caller finishes streaming it.
tmp_path: str | None = None
try:
with NamedTemporaryFile(mode="w+b", suffix=".zip", delete=False) as tmp:
tmp_path = tmp.name
with ZipFile(tmp, mode="w", compression=ZIP_DEFLATED) as zf:
for upload_file in upload_files:
# Ensure the entry name is safe and unique.
safe_name = FileService._sanitize_zip_entry_name(upload_file.name)
arcname = FileService._dedupe_zip_entry_name(safe_name, used_names)
used_names.add(arcname)
# Stream file bytes from storage into the ZIP entry.
with zf.open(arcname, "w") as entry:
for chunk in storage.load(upload_file.key, stream=True):
entry.write(chunk)
# Flush so `send_file(path, ...)` can re-open it safely on all platforms.
tmp.flush()
assert tmp_path is not None
yield tmp_path
finally:
# Remove the temp file when the context is closed (typically after the response finishes streaming).
if tmp_path is not None:
with suppress(FileNotFoundError):
os.remove(tmp_path)

View File

@ -83,7 +83,30 @@
<p class="content1">Dear {{ to }},</p>
<p class="content2">{{ inviter_name }} is pleased to invite you to join our workspace on Dify, a platform specifically designed for LLM application development. On Dify, you can explore, create, and collaborate to build and operate AI applications.</p>
<p class="content2">Click the button below to log in to Dify and join the workspace.</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
<div style="text-align: center; margin-bottom: 32px;">
<a href="{{ url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">Login Here</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ url }}
</a>
</p>
</div>
<p class="content2">Best regards,</p>
<p class="content2">Dify Team</p>
</div>

View File

@ -83,7 +83,30 @@
<p class="content1">尊敬的 {{ to }}</p>
<p class="content2">{{ inviter_name }} 现邀请您加入我们在 Dify 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 Dify 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
<p class="content2">点击下方按钮即可登录 Dify 并且加入空间。</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
<div style="text-align: center; margin-bottom: 32px;">
<a href="{{ url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">在此登录</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ url }}
</a>
</p>
</div>
<p class="content2">此致,</p>
<p class="content2">Dify 团队</p>
</div>

View File

@ -115,7 +115,30 @@
We noticed you tried to sign up, but this email is already registered with an existing account.
Please log in here: </p>
<a href="{{ login_url }}" class="button">Log In</a>
<div style="text-align: center; margin-bottom: 20px;">
<a href="{{ login_url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">Log In</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ login_url }}
</a>
</p>
</div>
<p class="description">
If you forgot your password, you can reset it here: <a href="{{ reset_password_url }}"
class="reset-btn">Reset Password</a>

View File

@ -115,7 +115,30 @@
我们注意到您尝试注册,但此电子邮件已注册。
请在此登录: </p>
<a href="{{ login_url }}" class="button">登录</a>
<div style="text-align: center; margin-bottom: 20px;">
<a href="{{ login_url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">登录</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ login_url }}
</a>
</p>
</div>
<p class="description">
如果您忘记了密码,可以在此重置: <a href="{{ reset_password_url }}" class="reset-btn">重置密码</a>
</p>

View File

@ -92,12 +92,34 @@
platform specifically designed for LLM application development. On {{application_title}}, you can explore,
create, and collaborate to build and operate AI applications.</p>
<p class="content2">Click the button below to log in to {{application_title}} and join the workspace.</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none"
class="button" href="{{ url }}">Login Here</a></p>
<div style="text-align: center; margin-bottom: 32px;">
<a href="{{ url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">Login Here</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ url }}
</a>
</p>
</div>
<p class="content2">Best regards,</p>
<p class="content2">{{application_title}} Team</p>
</div>
</div>
</body>
</html>
</html>

View File

@ -81,7 +81,30 @@
<p class="content1">尊敬的 {{ to }}</p>
<p class="content2">{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
<p class="content2">点击下方按钮即可登录 {{application_title}} 并且加入空间。</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
<div style="text-align: center; margin-bottom: 32px;">
<a href="{{ url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">在此登录</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
<a href="{{ url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ url }}
</a>
</p>
</div>
<p class="content2">此致,</p>
<p class="content2">{{application_title}} 团队</p>
</div>

View File

@ -111,7 +111,30 @@
We noticed you tried to sign up, but this email is already registered with an existing account.
Please log in here: </p>
<a href="{{ login_url }}" class="button">Log In</a>
<div style="text-align: center; margin-bottom: 20px;">
<a href="{{ login_url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">Log In</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ login_url }}
</a>
</p>
</div>
<p class="description">
If you forgot your password, you can reset it here: <a href="{{ reset_password_url }}"
class="reset-btn">Reset Password</a>

View File

@ -111,7 +111,30 @@
我们注意到您尝试注册,但此电子邮件已注册。
请在此登录: </p>
<a href="{{ login_url }}" class="button">登录</a>
<div style="text-align: center; margin-bottom: 20px;">
<a href="{{ login_url }}"
style="background-color:#2563eb;
color:#ffffff !important;
text-decoration:none;
display:inline-block;
font-weight:600;
border-radius:4px;
font-size:14px;
line-height:18px;
font-family: Helvetica, Arial, sans-serif;
text-align:center;
border-top: 10px solid #2563eb;
border-bottom: 10px solid #2563eb;
border-left: 20px solid #2563eb;
border-right: 20px solid #2563eb;
">登录</a>
<p style="font-size: 12px; color: #666666; margin-top: 20px; margin-bottom: 0;">
如果按钮无法使用,请将以下链接复制到浏览器打开:<br>
<a href="{{ login_url }}" style="color: #2563eb; text-decoration: underline; word-break: break-all;">
{{ login_url }}
</a>
</p>
</div>
<p class="description">
如果您忘记了密码,可以在此重置: <a href="{{ reset_password_url }}" class="reset-btn">重置密码</a>
</p>

View File

@ -1,254 +0,0 @@
"""
Unit tests for XSS prevention in App payloads.
This test module validates that HTML tags, JavaScript, and other potentially
dangerous content are rejected in App names and descriptions.
"""
import pytest
from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload
class TestXSSPreventionUnit:
"""Unit tests for XSS prevention in App payloads."""
def test_create_app_valid_names(self):
"""Test CreateAppPayload with valid app names."""
# Normal app names should be valid
valid_names = [
"My App",
"Test App 123",
"App with - dash",
"App with _ underscore",
"App with + plus",
"App with () parentheses",
"App with [] brackets",
"App with {} braces",
"App with ! exclamation",
"App with @ at",
"App with # hash",
"App with $ dollar",
"App with % percent",
"App with ^ caret",
"App with & ampersand",
"App with * asterisk",
"Unicode: 测试应用",
"Emoji: 🤖",
"Mixed: Test 测试 123",
]
for name in valid_names:
payload = CreateAppPayload(
name=name,
mode="chat",
)
assert payload.name == name
def test_create_app_xss_script_tags(self):
"""Test CreateAppPayload rejects script tags."""
xss_payloads = [
"<script>alert(document.cookie)</script>",
"<Script>alert(1)</Script>",
"<SCRIPT>alert('XSS')</SCRIPT>",
"<script>alert(String.fromCharCode(88,83,83))</script>",
"<script src='evil.js'></script>",
"<script>document.location='http://evil.com'</script>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_iframe_tags(self):
"""Test CreateAppPayload rejects iframe tags."""
xss_payloads = [
"<iframe src='evil.com'></iframe>",
"<Iframe srcdoc='<script>alert(1)</script>'></iframe>",
"<IFRAME src='javascript:alert(1)'></iframe>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_javascript_protocol(self):
"""Test CreateAppPayload rejects javascript: protocol."""
xss_payloads = [
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"JavaScript:alert(document.cookie)",
"javascript:void(0)",
"javascript://comment%0Aalert(1)",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_svg_onload(self):
"""Test CreateAppPayload rejects SVG with onload."""
xss_payloads = [
"<svg onload=alert(1)>",
"<SVG ONLOAD=alert(1)>",
"<svg/x/onload=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_event_handlers(self):
"""Test CreateAppPayload rejects HTML event handlers."""
xss_payloads = [
"<div onclick=alert(1)>",
"<img onerror=alert(1)>",
"<body onload=alert(1)>",
"<input onfocus=alert(1)>",
"<a onmouseover=alert(1)>",
"<DIV ONCLICK=alert(1)>",
"<img src=x onerror=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_object_embed(self):
"""Test CreateAppPayload rejects object and embed tags."""
xss_payloads = [
"<object data='evil.swf'></object>",
"<embed src='evil.swf'>",
"<OBJECT data='javascript:alert(1)'></OBJECT>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_link_javascript(self):
"""Test CreateAppPayload rejects link tags with javascript."""
xss_payloads = [
"<link href='javascript:alert(1)'>",
"<LINK HREF='javascript:alert(1)'>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_in_description(self):
"""Test CreateAppPayload rejects XSS in description."""
xss_descriptions = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for description in xss_descriptions:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(
name="Valid Name",
mode="chat",
description=description,
)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_valid_descriptions(self):
"""Test CreateAppPayload with valid descriptions."""
valid_descriptions = [
"A simple description",
"Description with < and > symbols",
"Description with & ampersand",
"Description with 'quotes' and \"double quotes\"",
"Description with / slashes",
"Description with \\ backslashes",
"Description with ; semicolons",
"Unicode: 这是一个描述",
"Emoji: 🎉🚀",
]
for description in valid_descriptions:
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=description,
)
assert payload.description == description
def test_create_app_none_description(self):
"""Test CreateAppPayload with None description."""
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=None,
)
assert payload.description is None
def test_update_app_xss_prevention(self):
"""Test UpdateAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
UpdateAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_update_app_valid_names(self):
"""Test UpdateAppPayload with valid names."""
payload = UpdateAppPayload(name="Valid Updated Name")
assert payload.name == "Valid Updated Name"
def test_copy_app_xss_prevention(self):
"""Test CopyAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
CopyAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_copy_app_valid_names(self):
"""Test CopyAppPayload with valid names."""
payload = CopyAppPayload(name="Valid Copy Name")
assert payload.name == "Valid Copy Name"
def test_copy_app_none_name(self):
"""Test CopyAppPayload with None name (should be allowed)."""
payload = CopyAppPayload(name=None)
assert payload.name is None
def test_edge_case_angle_brackets_content(self):
"""Test that angle brackets with actual content are rejected."""
# Angle brackets without valid HTML-like patterns should be checked
# The regex pattern <.*?on\w+\s*= should catch event handlers
# But let's verify other patterns too
# Valid: angle brackets used as symbols (not matched by our patterns)
# Our patterns specifically look for dangerous constructs
# Invalid: actual HTML tags with event handlers
invalid_names = [
"<div onclick=xss>",
"<img src=x onerror=alert(1)>",
]
for name in invalid_names:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()

View File

@ -0,0 +1,430 @@
"""
Unit tests for the dataset document download endpoint.
These tests validate that the controller returns a signed download URL for
upload-file documents, and rejects unsupported or missing file cases.
"""
from __future__ import annotations
import importlib
import sys
from collections import UserDict
from io import BytesIO
from types import SimpleNamespace
from typing import Any
from zipfile import ZipFile
import pytest
from flask import Flask
from werkzeug.exceptions import Forbidden, NotFound
@pytest.fixture
def app() -> Flask:
"""Create a minimal Flask app for request-context based controller tests."""
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def datasets_document_module(monkeypatch: pytest.MonkeyPatch):
"""
Reload `controllers.console.datasets.datasets_document` with lightweight decorators.
We patch auth / setup / rate-limit decorators to no-ops so we can unit test the
controller logic without requiring the full console stack.
"""
from controllers.console import console_ns, wraps
from libs import login
def _noop(func): # type: ignore[no-untyped-def]
return func
# Bypass login/setup/account checks in unit tests.
monkeypatch.setattr(login, "login_required", _noop)
monkeypatch.setattr(wraps, "setup_required", _noop)
monkeypatch.setattr(wraps, "account_initialization_required", _noop)
# Bypass billing-related decorators used by other endpoints in this module.
monkeypatch.setattr(wraps, "cloud_edition_billing_resource_check", lambda *_args, **_kwargs: (lambda f: f))
monkeypatch.setattr(wraps, "cloud_edition_billing_rate_limit_check", lambda *_args, **_kwargs: (lambda f: f))
# Avoid Flask-RESTX route registration side effects during import.
def _noop_route(*_args, **_kwargs): # type: ignore[override]
def _decorator(cls):
return cls
return _decorator
monkeypatch.setattr(console_ns, "route", _noop_route)
module_name = "controllers.console.datasets.datasets_document"
sys.modules.pop(module_name, None)
return importlib.import_module(module_name)
def _mock_user(*, is_dataset_editor: bool = True) -> SimpleNamespace:
"""Build a minimal user object compatible with dataset permission checks."""
return SimpleNamespace(is_dataset_editor=is_dataset_editor, id="user-123")
def _mock_document(
*,
document_id: str,
tenant_id: str,
data_source_type: str,
upload_file_id: str | None,
) -> SimpleNamespace:
"""Build a minimal document object used by the controller."""
data_source_info_dict: dict[str, Any] | None = None
if upload_file_id is not None:
data_source_info_dict = {"upload_file_id": upload_file_id}
else:
data_source_info_dict = {}
return SimpleNamespace(
id=document_id,
tenant_id=tenant_id,
data_source_type=data_source_type,
data_source_info_dict=data_source_info_dict,
)
def _wire_common_success_mocks(
*,
module,
monkeypatch: pytest.MonkeyPatch,
current_tenant_id: str,
document_tenant_id: str,
data_source_type: str,
upload_file_id: str | None,
upload_file_exists: bool,
signed_url: str,
) -> None:
"""Patch controller dependencies to create a deterministic test environment."""
import services.dataset_service as dataset_service_module
# Make `current_account_with_tenant()` return a known user + tenant id.
monkeypatch.setattr(module, "current_account_with_tenant", lambda: (_mock_user(), current_tenant_id))
# Return a dataset object and allow permission checks to pass.
monkeypatch.setattr(module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1"))
monkeypatch.setattr(module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None)
# Return a document that will be validated inside DocumentResource.get_document.
document = _mock_document(
document_id="doc-1",
tenant_id=document_tenant_id,
data_source_type=data_source_type,
upload_file_id=upload_file_id,
)
monkeypatch.setattr(module.DocumentService, "get_document", lambda *_args, **_kwargs: document)
# Mock UploadFile lookup via FileService batch helper.
upload_files_by_id: dict[str, Any] = {}
if upload_file_exists and upload_file_id is not None:
upload_files_by_id[str(upload_file_id)] = SimpleNamespace(id=str(upload_file_id))
monkeypatch.setattr(module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: upload_files_by_id)
# Mock signing helper so the returned URL is deterministic.
monkeypatch.setattr(dataset_service_module.file_helpers, "get_signed_file_url", lambda **_kwargs: signed_url)
def _mock_send_file(obj, **kwargs): # type: ignore[no-untyped-def]
"""Return a lightweight representation of `send_file(...)` for unit tests."""
class _ResponseMock(UserDict):
def __init__(self, sent_file: object, send_file_kwargs: dict[str, object]) -> None:
super().__init__({"_sent_file": sent_file, "_send_file_kwargs": send_file_kwargs})
self._on_close: object | None = None
def call_on_close(self, func): # type: ignore[no-untyped-def]
self._on_close = func
return func
return _ResponseMock(obj, kwargs)
def test_batch_download_zip_returns_send_file(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure batch ZIP download returns a zip attachment via `send_file`."""
# Arrange common permission mocks.
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
# Two upload-file documents, each referencing an UploadFile.
doc1 = _mock_document(
document_id="11111111-1111-1111-1111-111111111111",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-1",
)
doc2 = _mock_document(
document_id="22222222-2222-2222-2222-222222222222",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-2",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc1, doc2],
)
monkeypatch.setattr(
datasets_document_module.FileService,
"get_upload_files_by_ids",
lambda *_args, **_kwargs: {
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
},
)
# Mock storage streaming content.
import services.file_service as file_service_module
monkeypatch.setattr(file_service_module.storage, "load", lambda _key, stream=True: [b"hello"])
# Replace send_file used by the controller to avoid a real Flask response object.
monkeypatch.setattr(datasets_document_module, "send_file", _mock_send_file)
# Act
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
result = api.post(dataset_id="ds-1")
# Assert: we returned via send_file with correct mime type and attachment.
assert result["_send_file_kwargs"]["mimetype"] == "application/zip"
assert result["_send_file_kwargs"]["as_attachment"] is True
assert isinstance(result["_send_file_kwargs"]["download_name"], str)
assert result["_send_file_kwargs"]["download_name"].endswith(".zip")
# Ensure our cleanup hook is registered and execute it to avoid temp file leaks in unit tests.
assert getattr(result, "_on_close", None) is not None
result._on_close() # type: ignore[attr-defined]
def test_batch_download_zip_response_is_openable_zip(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure the real Flask `send_file` response body is a valid ZIP that can be opened."""
# Arrange: same controller mocks as the lightweight send_file test, but we keep the real `send_file`.
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
doc1 = _mock_document(
document_id="33333333-3333-3333-3333-333333333333",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-1",
)
doc2 = _mock_document(
document_id="44444444-4444-4444-4444-444444444444",
tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-2",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc1, doc2],
)
monkeypatch.setattr(
datasets_document_module.FileService,
"get_upload_files_by_ids",
lambda *_args, **_kwargs: {
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
},
)
# Stream distinct bytes per key so we can verify both ZIP entries.
import services.file_service as file_service_module
monkeypatch.setattr(
file_service_module.storage, "load", lambda key, stream=True: [b"one"] if key == "k1" else [b"two"]
)
# Act
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
response = api.post(dataset_id="ds-1")
# Assert: response body is a valid ZIP and contains the expected entries.
response.direct_passthrough = False
data = response.get_data()
response.close()
with ZipFile(BytesIO(data), mode="r") as zf:
assert zf.namelist() == ["a.txt", "b.txt"]
assert zf.read("a.txt") == b"one"
assert zf.read("b.txt") == b"two"
def test_batch_download_zip_rejects_non_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure batch ZIP download rejects non upload-file documents."""
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
monkeypatch.setattr(
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
)
monkeypatch.setattr(
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
)
doc = _mock_document(
document_id="55555555-5555-5555-5555-555555555555",
tenant_id="tenant-123",
data_source_type="website_crawl",
upload_file_id="file-1",
)
monkeypatch.setattr(
datasets_document_module.DocumentService,
"get_documents_by_ids",
lambda *_args, **_kwargs: [doc],
)
with app.test_request_context(
"/datasets/ds-1/documents/download-zip",
method="POST",
json={"document_ids": ["55555555-5555-5555-5555-555555555555"]},
):
api = datasets_document_module.DocumentBatchDownloadZipApi()
with pytest.raises(NotFound):
api.post(dataset_id="ds-1")
def test_document_download_returns_url_for_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure upload-file documents return a `{url}` JSON payload."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
# Build a request context then call the resource method directly.
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
result = api.get(dataset_id="ds-1", document_id="doc-1")
assert result == {"url": "https://example.com/signed"}
def test_document_download_rejects_non_upload_file_document(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure non-upload documents raise 404 (no file to download)."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="website_crawl",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_missing_upload_file_id(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure missing `upload_file_id` raises 404."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id=None,
upload_file_exists=False,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_when_upload_file_record_missing(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure missing UploadFile row raises 404."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-123",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=False,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(NotFound):
api.get(dataset_id="ds-1", document_id="doc-1")
def test_document_download_rejects_tenant_mismatch(
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure tenant mismatch is rejected by the shared `get_document()` permission check."""
_wire_common_success_mocks(
module=datasets_document_module,
monkeypatch=monkeypatch,
current_tenant_id="tenant-123",
document_tenant_id="tenant-999",
data_source_type="upload_file",
upload_file_id="file-123",
upload_file_exists=True,
signed_url="https://example.com/signed",
)
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
api = datasets_document_module.DocumentDownloadApi()
with pytest.raises(Forbidden):
api.get(dataset_id="ds-1", document_id="doc-1")

View File

@ -346,6 +346,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeRateLimitError",
"message": "Rate limit exceeded",
"args": {"description": "Rate limit exceeded"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -364,6 +365,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeAuthorizationError",
"message": "Invalid credentials",
"args": {"description": "Invalid credentials"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -382,6 +384,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeBadRequestError",
"message": "Invalid parameters",
"args": {"description": "Invalid parameters"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -400,6 +403,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeConnectionError",
"message": "Connection to external service failed",
"args": {"description": "Connection to external service failed"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})
@ -418,6 +422,7 @@ class TestPluginRuntimeErrorHandling:
mock_response.status_code = 200
invoke_error = {
"error_type": "InvokeServerUnavailableError",
"message": "Service temporarily unavailable",
"args": {"description": "Service temporarily unavailable"},
}
error_message = json.dumps({"error_type": "PluginInvokeError", "message": json.dumps(invoke_error)})

View File

@ -0,0 +1,99 @@
"""
Unit tests for `services.file_service.FileService` helpers.
We keep these tests focused on:
- ZIP tempfile building (sanitization + deduplication + content writes)
- tenant-scoped batch lookup behavior (`get_upload_files_by_ids`)
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from zipfile import ZipFile
import pytest
import services.file_service as file_service_module
from services.file_service import FileService
def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure ZIP entry names are safe and unique while preserving extensions."""
# Arrange: three upload files that all sanitize down to the same basename ("b.txt").
upload_files: list[Any] = [
SimpleNamespace(name="a/b.txt", key="k1"),
SimpleNamespace(name="c/b.txt", key="k2"),
SimpleNamespace(name="../b.txt", key="k3"),
]
# Stream distinct bytes per key so we can verify content is written to the right entry.
data_by_key: dict[str, list[bytes]] = {"k1": [b"one"], "k2": [b"two"], "k3": [b"three"]}
def _load(key: str, stream: bool = True) -> list[bytes]:
# Return the corresponding chunks for this key (the production code iterates chunks).
assert stream is True
return data_by_key[key]
monkeypatch.setattr(file_service_module.storage, "load", _load)
# Act: build zip in a tempfile.
with FileService.build_upload_files_zip_tempfile(upload_files=upload_files) as tmp:
with ZipFile(tmp, mode="r") as zf:
# Assert: names are sanitized (no directory components) and deduped with suffixes.
assert zf.namelist() == ["b.txt", "b (1).txt", "b (2).txt"]
# Assert: each entry contains the correct bytes from storage.
assert zf.read("b.txt") == b"one"
assert zf.read("b (1).txt") == b"two"
assert zf.read("b (2).txt") == b"three"
def test_get_upload_files_by_ids_returns_empty_when_no_ids(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure empty input returns an empty mapping without hitting the database."""
class _Session:
def scalars(self, _stmt): # type: ignore[no-untyped-def]
raise AssertionError("db.session.scalars should not be called for empty id lists")
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=_Session()))
assert FileService.get_upload_files_by_ids("tenant-1", []) == {}
def test_get_upload_files_by_ids_returns_id_keyed_mapping(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure batch lookup returns a dict keyed by stringified UploadFile ids."""
upload_files: list[Any] = [
SimpleNamespace(id="file-1", tenant_id="tenant-1"),
SimpleNamespace(id="file-2", tenant_id="tenant-1"),
]
class _ScalarResult:
def __init__(self, items: list[Any]) -> None:
self._items = items
def all(self) -> list[Any]:
return self._items
class _Session:
def __init__(self, items: list[Any]) -> None:
self._items = items
self.calls: list[object] = []
def scalars(self, stmt): # type: ignore[no-untyped-def]
# Capture the statement so we can at least assert the query path is taken.
self.calls.append(stmt)
return _ScalarResult(self._items)
session = _Session(upload_files)
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=session))
# Provide duplicates to ensure callers can safely pass repeated ids.
result = FileService.get_upload_files_by_ids("tenant-1", ["file-1", "file-1", "file-2"])
assert set(result.keys()) == {"file-1", "file-2"}
assert result["file-1"].id == "file-1"
assert result["file-2"].id == "file-2"
assert len(session.calls) == 1

View File

@ -1,4 +1,4 @@
import type { FC } from 'react'
import type { FC, MouseEvent } from 'react'
import type { Resources } from './index'
import Link from 'next/link'
import { Fragment, useState } from 'react'
@ -18,6 +18,8 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import ProgressTooltip from './progress-tooltip'
import Tooltip from './tooltip'
@ -36,6 +38,30 @@ const Popup: FC<PopupProps> = ({
? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '')
: 'notion'
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
/**
* Download the original uploaded file for citations whose data source is upload-file.
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
*/
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
// Prevent toggling the citation popup when user clicks the download link.
e.preventDefault()
e.stopPropagation()
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
const datasetId = data.sources?.[0]?.dataset_id
const documentId = data.documentId || data.sources?.[0]?.document_id
if (!isUploadFile || !datasetId || !documentId || isDownloading)
return
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
const res = await downloadDocument({ datasetId, documentId })
if (res?.url)
downloadUrl({ url: res.url, fileName: data.documentName })
}
return (
<PortalToFollowElem
open={open}
@ -49,6 +75,7 @@ const Popup: FC<PopupProps> = ({
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
{/* Keep the trigger purely for opening the popup (no download link here). */}
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
@ -57,7 +84,21 @@ const Popup: FC<PopupProps> = ({
<div className="px-4 pb-2 pt-3">
<div className="flex h-[18px] items-center">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="system-xs-medium truncate text-text-tertiary">{data.documentName}</div>
<div className="system-xs-medium truncate text-text-tertiary">
{/* If it's an upload-file reference, the title becomes a download link. */}
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
? (
<button
type="button"
className="cursor-pointer truncate text-text-tertiary hover:underline"
onClick={handleDownloadUploadFile}
disabled={isDownloading}
>
{data.documentName}
</button>
)
: data.documentName}
</div>
</div>
</div>
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">

View File

@ -0,0 +1,101 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import EconomicalRetrievalMethodConfig from './index'
// Mock dependencies
vi.mock('../../settings/option-card', () => ({
default: ({ children, title, description, disabled, id }: {
children?: React.ReactNode
title?: string
description?: React.ReactNode
disabled?: boolean
id?: string
}) => (
<div data-testid="option-card" data-title={title} data-id={id} data-disabled={disabled}>
<div>{description}</div>
{children}
</div>
),
}))
vi.mock('../retrieval-param-config', () => ({
default: ({ value, onChange, type }: {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
type?: string
}) => (
<div data-testid="retrieval-param-config" data-type={type}>
<button onClick={() => onChange({ ...value, newProp: 'changed' })}>
Change Value
</button>
</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
VectorSearch: () => <svg data-testid="vector-search-icon" />,
}))
describe('EconomicalRetrievalMethodConfig', () => {
const mockOnChange = vi.fn()
const defaultProps = {
value: {
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 2,
score_threshold_enabled: false,
score_threshold: 0.5,
},
onChange: mockOnChange,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render correctly', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
expect(screen.getByTestId('option-card')).toBeInTheDocument()
expect(screen.getByTestId('retrieval-param-config')).toBeInTheDocument()
// Check if title and description are rendered (mocked i18n returns key)
expect(screen.getByText('dataset.retrieval.keyword_search.description')).toBeInTheDocument()
})
it('should pass correct props to OptionCard', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} disabled={true} />)
const card = screen.getByTestId('option-card')
expect(card).toHaveAttribute('data-disabled', 'true')
expect(card).toHaveAttribute('data-id', RETRIEVE_METHOD.keywordSearch)
})
it('should pass correct props to RetrievalParamConfig', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
const config = screen.getByTestId('retrieval-param-config')
expect(config).toHaveAttribute('data-type', RETRIEVE_METHOD.keywordSearch)
})
it('should handle onChange events', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
fireEvent.click(screen.getByText('Change Value'))
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith({
...defaultProps.value,
newProp: 'changed',
})
})
it('should default disabled prop to false', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
const card = screen.getByTestId('option-card')
expect(card).toHaveAttribute('data-disabled', 'false')
})
})

View File

@ -0,0 +1,148 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../create/icons'
import RetrievalMethodInfo, { getIcon } from './index'
// Mock next/image
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
),
}))
// Mock RadioCard
vi.mock('@/app/components/base/radio-card', () => ({
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
<div data-testid="radio-card">
<div data-testid="card-title">{title}</div>
<div data-testid="card-description">{description}</div>
<div data-testid="card-icon">{icon}</div>
<div data-testid="chosen-config">{chosenConfig}</div>
</div>
),
}))
// Mock icons
vi.mock('../../create/icons', () => ({
retrievalIcon: {
vector: 'vector-icon.png',
fullText: 'fulltext-icon.png',
hybrid: 'hybrid-icon.png',
},
}))
describe('RetrievalMethodInfo', () => {
const defaultConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'test-provider',
reranking_model_name: 'test-model',
},
top_k: 5,
score_threshold_enabled: true,
score_threshold: 0.8,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render correctly with full config', () => {
render(<RetrievalMethodInfo value={defaultConfig} />)
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
// Check Title & Description (mocked i18n returns key prefixed with ns)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
// Check Icon
const icon = screen.getByTestId('method-icon')
expect(icon).toHaveAttribute('src', 'vector-icon.png')
// Check Config Details
expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
expect(screen.getByText('5')).toBeInTheDocument() // Top K
expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
})
it('should not render reranking model if missing', () => {
const configWithoutRerank = {
...defaultConfig,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}
render(<RetrievalMethodInfo value={configWithoutRerank} />)
expect(screen.queryByText('test-model')).not.toBeInTheDocument()
// Other fields should still be there
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should handle different retrieval methods', () => {
// Test Hybrid
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
unmount()
// Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
})
describe('getIcon utility', () => {
it('should return correct icon for each type', () => {
expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
expect(getIcon(RETRIEVE_METHOD.fullText)).toBe(retrievalIcon.fullText)
expect(getIcon(RETRIEVE_METHOD.hybrid)).toBe(retrievalIcon.hybrid)
expect(getIcon(RETRIEVE_METHOD.invertedIndex)).toBe(retrievalIcon.vector)
expect(getIcon(RETRIEVE_METHOD.keywordSearch)).toBe(retrievalIcon.vector)
})
it('should return default vector icon for unknown type', () => {
// Test fallback branch when type is not in the mapping
const unknownType = 'unknown_method' as RETRIEVE_METHOD
expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
})
})
it('should not render score threshold if disabled', () => {
const configWithoutScoreThreshold = {
...defaultConfig,
score_threshold_enabled: false,
score_threshold: 0,
}
render(<RetrievalMethodInfo value={configWithoutScoreThreshold} />)
// score_threshold is still rendered but may be undefined
expect(screen.queryByText('0.8')).not.toBeInTheDocument()
})
it('should render correctly with invertedIndex search method', () => {
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
// invertedIndex uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
it('should render correctly with keywordSearch search method', () => {
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
// keywordSearch uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
})

View File

@ -30,9 +30,10 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
import useTimestamp from '@/hooks/use-timestamp'
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatNumber } from '@/utils/format'
import BatchAction from '../detail/completed/common/batch-action'
import StatusItem from '../status-item'
@ -222,6 +223,7 @@ const DocumentList: FC<IDocumentListProps> = ({
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
const handleAction = (actionName: DocumentActionType) => {
return async () => {
@ -300,6 +302,39 @@ const DocumentList: FC<IDocumentListProps> = ({
return dataSourceType === DatasourceType.onlineDrive
}, [])
const downloadableSelectedIds = useMemo(() => {
const selectedSet = new Set(selectedIds)
return localDocs
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
.map(doc => doc.id)
}, [localDocs, selectedIds])
/**
* Generate a random ZIP filename for bulk document downloads.
* We intentionally avoid leaking dataset info in the exported archive name.
*/
const generateDocsZipFileName = useCallback((): string => {
// Prefer UUID for uniqueness; fall back to time+random when unavailable.
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
return `${randomPart}-docs.zip`
}, [])
const handleBatchDownload = useCallback(async () => {
if (isDownloadingZip)
return
// Download as a single ZIP to avoid browser caps on multiple automatic downloads.
const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }))
if (e || !blob) {
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
}, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t])
return (
<div className="relative mt-3 flex h-full w-full flex-col">
<div className="relative h-0 grow overflow-x-auto">
@ -463,6 +498,7 @@ const DocumentList: FC<IDocumentListProps> = ({
onArchive={handleAction(DocumentActionType.archive)}
onBatchEnable={handleAction(DocumentActionType.enable)}
onBatchDisable={handleAction(DocumentActionType.disable)}
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
onBatchDelete={handleAction(DocumentActionType.delete)}
onEditMetadata={showEditModal}
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}

View File

@ -1,8 +1,10 @@
import type { OperationName } from '../types'
import type { CommonResponse } from '@/models/common'
import type { DocumentDownloadResponse } from '@/service/datasets'
import {
RiArchive2Line,
RiDeleteBinLine,
RiDownload2Line,
RiEditLine,
RiEqualizer2Line,
RiLoopLeftLine,
@ -28,6 +30,7 @@ import {
useDocumentArchive,
useDocumentDelete,
useDocumentDisable,
useDocumentDownload,
useDocumentEnable,
useDocumentPause,
useDocumentResume,
@ -37,6 +40,7 @@ import {
} from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import s from '../style.module.css'
import RenameModal from './rename-modal'
@ -69,7 +73,7 @@ const Operations = ({
scene = 'list',
className = '',
}: OperationsProps) => {
const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const [showModal, setShowModal] = useState(false)
const [deleting, setDeleting] = useState(false)
const { notify } = useContext(ToastContext)
@ -80,6 +84,7 @@ const Operations = ({
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
const { mutateAsync: syncDocument } = useSyncDocument()
const { mutateAsync: syncWebsite } = useSyncWebsite()
const { mutateAsync: pauseDocument } = useDocumentPause()
@ -158,6 +163,24 @@ const Operations = ({
onUpdate()
}, [onUpdate])
const handleDownload = useCallback(async () => {
// Avoid repeated clicks while the signed URL request is in-flight.
if (isDownloading)
return
// Request a signed URL first (it points to `/files/<id>/file-preview?...&as_attachment=true`).
const [e, res] = await asyncRunSafe<DocumentDownloadResponse>(
downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>,
)
if (e || !res?.url) {
notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
downloadUrl({ url: res.url, fileName: name })
}, [datasetId, downloadDocument, id, isDownloading, name, notify, t])
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
{isListScene && !embeddingAvailable && (
@ -214,6 +237,20 @@ const Operations = ({
<RiEditLine className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</div>
{data_source_type === DataSourceType.FILE && (
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<div className={s.actionItem} onClick={() => onOperate('sync')}>
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
@ -223,6 +260,23 @@ const Operations = ({
<Divider className="my-1" />
</>
)}
{archived && data_source_type === DataSourceType.FILE && (
<>
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
<div className={s.actionItem} onClick={() => onOperate('pause')}>
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -14,6 +14,7 @@ type IBatchActionProps = {
selectedIds: string[]
onBatchEnable: () => void
onBatchDisable: () => void
onBatchDownload?: () => void
onBatchDelete: () => Promise<void>
onArchive?: () => void
onEditMetadata?: () => void
@ -26,6 +27,7 @@ const BatchAction: FC<IBatchActionProps> = ({
selectedIds,
onBatchEnable,
onBatchDisable,
onBatchDownload,
onArchive,
onBatchDelete,
onEditMetadata,
@ -103,6 +105,16 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t(`${i18nPrefix}.reIndex`, { ns: 'dataset' })}</span>
</Button>
)}
{onBatchDownload && (
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchDownload}
>
<RiDownload2Line className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.download`, { ns: 'dataset' })}</span>
</Button>
)}
<Button
variant="ghost"
destructive

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import EmbeddingSkeleton from './index'
// Mock Skeleton components
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-container">{children}</div>,
SkeletonPoint: () => <div data-testid="skeleton-point" />,
SkeletonRectangle: () => <div data-testid="skeleton-rectangle" />,
SkeletonRow: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-row">{children}</div>,
}))
// Mock Divider
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
describe('EmbeddingSkeleton', () => {
it('should render correct number of skeletons', () => {
render(<EmbeddingSkeleton />)
// It renders 5 CardSkeletons. Each CardSkelton has multiple SkeletonContainers.
// Let's count the number of main wrapper divs (loop is 5)
// Each iteration renders a CardSkeleton and potentially a Divider.
// The component structure is:
// div.relative...
// div.absolute... (mask)
// map(5) -> div.w-full.px-11 -> CardSkelton + Divider (except last?)
// Actually the code says `index !== 9`, but the loop is length 5.
// So `index` goes 0..4. All are !== 9. So 5 dividers should be rendered.
expect(screen.getAllByTestId('divider')).toHaveLength(5)
// Just ensure it renders without crashing and contains skeleton elements
expect(screen.getAllByTestId('skeleton-container').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0)
})
it('should render the mask overlay', () => {
const { container } = render(<EmbeddingSkeleton />)
// Check for the absolute positioned mask
const mask = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(mask).toBeInTheDocument()
})
})

View File

@ -0,0 +1,92 @@
import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
}
const Card = ({
apiEnabled,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi])
return (
<div className="w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="p-1">
<div className="p-2">
<div className="mb-1.5 flex justify-between">
<div className="flex items-center gap-1">
<Indicator
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
/>
<div
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
>
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('appMenus.apiAccessTip', { ns: 'common' })}
</div>
</div>
</div>
<div className="h-px bg-divider-subtle"></div>
<div className="p-1">
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 items-center space-x-[7px] rounded-lg px-2 text-text-tertiary hover:bg-state-base-hover"
>
<RiBookOpenLine className="size-3.5 shrink-0" />
<div className="system-sm-regular grow truncate">
{t('overview.apiInfo.doc', { ns: 'appOverview' })}
</div>
<RiArrowRightUpLine className="size-3.5 shrink-0" />
</Link>
</div>
</div>
)
}
export default React.memo(Card)

View File

@ -0,0 +1,65 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ApiAccessProps = {
expand: boolean
apiEnabled: boolean
}
const ApiAccess = ({
expand,
apiEnabled,
}: ApiAccessProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleToggle = () => {
setOpen(!open)
}
return (
<div className="p-3 pt-2">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default React.memo(ApiAccess)

View File

@ -1,8 +1,7 @@
import type { RelatedAppResponse } from '@/models/datasets'
import * as React from 'react'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
import ServiceApi from './service-api'
import ApiAccess from './api-access'
import Statistics from './statistics'
type IExtraInfoProps = {
@ -17,7 +16,6 @@ const ExtraInfo = ({
expand,
}: IExtraInfoProps) => {
const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<>
@ -28,9 +26,8 @@ const ExtraInfo = ({
relatedApps={relatedApps}
/>
)}
<ServiceApi
<ApiAccess
expand={expand}
apiBaseUrl={apiBaseInfo?.api_base_url ?? ''}
apiEnabled={apiEnabled ?? false}
/>
</>

View File

@ -6,45 +6,22 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import Switch from '@/app/components/base/switch'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
apiBaseUrl: string
}
const Card = ({
apiEnabled,
apiBaseUrl,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, disableDatasetServiceApi])
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)
}, [])
@ -68,24 +45,16 @@ const Card = ({
<div className="flex items-center gap-x-1">
<Indicator
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
className="system-xs-semibold-uppercase text-text-success"
>
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
{t('serviceApi.enabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="flex flex-col">
<div className="system-xs-regular leading-6 text-text-tertiary">

View File

@ -1,22 +1,17 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ServiceApiProps = {
expand: boolean
apiBaseUrl: string
apiEnabled: boolean
}
const ServiceApi = ({
expand,
apiBaseUrl,
apiEnabled,
}: ServiceApiProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@ -26,7 +21,7 @@ const ServiceApi = ({
}
return (
<div className="p-3 pt-2">
<div>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
@ -41,22 +36,21 @@ const ServiceApi = ({
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
apiBaseUrl={apiBaseUrl}
/>
</PortalToFollowElemContent>

View File

@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import DatasetFooter from './index'
describe('DatasetFooter', () => {
it('should render correctly', () => {
render(<DatasetFooter />)
// Check main title (mocked i18n returns ns:key or key)
// The code uses t('didYouKnow', { ns: 'dataset' })
// With default mock it likely returns 'dataset.didYouKnow'
expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
// Check paragraph content
expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
})
it('should have correct styling', () => {
const { container } = render(<DatasetFooter />)
const footer = container.querySelector('footer')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
const h3 = container.querySelector('h3')
expect(h3).toHaveClass('text-gradient')
})
})

View File

@ -14,13 +14,14 @@ import TagFilter from '@/app/components/base/tag-management/filter'
// Hooks
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { useAppContext } from '@/context/app-context'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
// Components
import ExternalAPIPanel from '../external-api/external-api-panel'
import ServiceApi from '../extra-info/service-api'
import DatasetFooter from './dataset-footer'
import Datasets from './datasets'
@ -58,6 +59,9 @@ const List = () => {
return router.replace('/apps')
}, [currentWorkspace, router])
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<div className="scroll-container relative flex grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex items-center justify-end gap-x-1 bg-background-body px-12 pb-2 pt-4">
@ -81,6 +85,11 @@ const List = () => {
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
{
isCurrentWorkspaceManager && (
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
)
}
<div className="h-4 w-[1px] bg-divider-regular" />
<Button
className="shadows-shadow-xs gap-0.5"
@ -96,7 +105,6 @@ const List = () => {
{showTagManagementModal && (
<TagManagementModal type="knowledge" show={showTagManagementModal} />
)}
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
</div>
)

View File

@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react'
import NewDatasetCard from './index'
type MockOptionProps = {
text: string
href: string
}
// Mock dependencies
vi.mock('./option', () => ({
default: ({ text, href }: MockOptionProps) => (
<a data-testid="option-link" href={href}>
{text}
</a>
),
}))
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <svg data-testid="icon-add" />,
RiFunctionAddLine: () => <svg data-testid="icon-function" />,
}))
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
ApiConnectionMod: () => <svg data-testid="icon-api" />,
}))
describe('NewDatasetCard', () => {
it('should render all options', () => {
render(<NewDatasetCard />)
const options = screen.getAllByTestId('option-link')
expect(options).toHaveLength(3)
// Check first option (Create Dataset)
const createDataset = options[0]
expect(createDataset).toHaveAttribute('href', '/datasets/create')
expect(createDataset).toHaveTextContent('dataset.createDataset')
// Check second option (Create from Pipeline)
const createFromPipeline = options[1]
expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
// Check third option (Connect Dataset)
const connectDataset = options[2]
expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
expect(connectDataset).toHaveTextContent('dataset.connectDataset')
})
})

View File

@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/react'
import { ChunkingMode } from '@/models/datasets'
import ChunkStructure from './index'
type MockOptionCardProps = {
id: string
title: string
isActive?: boolean
disabled?: boolean
}
// Mock dependencies
vi.mock('../option-card', () => ({
default: ({ id, title, isActive, disabled }: MockOptionCardProps) => (
<div
data-testid="option-card"
data-id={id}
data-active={isActive}
data-disabled={disabled}
>
{title}
</div>
),
}))
// Mock hook
vi.mock('./hooks', () => ({
useChunkStructure: () => ({
options: [
{
id: ChunkingMode.text,
title: 'General',
description: 'General description',
icon: <svg />,
effectColor: 'indigo',
iconActiveColor: 'indigo',
},
{
id: ChunkingMode.parentChild,
title: 'Parent-Child',
description: 'PC description',
icon: <svg />,
effectColor: 'blue',
iconActiveColor: 'blue',
},
],
}),
}))
describe('ChunkStructure', () => {
it('should render all options', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
expect(options).toHaveLength(2)
expect(options[0]).toHaveTextContent('General')
expect(options[1]).toHaveTextContent('Parent-Child')
})
it('should set active state correctly', () => {
// Render with 'text' active
const { unmount } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
expect(options[0]).toHaveAttribute('data-active', 'true')
expect(options[1]).toHaveAttribute('data-active', 'false')
unmount()
// Render with 'parentChild' active
render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const newOptions = screen.getAllByTestId('option-card')
expect(newOptions[0]).toHaveAttribute('data-active', 'false')
expect(newOptions[1]).toHaveAttribute('data-active', 'true')
})
it('should be always disabled', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
options.forEach((option) => {
expect(option).toHaveAttribute('data-disabled', 'true')
})
})
})

View File

@ -0,0 +1,130 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ActionList from './action-list'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.action || 'actions'}`
return key
},
}),
}))
const mockToolData = [
{ name: 'tool-1', label: { en_US: 'Tool 1' } },
{ name: 'tool-2', label: { en_US: 'Tool 2' } },
]
const mockProvider = {
name: 'test-plugin/test-tool',
type: 'builtin',
}
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({ data: [mockProvider] }),
useBuiltinTools: (key: string) => ({
data: key ? mockToolData : undefined,
}),
}))
vi.mock('@/app/components/tools/provider/tool-item', () => ({
default: ({ tool }: { tool: { name: string } }) => (
<div data-testid="tool-item">{tool.name}</div>
),
}))
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
tool: {
identity: {
author: 'test-author',
name: 'test-tool',
description: { en_US: 'Test' },
icon: 'icon.png',
label: { en_US: 'Test Tool' },
tags: [],
},
credentials_schema: [],
},
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('ActionList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render tool items when data is available', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
expect(screen.getByText('2 actions')).toBeInTheDocument()
expect(screen.getAllByTestId('tool-item')).toHaveLength(2)
})
it('should render tool names', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
expect(screen.getByText('tool-1')).toBeInTheDocument()
expect(screen.getByText('tool-2')).toBeInTheDocument()
})
it('should return null when no tool declaration', () => {
const detail = createPluginDetail({
declaration: {} as PluginDetail['declaration'],
})
const { container } = render(<ActionList detail={detail} />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when providerKey is empty', () => {
const detail = createPluginDetail({
declaration: {
tool: {
identity: undefined,
},
} as unknown as PluginDetail['declaration'],
})
const { container } = render(<ActionList detail={detail} />)
expect(container).toBeEmptyDOMElement()
})
})
describe('Props', () => {
it('should use plugin_id in provider key construction', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
// The provider key is constructed from plugin_id and tool identity name
// When they match the mock, it renders
expect(screen.getByText('2 actions')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,131 @@
import type { PluginDetail, StrategyDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AgentStrategyList from './agent-strategy-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.strategy || 'strategies'}`
return key
},
}),
}))
const mockStrategies = [
{
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy 1' },
provider: 'provider-1',
},
parameters: [],
description: { en_US: 'Strategy 1 desc' },
output_schema: {},
features: [],
},
] as unknown as StrategyDetail[]
let mockStrategyProviderDetail: { declaration: { identity: unknown, strategies: StrategyDetail[] } } | undefined
vi.mock('@/service/use-strategy', () => ({
useStrategyProviderDetail: () => ({
data: mockStrategyProviderDetail,
}),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/strategy-item', () => ({
default: ({ detail }: { detail: StrategyDetail }) => (
<div data-testid="strategy-item">{detail.identity.name}</div>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
agent_strategy: {
identity: {
author: 'test-author',
name: 'test-strategy',
label: { en_US: 'Test Strategy' },
description: { en_US: 'Test' },
icon: 'icon.png',
tags: [],
},
},
} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('AgentStrategyList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStrategyProviderDetail = {
declaration: {
identity: { author: 'test', name: 'test' },
strategies: mockStrategies,
},
}
})
describe('Rendering', () => {
it('should render strategy items when data is available', () => {
render(<AgentStrategyList detail={createPluginDetail()} />)
expect(screen.getByText('1 strategy')).toBeInTheDocument()
expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
})
it('should return null when no strategy provider detail', () => {
mockStrategyProviderDetail = undefined
const { container } = render(<AgentStrategyList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should render multiple strategies', () => {
mockStrategyProviderDetail = {
declaration: {
identity: { author: 'test', name: 'test' },
strategies: [
...mockStrategies,
{ ...mockStrategies[0], identity: { ...mockStrategies[0].identity, name: 'strategy-2' } },
],
},
}
render(<AgentStrategyList detail={createPluginDetail()} />)
expect(screen.getByText('2 strategies')).toBeInTheDocument()
expect(screen.getAllByTestId('strategy-item')).toHaveLength(2)
})
})
describe('Props', () => {
it('should pass tenant_id to provider detail', () => {
const detail = createPluginDetail()
detail.tenant_id = 'custom-tenant'
render(<AgentStrategyList detail={detail} />)
expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,104 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DatasourceActionList from './datasource-action-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.action || 'actions'}`
return key
},
}),
}))
const mockDataSourceList = [
{ plugin_id: 'test-plugin', name: 'Data Source 1' },
]
let mockDataSourceListData: typeof mockDataSourceList | undefined
vi.mock('@/service/use-pipeline', () => ({
useDataSourceList: () => ({ data: mockDataSourceListData }),
}))
vi.mock('@/app/components/workflow/block-selector/utils', () => ({
transformDataSourceToTool: (ds: unknown) => ds,
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
datasource: {
identity: {
author: 'test-author',
name: 'test-datasource',
description: { en_US: 'Test' },
icon: 'icon.png',
label: { en_US: 'Test Datasource' },
tags: [],
},
credentials_schema: [],
},
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('DatasourceActionList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataSourceListData = mockDataSourceList
})
describe('Rendering', () => {
it('should render action count when data and provider exist', () => {
render(<DatasourceActionList detail={createPluginDetail()} />)
// The component always shows "0 action" because data is hardcoded as empty array
expect(screen.getByText('0 action')).toBeInTheDocument()
})
it('should return null when no provider found', () => {
mockDataSourceListData = []
const { container } = render(<DatasourceActionList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when dataSourceList is undefined', () => {
mockDataSourceListData = undefined
const { container } = render(<DatasourceActionList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
})
describe('Props', () => {
it('should use plugin_id to find matching datasource', () => {
const detail = createPluginDetail()
detail.plugin_id = 'different-plugin'
mockDataSourceListData = [{ plugin_id: 'different-plugin', name: 'Different DS' }]
render(<DatasourceActionList detail={detail} />)
expect(screen.getByText('0 action')).toBeInTheDocument()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,386 @@
import type { EndpointListItem, PluginDetail } from '../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointCard from './endpoint-card'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
const mockHandleChange = vi.fn()
const mockEnableEndpoint = vi.fn()
const mockDisableEndpoint = vi.fn()
const mockDeleteEndpoint = vi.fn()
const mockUpdateEndpoint = vi.fn()
// Flags to control whether operations should fail
const failureFlags = {
enable: false,
disable: false,
delete: false,
update: false,
}
vi.mock('@/service/use-endpoints', () => ({
useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockEnableEndpoint(id)
if (failureFlags.enable)
onError()
else
onSuccess()
},
}),
useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockDisableEndpoint(id)
if (failureFlags.disable)
onError()
else
onSuccess()
},
}),
useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockDeleteEndpoint(id)
if (failureFlags.delete)
onError()
else
onSuccess()
},
}),
useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (data: unknown) => {
mockUpdateEndpoint(data)
if (failureFlags.update)
onError()
else
onSuccess()
},
}),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <span data-testid="indicator" data-color={color} />,
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
addDefaultValue: (value: unknown) => value,
}))
vi.mock('./endpoint-modal', () => ({
default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'Updated' })}>Save</button>
</div>
),
}))
const mockEndpointData: EndpointListItem = {
id: 'ep-1',
name: 'Test Endpoint',
url: 'https://api.example.com',
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-02',
settings: {},
tenant_id: 'tenant-1',
plugin_id: 'plugin-1',
expired_at: '',
hook_id: 'hook-1',
declaration: {
settings: [],
endpoints: [
{ path: '/api/test', method: 'GET' },
{ path: '/api/hidden', method: 'POST', hidden: true },
],
},
}
const mockPluginDetail: PluginDetail = {
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
}
describe('EndpointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset failure flags
failureFlags.enable = false
failureFlags.disable = false
failureFlags.delete = false
failureFlags.update = false
// Mock Toast.notify to prevent toast elements from accumulating in DOM
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render endpoint name', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
it('should render visible endpoints only', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('GET')).toBeInTheDocument()
expect(screen.getByText('https://api.example.com/api/test')).toBeInTheDocument()
expect(screen.queryByText('POST')).not.toBeInTheDocument()
})
it('should show active status when enabled', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('detailPanel.serviceOk')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
})
it('should show disabled status when not enabled', () => {
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
expect(screen.getByText('detailPanel.disabled')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'gray')
})
})
describe('User Interactions', () => {
it('should show disable confirm when switching off', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
})
it('should call disableEndpoint when confirm disable', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
// Click confirm button in the Confirm dialog
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1')
})
it('should show delete confirm when delete clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find delete button by its destructive class
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
})
it('should call deleteEndpoint when confirm delete', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1')
})
it('should show edit modal when edit clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
it('should call updateEndpoint when save in modal', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
})
})
describe('Copy Functionality', () => {
it('should reset copy state after timeout', async () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find copy button by its class
const allButtons = screen.getAllByRole('button')
const copyButton = allButtons.find(btn => btn.classList.contains('ml-2'))
expect(copyButton).toBeDefined()
if (copyButton) {
fireEvent.click(copyButton)
act(() => {
vi.advanceTimersByTime(2000)
})
// After timeout, the component should still be rendered correctly
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
}
})
})
describe('Edge Cases', () => {
it('should handle empty endpoints', () => {
const dataWithNoEndpoints = {
...mockEndpointData,
declaration: { settings: [], endpoints: [] },
}
render(<EndpointCard pluginDetail={mockPluginDetail} data={dataWithNoEndpoints} handleChange={mockHandleChange} />)
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
it('should call handleChange after enable', () => {
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockHandleChange).toHaveBeenCalled()
})
it('should hide disable confirm and revert state when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
// Confirm should be hidden
expect(screen.queryByText('detailPanel.endpointDisableTip')).not.toBeInTheDocument()
})
it('should hide delete confirm when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
expect(screen.queryByText('detailPanel.endpointDeleteTip')).not.toBeInTheDocument()
})
it('should hide edit modal when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
})
})
describe('Error Handling', () => {
it('should show error toast when enable fails', () => {
failureFlags.enable = true
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockEnableEndpoint).toHaveBeenCalled()
})
it('should show error toast when disable fails', () => {
failureFlags.disable = true
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDisableEndpoint).toHaveBeenCalled()
})
it('should show error toast when delete fails', () => {
failureFlags.delete = true
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalled()
})
it('should show error toast when update fails', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
expect(editButton).toBeDefined()
if (editButton)
fireEvent.click(editButton)
// Verify modal is open
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
// Set failure flag before save is clicked
failureFlags.update = true
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
// On error, handleChange is not called
expect(mockHandleChange).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,222 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EndpointList from './endpoint-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
const mockEndpoints = [
{ id: 'ep-1', name: 'Endpoint 1', url: 'https://api.example.com', declaration: { settings: [], endpoints: [] } },
]
let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined
const mockInvalidateEndpointList = vi.fn()
const mockCreateEndpoint = vi.fn()
vi.mock('@/service/use-endpoints', () => ({
useEndpointList: () => ({ data: mockEndpointListData }),
useInvalidateEndpointList: () => mockInvalidateEndpointList,
useCreateEndpoint: ({ onSuccess }: { onSuccess: () => void }) => ({
mutate: (data: unknown) => {
mockCreateEndpoint(data)
onSuccess()
},
}),
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
}))
vi.mock('./endpoint-card', () => ({
default: ({ data }: { data: { name: string } }) => (
<div data-testid="endpoint-card">{data.name}</div>
),
}))
vi.mock('./endpoint-modal', () => ({
default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'New Endpoint' })}>Save</button>
</div>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
endpoint: { settings: [], endpoints: [] },
tool: undefined,
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('EndpointList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEndpointListData = { endpoints: mockEndpoints }
})
describe('Rendering', () => {
it('should render endpoint list', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
})
it('should render endpoint cards', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByTestId('endpoint-card')).toBeInTheDocument()
expect(screen.getByText('Endpoint 1')).toBeInTheDocument()
})
it('should return null when no data', () => {
mockEndpointListData = undefined
const { container } = render(<EndpointList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should show empty message when no endpoints', () => {
mockEndpointListData = { endpoints: [] }
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('detailPanel.endpointsEmpty')).toBeInTheDocument()
})
it('should render add button', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
})
})
describe('User Interactions', () => {
it('should show modal when add button clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
it('should hide modal when cancel clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
})
it('should call createEndpoint when save clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalled()
})
})
describe('Border Style', () => {
it('should render with border style based on tool existence', () => {
const detail = createPluginDetail()
detail.declaration.tool = {} as PluginDetail['declaration']['tool']
render(<EndpointList detail={detail} />)
// Verify the component renders correctly
expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
})
})
describe('Multiple Endpoints', () => {
it('should render multiple endpoint cards', () => {
mockEndpointListData = {
endpoints: [
{ id: 'ep-1', name: 'Endpoint 1', url: 'https://api1.example.com', declaration: { settings: [], endpoints: [] } },
{ id: 'ep-2', name: 'Endpoint 2', url: 'https://api2.example.com', declaration: { settings: [], endpoints: [] } },
],
}
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getAllByTestId('endpoint-card')).toHaveLength(2)
})
})
describe('Tooltip', () => {
it('should render with tooltip content', () => {
render(<EndpointList detail={createPluginDetail()} />)
// Tooltip is rendered - the add button should be visible
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
})
})
describe('Create Endpoint Flow', () => {
it('should invalidate endpoint list after successful create', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
})
it('should pass correct params to createEndpoint', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalledWith({
pluginUniqueID: 'test-uid',
state: { name: 'New Endpoint' },
})
})
})
})

View File

@ -0,0 +1,519 @@
import type { FormSchema } from '../../base/form/types'
import type { PluginDetail } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointModal from './endpoint-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (opts?.field)
return `${key}: ${opts.field}`
return key
},
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string> | string) =>
typeof obj === 'string' ? obj : obj?.en_US || '',
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
default: ({ value, onChange, fieldMoreInfo }: {
value: Record<string, unknown>
onChange: (v: Record<string, unknown>) => void
fieldMoreInfo?: (item: { url?: string }) => React.ReactNode
}) => {
return (
<div data-testid="form">
<input
data-testid="form-input"
value={value.name as string || ''}
onChange={e => onChange({ ...value, name: e.target.value })}
/>
{/* Render fieldMoreInfo to test url link */}
{fieldMoreInfo && (
<div data-testid="field-more-info">
{fieldMoreInfo({ url: 'https://example.com' })}
{fieldMoreInfo({})}
</div>
)}
</div>
)
},
}))
vi.mock('../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
const mockFormSchemas = [
{ name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' },
{ name: 'apiKey', label: { en_US: 'API Key' }, type: 'secret-input', required: false, default: '' },
] as unknown as FormSchema[]
const mockPluginDetail: PluginDetail = {
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
}
describe('EndpointModal', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
let mockToastNotify: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Rendering', () => {
it('should render drawer', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render title and description', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByText('detailPanel.endpointModalTitle')).toBeInTheDocument()
expect(screen.getByText('detailPanel.endpointModalDesc')).toBeInTheDocument()
})
it('should render form with fieldMoreInfo url link', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('field-more-info')).toBeInTheDocument()
// Should render the "howToGet" link when url exists
expect(screen.getByText('howToGet')).toBeInTheDocument()
})
it('should render readme entrance', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCancel when cancel clicked', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button clicked', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
// Find the close button (ActionButton with RiCloseLine icon)
const allButtons = screen.getAllByRole('button')
const closeButton = allButtons.find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should update form value when input changes', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
const input = screen.getByTestId('form-input')
fireEvent.change(input, { target: { value: 'Test Name' } })
expect(input).toHaveValue('Test Name')
})
})
describe('Default Values', () => {
it('should use defaultValues when provided', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Default Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form-input')).toHaveValue('Default Name')
})
it('should extract default values from schemas when no defaultValues', () => {
const schemasWithDefaults = [
{ name: 'name', label: 'Name', type: 'text-input', required: true, default: 'Schema Default' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithDefaults}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form-input')).toHaveValue('Schema Default')
})
it('should handle schemas without default values', () => {
const schemasNoDefault = [
{ name: 'name', label: 'Name', type: 'text-input', required: false },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasNoDefault}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form')).toBeInTheDocument()
})
})
describe('Validation - handleSave', () => {
it('should show toast error when required field is empty', () => {
const schemasWithRequired = [
{ name: 'name', label: { en_US: 'Name Field' }, type: 'text-input', required: true, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithRequired}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('errorMsg.fieldRequired'),
})
expect(mockOnSaved).not.toHaveBeenCalled()
})
it('should show toast error with string label when required field is empty', () => {
const schemasWithStringLabel = [
{ name: 'name', label: 'String Label', type: 'text-input', required: true, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithStringLabel}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('String Label'),
})
})
it('should call onSaved when all required fields are filled', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Valid Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' })
})
it('should not validate non-required empty fields', () => {
const schemasOptional = [
{ name: 'optional', label: 'Optional', type: 'text-input', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasOptional}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).not.toHaveBeenCalled()
expect(mockOnSaved).toHaveBeenCalled()
})
})
describe('Boolean Field Processing', () => {
it('should convert string "true" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'true' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "1" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: '1' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "True" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'True' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "false" to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'false' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should convert number 1 to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 1 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert number 0 to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 0 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should preserve boolean true value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: true }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should preserve boolean false value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: false }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should not process non-boolean fields', () => {
const schemasWithText = [
{ name: 'text', label: 'Text', type: 'text-input', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithText}
defaultValues={{ text: 'hello' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' })
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(EndpointModal).toBeDefined()
expect((EndpointModal as { $$typeof?: symbol }).$$typeof).toBeDefined()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,103 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ModelList from './model-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} models`
return key
},
}),
}))
const mockModels = [
{ model: 'gpt-4', provider: 'openai' },
{ model: 'gpt-3.5', provider: 'openai' },
]
let mockModelListResponse: { data: typeof mockModels } | undefined
vi.mock('@/service/use-models', () => ({
useModelProviderModelList: () => ({
data: mockModelListResponse,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => (
<span data-testid="model-icon">{modelName}</span>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => (
<span data-testid="model-name">{modelItem.model}</span>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
model: { provider: 'openai' },
} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('ModelList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelListResponse = { data: mockModels }
})
describe('Rendering', () => {
it('should render model list when data is available', () => {
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getByText('2 models')).toBeInTheDocument()
})
it('should render model icons and names', () => {
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getAllByTestId('model-icon')).toHaveLength(2)
expect(screen.getAllByTestId('model-name')).toHaveLength(2)
// Both icon and name show the model name, so use getAllByText
expect(screen.getAllByText('gpt-4')).toHaveLength(2)
expect(screen.getAllByText('gpt-3.5')).toHaveLength(2)
})
it('should return null when no data', () => {
mockModelListResponse = undefined
const { container } = render(<ModelList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should handle empty model list', () => {
mockModelListResponse = { data: [] }
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getByText('0 models')).toBeInTheDocument()
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,215 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../types'
import OperationDropdown from './operation-dropdown'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: <T,>(selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T =>
selector({ systemFeatures: { enable_marketplace: true } }),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
<button data-testid="action-button" className={className} onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
describe('OperationDropdown', () => {
const mockOnInfo = vi.fn()
const mockOnCheckVersion = vi.fn()
const mockOnRemove = vi.fn()
const defaultProps = {
source: PluginSource.github,
detailUrl: 'https://github.com/test/repo',
onInfo: mockOnInfo,
onCheckVersion: mockOnCheckVersion,
onRemove: mockOnRemove,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render trigger button', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByTestId('action-button')).toBeInTheDocument()
})
it('should render dropdown content', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should render info option for github source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.info')).toBeInTheDocument()
})
it('should render check update option for github source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.checkUpdate')).toBeInTheDocument()
})
it('should render view detail option for github source with marketplace enabled', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
})
it('should render view detail option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
})
it('should always render remove option', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
})
it('should not render info option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.queryByText('detailPanel.operation.info')).not.toBeInTheDocument()
})
it('should not render check update option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.queryByText('detailPanel.operation.checkUpdate')).not.toBeInTheDocument()
})
it('should not render view detail for local source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.local} />)
expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
})
it('should not render view detail for debugging source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.debugging} />)
expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should toggle dropdown when trigger is clicked', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// The portal-elem should reflect the open state
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should call onInfo when info option is clicked', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
fireEvent.click(screen.getByText('detailPanel.operation.info'))
expect(mockOnInfo).toHaveBeenCalledTimes(1)
})
it('should call onCheckVersion when check update option is clicked', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
fireEvent.click(screen.getByText('detailPanel.operation.checkUpdate'))
expect(mockOnCheckVersion).toHaveBeenCalledTimes(1)
})
it('should call onRemove when remove option is clicked', () => {
render(<OperationDropdown {...defaultProps} />)
fireEvent.click(screen.getByText('detailPanel.operation.remove'))
expect(mockOnRemove).toHaveBeenCalledTimes(1)
})
it('should have correct href for view detail link', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
expect(link).toHaveAttribute('href', 'https://github.com/test/repo')
expect(link).toHaveAttribute('target', '_blank')
})
})
describe('Props Variations', () => {
it('should handle all plugin sources', () => {
const sources = [
PluginSource.github,
PluginSource.marketplace,
PluginSource.local,
PluginSource.debugging,
]
sources.forEach((source) => {
const { unmount } = render(
<OperationDropdown {...defaultProps} source={source} />,
)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
unmount()
})
})
it('should handle different detail URLs', () => {
const urls = [
'https://github.com/owner/repo',
'https://marketplace.example.com/plugin/123',
]
urls.forEach((url) => {
const { unmount } = render(
<OperationDropdown {...defaultProps} detailUrl={url} source={PluginSource.github} />,
)
const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
expect(link).toHaveAttribute('href', url)
unmount()
})
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Verify the component is exported as a memo component
expect(OperationDropdown).toBeDefined()
// React.memo wraps the component, so it should have $$typeof
expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined()
})
})
})

View File

@ -0,0 +1,461 @@
import type { SimpleDetail } from './store'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { usePluginStore } from './store'
// Factory function to create mock SimpleDetail
const createSimpleDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({
plugin_id: 'test-plugin-id',
name: 'Test Plugin',
plugin_unique_identifier: 'test-plugin-uid',
id: 'test-id',
provider: 'test-provider',
declaration: {
category: 'tool' as SimpleDetail['declaration']['category'],
name: 'test-declaration',
},
...overrides,
})
describe('usePluginStore', () => {
beforeEach(() => {
// Reset store state before each test
const { result } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(undefined)
})
})
describe('Initial State', () => {
it('should have undefined detail initially', () => {
const { result } = renderHook(() => usePluginStore())
expect(result.current.detail).toBeUndefined()
})
it('should provide setDetail function', () => {
const { result } = renderHook(() => usePluginStore())
expect(typeof result.current.setDetail).toBe('function')
})
})
describe('setDetail', () => {
it('should set detail with valid SimpleDetail', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toEqual(detail)
})
it('should set detail to undefined', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
// First set a value
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toEqual(detail)
// Then clear it
act(() => {
result.current.setDetail(undefined)
})
expect(result.current.detail).toBeUndefined()
})
it('should update detail when called multiple times', () => {
const { result } = renderHook(() => usePluginStore())
const detail1 = createSimpleDetail({ plugin_id: 'plugin-1' })
const detail2 = createSimpleDetail({ plugin_id: 'plugin-2' })
act(() => {
result.current.setDetail(detail1)
})
expect(result.current.detail?.plugin_id).toBe('plugin-1')
act(() => {
result.current.setDetail(detail2)
})
expect(result.current.detail?.plugin_id).toBe('plugin-2')
})
it('should handle detail with trigger declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: null,
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger).toEqual({
subscription_schema: [],
subscription_constructor: null,
})
})
it('should handle detail with partial declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
name: 'partial-plugin',
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.name).toBe('partial-plugin')
})
})
describe('Store Sharing', () => {
it('should share state across multiple hook instances', () => {
const { result: result1 } = renderHook(() => usePluginStore())
const { result: result2 } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result1.current.setDetail(detail)
})
// Both hooks should see the same state
expect(result1.current.detail).toEqual(detail)
expect(result2.current.detail).toEqual(detail)
})
it('should update all hook instances when state changes', () => {
const { result: result1 } = renderHook(() => usePluginStore())
const { result: result2 } = renderHook(() => usePluginStore())
const detail1 = createSimpleDetail({ name: 'Plugin One' })
const detail2 = createSimpleDetail({ name: 'Plugin Two' })
act(() => {
result1.current.setDetail(detail1)
})
expect(result1.current.detail?.name).toBe('Plugin One')
expect(result2.current.detail?.name).toBe('Plugin One')
act(() => {
result2.current.setDetail(detail2)
})
expect(result1.current.detail?.name).toBe('Plugin Two')
expect(result2.current.detail?.name).toBe('Plugin Two')
})
})
describe('Selector Pattern', () => {
// Extract selectors to reduce nesting depth
const selectDetail = (state: ReturnType<typeof usePluginStore.getState>) => state.detail
const selectSetDetail = (state: ReturnType<typeof usePluginStore.getState>) => state.setDetail
it('should support selector to get specific field', () => {
const { result: setterResult } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({ plugin_id: 'selected-plugin' })
act(() => {
setterResult.current.setDetail(detail)
})
// Use selector to get only detail
const { result: selectorResult } = renderHook(() => usePluginStore(selectDetail))
expect(selectorResult.current?.plugin_id).toBe('selected-plugin')
})
it('should support selector to get setDetail function', () => {
const { result } = renderHook(() => usePluginStore(selectSetDetail))
expect(typeof result.current).toBe('function')
})
})
describe('Edge Cases', () => {
it('should handle empty string values in detail', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
plugin_id: '',
name: '',
plugin_unique_identifier: '',
provider: '',
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.plugin_id).toBe('')
expect(result.current.detail?.name).toBe('')
})
it('should handle detail with empty declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration).toEqual({})
})
it('should handle rapid state updates', () => {
const { result } = renderHook(() => usePluginStore())
act(() => {
for (let i = 0; i < 10; i++)
result.current.setDetail(createSimpleDetail({ plugin_id: `plugin-${i}` }))
})
expect(result.current.detail?.plugin_id).toBe('plugin-9')
})
it('should handle setDetail called without arguments', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toBeDefined()
act(() => {
result.current.setDetail()
})
expect(result.current.detail).toBeUndefined()
})
})
describe('Type Safety', () => {
it('should preserve all SimpleDetail fields correctly', () => {
const { result } = renderHook(() => usePluginStore())
const detail: SimpleDetail = {
plugin_id: 'type-test-id',
name: 'Type Test Plugin',
plugin_unique_identifier: 'type-test-uid',
id: 'type-id',
provider: 'type-provider',
declaration: {
category: 'model' as SimpleDetail['declaration']['category'],
name: 'type-declaration',
version: '2.0.0',
author: 'test-author',
},
}
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toStrictEqual(detail)
expect(result.current.detail?.plugin_id).toBe('type-test-id')
expect(result.current.detail?.name).toBe('Type Test Plugin')
expect(result.current.detail?.plugin_unique_identifier).toBe('type-test-uid')
expect(result.current.detail?.id).toBe('type-id')
expect(result.current.detail?.provider).toBe('type-provider')
})
it('should handle declaration with subscription_constructor', () => {
const { result } = renderHook(() => usePluginStore())
const mockConstructor = {
credentials_schema: [],
oauth_schema: {
client_schema: [],
credentials_schema: [],
},
parameters: [],
}
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: mockConstructor as unknown as NonNullable<SimpleDetail['declaration']['trigger']>['subscription_constructor'],
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger?.subscription_constructor).toBeDefined()
})
it('should handle declaration with subscription_schema', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: null,
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger?.subscription_schema).toEqual([])
})
})
describe('State Persistence', () => {
it('should maintain state after multiple renders', () => {
const detail = createSimpleDetail({ name: 'Persistent Plugin' })
const { result, rerender } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(detail)
})
// Rerender multiple times
rerender()
rerender()
rerender()
expect(result.current.detail?.name).toBe('Persistent Plugin')
})
it('should maintain reference equality for unchanged state', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
const firstDetailRef = result.current.detail
// Get state again without changing
const { result: result2 } = renderHook(() => usePluginStore())
expect(result2.current.detail).toBe(firstDetailRef)
})
})
describe('Concurrent Updates', () => {
it('should handle updates from multiple sources correctly', () => {
const { result: hook1 } = renderHook(() => usePluginStore())
const { result: hook2 } = renderHook(() => usePluginStore())
const { result: hook3 } = renderHook(() => usePluginStore())
act(() => {
hook1.current.setDetail(createSimpleDetail({ name: 'From Hook 1' }))
})
act(() => {
hook2.current.setDetail(createSimpleDetail({ name: 'From Hook 2' }))
})
act(() => {
hook3.current.setDetail(createSimpleDetail({ name: 'From Hook 3' }))
})
// All hooks should reflect the last update
expect(hook1.current.detail?.name).toBe('From Hook 3')
expect(hook2.current.detail?.name).toBe('From Hook 3')
expect(hook3.current.detail?.name).toBe('From Hook 3')
})
it('should handle interleaved read and write operations', () => {
const { result } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-1' }))
})
expect(result.current.detail?.plugin_id).toBe('step-1')
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-2' }))
})
expect(result.current.detail?.plugin_id).toBe('step-2')
act(() => {
result.current.setDetail(undefined)
})
expect(result.current.detail).toBeUndefined()
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-3' }))
})
expect(result.current.detail?.plugin_id).toBe('step-3')
})
})
describe('Declaration Variations', () => {
it('should handle declaration with all optional fields', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
category: 'extension' as SimpleDetail['declaration']['category'],
name: 'full-declaration',
version: '1.0.0',
author: 'full-author',
icon: 'icon.png',
verified: true,
tags: ['tag1', 'tag2'],
},
})
act(() => {
result.current.setDetail(detail)
})
const decl = result.current.detail?.declaration
expect(decl?.category).toBe('extension')
expect(decl?.name).toBe('full-declaration')
expect(decl?.version).toBe('1.0.0')
expect(decl?.author).toBe('full-author')
expect(decl?.icon).toBe('icon.png')
expect(decl?.verified).toBe(true)
expect(decl?.tags).toEqual(['tag1', 'tag2'])
})
it('should handle declaration with nested tool object', () => {
const { result } = renderHook(() => usePluginStore())
const mockTool = {
identity: {
author: 'tool-author',
name: 'tool-name',
icon: 'tool-icon.png',
tags: ['api', 'utility'],
},
credentials_schema: [],
}
const detail = createSimpleDetail({
declaration: {
tool: mockTool as unknown as SimpleDetail['declaration']['tool'],
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.tool?.identity.name).toBe('tool-name')
expect(result.current.detail?.declaration.tool?.identity.tags).toEqual(['api', 'utility'])
})
})
})

View File

@ -0,0 +1,203 @@
import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StrategyDetail from './strategy-detail'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
type ProviderType = Parameters<typeof StrategyDetail>[0]['provider']
const mockProvider = {
author: 'test-author',
name: 'test-provider',
description: { en_US: 'Provider desc' },
tenant_id: 'tenant-1',
icon: 'icon.png',
label: { en_US: 'Test Provider' },
tags: [],
} as unknown as ProviderType
const mockDetail = {
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy Label' },
provider: 'provider-1',
},
parameters: [
{
name: 'param1',
label: { en_US: 'Parameter 1' },
type: 'text-input',
required: true,
human_description: { en_US: 'A text parameter' },
},
],
description: { en_US: 'Strategy description' },
output_schema: {
properties: {
result: { type: 'string', description: 'Result output' },
items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
},
},
features: [],
} as unknown as StrategyDetailType
describe('StrategyDetail', () => {
const mockOnHide = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render drawer', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render provider label', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('Test Provider')).toBeInTheDocument()
})
it('should render strategy label', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
it('should render parameters section', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
})
it('should render output schema section', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('OUTPUT')).toBeInTheDocument()
expect(screen.getByText('result')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
})
it('should render BACK button', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('BACK')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHide when close button clicked', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
// Find the close button (ActionButton with action-btn class)
const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when BACK clicked', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
fireEvent.click(screen.getByText('BACK'))
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
describe('Parameter Types', () => {
it('should display correct type for number-input', () => {
const detailWithNumber = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'number-input' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithNumber} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
})
it('should display correct type for checkbox', () => {
const detailWithCheckbox = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'checkbox' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithCheckbox} onHide={mockOnHide} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display correct type for file', () => {
const detailWithFile = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'file' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithFile} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
})
it('should display correct type for array[tools]', () => {
const detailWithArrayTools = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'array[tools]' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithArrayTools} onHide={mockOnHide} />)
expect(screen.getByText('multiple-tool-select')).toBeInTheDocument()
})
it('should display original type for unknown types', () => {
const detailWithUnknown = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'custom-type' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithUnknown} onHide={mockOnHide} />)
expect(screen.getByText('custom-type')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty parameters', () => {
const detailEmpty = { ...mockDetail, parameters: [] }
render(<StrategyDetail provider={mockProvider} detail={detailEmpty} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
})
it('should handle no output schema', () => {
const detailNoOutput = { ...mockDetail, output_schema: undefined as unknown as Record<string, unknown> }
render(<StrategyDetail provider={mockProvider} detail={detailNoOutput} onHide={mockOnHide} />)
expect(screen.queryByText('OUTPUT')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,102 @@
import type { StrategyDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StrategyItem from './strategy-item'
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('./strategy-detail', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="strategy-detail-panel">
<button data-testid="hide-btn" onClick={onHide}>Hide</button>
</div>
),
}))
const mockProvider = {
author: 'test-author',
name: 'test-provider',
description: { en_US: 'Provider desc' } as Record<string, string>,
tenant_id: 'tenant-1',
icon: 'icon.png',
label: { en_US: 'Test Provider' } as Record<string, string>,
tags: [] as string[],
}
const mockDetail = {
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy Label' } as Record<string, string>,
provider: 'provider-1',
},
parameters: [],
description: { en_US: 'Strategy description' } as Record<string, string>,
output_schema: {},
features: [],
} as StrategyDetail
describe('StrategyItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render strategy label', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
it('should render strategy description', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.getByText('Strategy description')).toBeInTheDocument()
})
it('should not show detail panel initially', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should show detail panel when clicked', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
fireEvent.click(screen.getByText('Strategy Label'))
expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
})
it('should hide detail panel when hide is called', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
fireEvent.click(screen.getByText('Strategy Label'))
expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-btn'))
expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle empty description', () => {
const detailWithEmptyDesc = {
...mockDetail,
description: { en_US: '' } as Record<string, string>,
} as StrategyDetail
render(<StrategyItem provider={mockProvider} detail={detailWithEmptyDesc} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
})
})

View File

@ -1874,4 +1874,187 @@ describe('CommonCreateModal', () => {
expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true')
})
})
describe('normalizeFormType Additional Branches', () => {
it('should handle "text" type by returning textInput', () => {
const detailWithText = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [],
parameters: [
{ name: 'text_type_field', type: 'text' },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithText)
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />)
expect(screen.getByTestId('form-field-text_type_field')).toBeInTheDocument()
})
it('should handle "secret" type by returning secretInput', () => {
const detailWithSecret = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [],
parameters: [
{ name: 'secret_type_field', type: 'secret' },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithSecret)
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />)
expect(screen.getByTestId('form-field-secret_type_field')).toBeInTheDocument()
})
})
describe('HandleManualPropertiesChange Provider Fallback', () => {
it('should not call updateBuilder when provider is empty', async () => {
const detailWithEmptyProvider = createMockPluginDetail({
provider: '',
declaration: {
trigger: {
subscription_schema: [
{ name: 'webhook_url', type: 'text', required: true },
],
subscription_constructor: {
credentials_schema: [],
parameters: [],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithEmptyProvider)
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
// updateBuilder should not be called when provider is empty
expect(mockUpdateBuilder).not.toHaveBeenCalled()
})
})
describe('Configuration Step Without Endpoint', () => {
it('should handle builder without endpoint', async () => {
const builderWithoutEndpoint = createMockSubscriptionBuilder({
endpoint: '',
})
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builderWithoutEndpoint} />)
// Component should render without errors
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
describe('ApiKeyStep Flow Additional Coverage', () => {
it('should handle verify when no builder created yet', async () => {
const detailWithCredentials = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [
{ name: 'api_key', type: 'secret', required: true },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithCredentials)
// Make createBuilder slow
mockCreateBuilder.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
render(<CommonCreateModal {...defaultProps} />)
// Click verify before builder is created
fireEvent.click(screen.getByTestId('modal-confirm'))
// Should still attempt to verify
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
describe('Auto Parameters Not For APIKEY in Configuration', () => {
it('should include parameters for APIKEY in configuration step', async () => {
const detailWithParams = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [
{ name: 'api_key', type: 'secret', required: true },
],
parameters: [
{ name: 'extra_param', type: 'string', required: true },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithParams)
// First verify credentials
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} builder={builder} />)
// Click verify
fireEvent.click(screen.getByTestId('modal-confirm'))
await waitFor(() => {
expect(mockVerifyCredentials).toHaveBeenCalled()
})
// Now in configuration step, should see extra_param
expect(screen.getByTestId('form-field-extra_param')).toBeInTheDocument()
})
})
describe('needCheckValidatedValues Option', () => {
it('should pass needCheckValidatedValues: false for manual properties', async () => {
const detailWithManualSchema = createMockPluginDetail({
declaration: {
trigger: {
subscription_schema: [
{ name: 'webhook_url', type: 'text', required: true },
],
subscription_constructor: {
credentials_schema: [],
parameters: [],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithManualSchema)
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'test' } })
await waitFor(() => {
expect(mockUpdateBuilder).toHaveBeenCalled()
})
})
})
})

View File

@ -1475,4 +1475,213 @@ describe('CreateSubscriptionButton', () => {
})
})
})
// ==================== OAuth Callback Edge Cases ====================
describe('OAuth Callback - Falsy Data', () => {
it('should not open modal when OAuth callback returns falsy data', async () => {
// Arrange
const { openOAuthPopup } = await import('@/hooks/use-oauth')
vi.mocked(openOAuthPopup).mockImplementation((url: string, callback: (data?: unknown) => void) => {
callback(undefined) // falsy callback data
return null
})
const mockBuilder: TriggerSubscriptionBuilder = {
id: 'oauth-builder',
name: 'OAuth Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
properties: {},
workflows_in_use: 0,
}
mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onSuccess: (response: { authorization_url: string, subscription_builder: TriggerSubscriptionBuilder }) => void }) => {
callbacks.onSuccess({
authorization_url: 'https://oauth.test.com/authorize',
subscription_builder: mockBuilder,
})
})
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
}),
oauthConfig: createOAuthConfig({ configured: true }),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// Click on OAuth option
const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
fireEvent.click(oauthOption)
// Assert - modal should NOT open because callback data was falsy
await waitFor(() => {
expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument()
})
})
})
// ==================== TriggerProps ClassName Branches ====================
describe('TriggerProps ClassName Branches', () => {
it('should apply pointer-events-none when non-default method with multiple supported methods', () => {
// Arrange - Single APIKEY method (methodType = APIKEY, not DEFAULT_METHOD)
// But we need multiple methods to test this branch
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL],
}),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// The methodType will be DEFAULT_METHOD since multiple methods
// This verifies the render doesn't crash with multiple methods
expect(screen.getByTestId('custom-select')).toHaveAttribute('data-value', 'default')
})
})
// ==================== Tooltip Disabled Branches ====================
describe('Tooltip Disabled Branches', () => {
it('should enable tooltip when single method and not at max count', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: [createSubscription()], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - tooltip should be enabled (disabled prop = false for single method)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
it('should disable tooltip when multiple methods and not at max count', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
}),
subscriptions: [createSubscription()], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - tooltip should be disabled (neither single method nor at max)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
// ==================== Tooltip PopupContent Branches ====================
describe('Tooltip PopupContent Branches', () => {
it('should show max count message when at max subscriptions', () => {
// Arrange
const maxSubscriptions = createMaxSubscriptions()
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: maxSubscriptions,
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - component renders with max subscriptions
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
it('should show method description when not at max', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: [], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - component renders without max subscriptions
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
// ==================== Provider Info Fallbacks ====================
describe('Provider Info Fallbacks', () => {
it('should handle undefined supported_creation_methods', () => {
// Arrange - providerInfo with undefined supported_creation_methods
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: {
...createProviderInfo(),
supported_creation_methods: undefined as unknown as SupportedCreationMethods[],
},
})
const props = createDefaultProps()
// Act
const { container } = render(<CreateSubscriptionButton {...props} />)
// Assert - should render null when supported methods fallback to empty
expect(container).toBeEmptyDOMElement()
})
it('should handle providerInfo with null supported_creation_methods', () => {
// Arrange
mockProviderInfo = { data: { ...createProviderInfo(), supported_creation_methods: null as unknown as SupportedCreationMethods[] } }
mockOAuthConfig = { data: undefined, refetch: vi.fn() }
mockStoreDetail = createStoreDetail()
const props = createDefaultProps()
// Act
const { container } = render(<CreateSubscriptionButton {...props} />)
// Assert - should render null
expect(container).toBeEmptyDOMElement()
})
})
// ==================== Method Type Logic ====================
describe('Method Type Logic', () => {
it('should use single method as methodType when only one supported', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.APIKEY],
}),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert
const customSelect = screen.getByTestId('custom-select')
expect(customSelect).toHaveAttribute('data-value', SupportedCreationMethods.APIKEY)
})
})
})

View File

@ -1240,4 +1240,60 @@ describe('OAuthClientSettingsModal', () => {
vi.useRealTimers()
})
})
describe('OAuth Client Schema Params Fallback', () => {
it('should handle schema when params is truthy but schema name not in params', () => {
const configWithSchemaNotInParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: {
client_id: 'test-id',
client_secret: 'test-secret',
},
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
{ name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
{ name: 'extra_field', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithSchemaNotInParams} />)
// extra_field should be rendered but without default value
const extraInput = screen.getByTestId('form-field-extra_field') as HTMLInputElement
expect(extraInput.defaultValue).toBe('')
})
it('should handle oauth_client_schema with undefined params', () => {
const configWithUndefinedParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: undefined as unknown as TriggerOAuthConfig['params'],
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithUndefinedParams} />)
// Form should not render because params is undefined (schema condition fails)
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
})
it('should handle oauth_client_schema with null params', () => {
const configWithNullParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: null as unknown as TriggerOAuthConfig['params'],
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithNullParams} />)
// Form should not render because params is null
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,287 @@
import type { TriggerEvent } from '@/app/components/plugins/types'
import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventDetailDrawer } from './event-detail-drawer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName }: { orgName: string }) => <div data-testid="org-info">{orgName}</div>,
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
triggerEventParametersToFormSchemas: (params: Array<Record<string, unknown>>) =>
params.map(p => ({
label: (p.label as Record<string, string>) || { en_US: p.name as string },
type: (p.type as string) || 'text-input',
required: (p.required as boolean) || false,
description: p.description as Record<string, string> | undefined,
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', () => ({
default: ({ name }: { name: string }) => <div data-testid="output-field">{name}</div>,
}))
const mockEventInfo = {
name: 'test-event',
identity: {
author: 'test-author',
name: 'test-event',
label: { en_US: 'Test Event' },
},
description: { en_US: 'Test event description' },
parameters: [
{
name: 'param1',
label: { en_US: 'Parameter 1' },
type: 'text-input',
auto_generate: null,
template: null,
scope: null,
required: true,
multiple: false,
default: null,
min: null,
max: null,
precision: null,
description: { en_US: 'A test parameter' },
},
],
output_schema: {
properties: {
result: { type: 'string', description: 'Result' },
},
required: ['result'],
},
} as unknown as TriggerEvent
const mockProviderInfo = {
provider: 'test-provider',
author: 'test-author',
name: 'test-provider/test-name',
icon: 'icon.png',
description: { en_US: 'Provider desc' },
supported_creation_methods: [],
} as unknown as TriggerProviderApiEntity
describe('EventDetailDrawer', () => {
const mockOnClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render drawer', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render event title', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('Test Event')).toBeInTheDocument()
})
it('should render event description', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByTestId('description')).toHaveTextContent('Test event description')
})
it('should render org info', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByTestId('org-info')).toBeInTheDocument()
})
it('should render parameters section', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
})
it('should render output section', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
expect(screen.getByTestId('output-field')).toHaveTextContent('result')
})
it('should render back button', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('detailPanel.operation.back')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when close button clicked', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
// Find the close button (ActionButton with action-btn class)
const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when back clicked', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
fireEvent.click(screen.getByText('detailPanel.operation.back'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle no parameters', () => {
const eventWithNoParams = { ...mockEventInfo, parameters: [] }
render(<EventDetailDrawer eventInfo={eventWithNoParams} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.item.noParameters')).toBeInTheDocument()
})
it('should handle no output schema', () => {
const eventWithNoOutput = { ...mockEventInfo, output_schema: {} }
render(<EventDetailDrawer eventInfo={eventWithNoOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
expect(screen.queryByTestId('output-field')).not.toBeInTheDocument()
})
})
describe('Parameter Types', () => {
it('should display correct type for number-input', () => {
const eventWithNumber = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'number-input' }],
}
render(<EventDetailDrawer eventInfo={eventWithNumber} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
})
it('should display correct type for checkbox', () => {
const eventWithCheckbox = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'checkbox' }],
}
render(<EventDetailDrawer eventInfo={eventWithCheckbox} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display correct type for file', () => {
const eventWithFile = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'file' }],
}
render(<EventDetailDrawer eventInfo={eventWithFile} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
})
it('should display original type for unknown types', () => {
const eventWithUnknown = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'custom-type' }],
}
render(<EventDetailDrawer eventInfo={eventWithUnknown} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('custom-type')).toBeInTheDocument()
})
})
describe('Output Schema Conversion', () => {
it('should handle array type in output schema', () => {
const eventWithArrayOutput = {
...mockEventInfo,
output_schema: {
properties: {
items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithArrayOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle nested properties in output schema', () => {
const eventWithNestedOutput = {
...mockEventInfo,
output_schema: {
properties: {
nested: {
type: 'object',
properties: { inner: { type: 'string' } },
required: ['inner'],
},
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithNestedOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle enum in output schema', () => {
const eventWithEnumOutput = {
...mockEventInfo,
output_schema: {
properties: {
status: { type: 'string', enum: ['active', 'inactive'], description: 'Status' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithEnumOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle array type schema', () => {
const eventWithArrayType = {
...mockEventInfo,
output_schema: {
properties: {
multi: { type: ['string', 'null'], description: 'Multi type' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithArrayType} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,146 @@
import type { TriggerEvent } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerEventsList } from './event-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.event || 'events'}`
return key
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
const mockTriggerEvents = [
{
name: 'event-1',
identity: {
author: 'author-1',
name: 'event-1',
label: { en_US: 'Event One' },
},
description: { en_US: 'Event one description' },
parameters: [],
output_schema: {},
},
] as unknown as TriggerEvent[]
let mockDetail: { plugin_id: string, provider: string } | undefined
let mockProviderInfo: { events: TriggerEvent[] } | undefined
vi.mock('../store', () => ({
usePluginStore: (selector: (state: { detail: typeof mockDetail }) => typeof mockDetail) =>
selector({ detail: mockDetail }),
}))
vi.mock('@/service/use-triggers', () => ({
useTriggerProviderInfo: () => ({ data: mockProviderInfo }),
}))
vi.mock('./event-detail-drawer', () => ({
EventDetailDrawer: ({ onClose }: { onClose: () => void }) => (
<div data-testid="event-detail-drawer">
<button data-testid="close-drawer" onClick={onClose}>Close</button>
</div>
),
}))
describe('TriggerEventsList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDetail = { plugin_id: 'test-plugin', provider: 'test-provider' }
mockProviderInfo = { events: mockTriggerEvents }
})
describe('Rendering', () => {
it('should render event count', () => {
render(<TriggerEventsList />)
expect(screen.getByText('1 events.event')).toBeInTheDocument()
})
it('should render event cards', () => {
render(<TriggerEventsList />)
expect(screen.getByText('Event One')).toBeInTheDocument()
expect(screen.getByText('Event one description')).toBeInTheDocument()
})
it('should return null when no provider info', () => {
mockProviderInfo = undefined
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when no events', () => {
mockProviderInfo = { events: [] }
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when no detail', () => {
mockDetail = undefined
mockProviderInfo = undefined
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
})
describe('User Interactions', () => {
it('should show detail drawer when event card clicked', () => {
render(<TriggerEventsList />)
fireEvent.click(screen.getByText('Event One'))
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
})
it('should hide detail drawer when close clicked', () => {
render(<TriggerEventsList />)
fireEvent.click(screen.getByText('Event One'))
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-drawer'))
expect(screen.queryByTestId('event-detail-drawer')).not.toBeInTheDocument()
})
})
describe('Multiple Events', () => {
it('should render multiple event cards', () => {
const secondEvent = {
name: 'event-2',
identity: {
author: 'author-2',
name: 'event-2',
label: { en_US: 'Event Two' },
},
description: { en_US: 'Event two description' },
parameters: [],
output_schema: {},
} as unknown as TriggerEvent
mockProviderInfo = {
events: [...mockTriggerEvents, secondEvent],
}
render(<TriggerEventsList />)
expect(screen.getByText('Event One')).toBeInTheDocument()
expect(screen.getByText('Event Two')).toBeInTheDocument()
expect(screen.getByText('2 events.events')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { NAME_FIELD } from './utils'
describe('utils', () => {
describe('NAME_FIELD', () => {
it('should have correct type', () => {
expect(NAME_FIELD.type).toBe(FormTypeEnum.textInput)
})
it('should have correct name', () => {
expect(NAME_FIELD.name).toBe('name')
})
it('should have label translations', () => {
expect(NAME_FIELD.label).toBeDefined()
expect(NAME_FIELD.label.en_US).toBe('Endpoint Name')
expect(NAME_FIELD.label.zh_Hans).toBe('端点名称')
expect(NAME_FIELD.label.ja_JP).toBe('エンドポイント名')
expect(NAME_FIELD.label.pt_BR).toBe('Nome do ponto final')
})
it('should have placeholder translations', () => {
expect(NAME_FIELD.placeholder).toBeDefined()
expect(NAME_FIELD.placeholder.en_US).toBe('Endpoint Name')
expect(NAME_FIELD.placeholder.zh_Hans).toBe('端点名称')
expect(NAME_FIELD.placeholder.ja_JP).toBe('エンドポイント名')
expect(NAME_FIELD.placeholder.pt_BR).toBe('Nome do ponto final')
})
it('should be required', () => {
expect(NAME_FIELD.required).toBe(true)
})
it('should have empty default value', () => {
expect(NAME_FIELD.default).toBe('')
})
it('should have null help', () => {
expect(NAME_FIELD.help).toBeNull()
})
it('should have all required field properties', () => {
const requiredKeys = ['type', 'name', 'label', 'placeholder', 'required', 'default', 'help']
requiredKeys.forEach((key) => {
expect(NAME_FIELD).toHaveProperty(key)
})
})
it('should match expected structure', () => {
expect(NAME_FIELD).toEqual({
type: FormTypeEnum.textInput,
name: 'name',
label: {
en_US: 'Endpoint Name',
zh_Hans: '端点名称',
ja_JP: 'エンドポイント名',
pt_BR: 'Nome do ponto final',
},
placeholder: {
en_US: 'Endpoint Name',
zh_Hans: '端点名称',
ja_JP: 'エンドポイント名',
pt_BR: 'Nome do ponto final',
},
required: true,
default: '',
help: null,
})
})
})
})

View File

@ -4220,11 +4220,6 @@
"count": 1
}
},
"i18n/en-US/common.json": {
"no-irregular-whitespace": {
"count": 3
}
},
"i18n/fr-FR/app-debug.json": {
"no-irregular-whitespace": {
"count": 1

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Abbrechen",
"stepOne.uploader.change": "Ändern",
"stepOne.uploader.failed": "Hochladen fehlgeschlagen",
"stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{size}}MB pro Datei.",
"stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei. Insgesamt maximal {{totalCount}} Dateien.",
"stepOne.uploader.title": "Textdatei hochladen",
"stepOne.uploader.validation.count": "Mehrere Dateien nicht unterstützt",
"stepOne.uploader.validation.filesNumber": "Sie haben das Limit für die Stapelverarbeitung von {{filesNumber}} erreicht.",

View File

@ -61,6 +61,7 @@
"account.workspaceName": "Workspace Name",
"account.workspaceNamePlaceholder": "Enter workspace name",
"actionMsg.copySuccessfully": "Copied successfully",
"actionMsg.downloadUnsuccessfully": "Download failed. Please try again later.",
"actionMsg.generatedSuccessfully": "Generated successfully",
"actionMsg.generatedUnsuccessfully": "Generated unsuccessfully",
"actionMsg.modifiedSuccessfully": "Modified successfully",
@ -91,6 +92,7 @@
"apiBasedExtension.title": "API extensions provide centralized API management, simplifying configuration for easy use across Dify's applications.",
"apiBasedExtension.type": "Type",
"appMenus.apiAccess": "API Access",
"appMenus.apiAccessTip": "This knowledge base is accessible via the Service API",
"appMenus.logAndAnn": "Logs & Annotations",
"appMenus.logs": "Logs",
"appMenus.overview": "Monitoring",
@ -281,7 +283,7 @@
"model.params.setToCurrentModelMaxTokenTip": "Max token is updated to the 80% maximum token of the current model {{maxToken}}.",
"model.params.stop_sequences": "Stop sequences",
"model.params.stop_sequencesPlaceholder": "Enter sequence and press Tab",
"model.params.stop_sequencesTip": "Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.",
"model.params.stop_sequencesTip": "Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.",
"model.params.temperature": "Temperature",
"model.params.temperatureTip": "Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.",
"model.params.top_p": "Top P",

View File

@ -26,6 +26,7 @@
"list.action.archive": "Archive",
"list.action.batchAdd": "Batch add",
"list.action.delete": "Delete",
"list.action.download": "Download",
"list.action.enableWarning": "Archived file cannot be enabled",
"list.action.pause": "Pause",
"list.action.resume": "Resume",

View File

@ -7,6 +7,7 @@
"batchAction.cancel": "Cancel",
"batchAction.delete": "Delete",
"batchAction.disable": "Disable",
"batchAction.download": "Download",
"batchAction.enable": "Enable",
"batchAction.reIndex": "Re-index",
"batchAction.selected": "Selected",
@ -170,7 +171,7 @@
"serviceApi.card.endpoint": "Service API Endpoint",
"serviceApi.card.title": "Backend service api",
"serviceApi.disabled": "Disabled",
"serviceApi.enabled": "In Service",
"serviceApi.enabled": "Enabled",
"serviceApi.title": "Service API",
"unavailable": "Unavailable",
"updated": "Updated",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Cancelar",
"stepOne.uploader.change": "Cambiar",
"stepOne.uploader.failed": "Error al cargar",
"stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{size}}MB cada uno.",
"stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno. Total máximo de {{totalCount}} archivos.",
"stepOne.uploader.title": "Cargar archivo",
"stepOne.uploader.validation.count": "No se admiten varios archivos",
"stepOne.uploader.validation.filesNumber": "Has alcanzado el límite de carga por lotes de {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "لغو",
"stepOne.uploader.change": "تغییر",
"stepOne.uploader.failed": "بارگذاری ناموفق بود",
"stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{size}}MB هر کدام.",
"stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل. حداکثر کل {{totalCount}} فایل.",
"stepOne.uploader.title": "بارگذاری فایل",
"stepOne.uploader.validation.count": "چندین فایل پشتیبانی نمیشود",
"stepOne.uploader.validation.filesNumber": "شما به حد مجاز بارگذاری دستهای {{filesNumber}} رسیدهاید.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Annuler",
"stepOne.uploader.change": "Changer",
"stepOne.uploader.failed": "Le téléchargement a échoué",
"stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Max {{size}}MB chacun.",
"stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun. Maximum total de {{totalCount}} fichiers.",
"stepOne.uploader.title": "Télécharger le fichier texte",
"stepOne.uploader.validation.count": "Plusieurs fichiers non pris en charge",
"stepOne.uploader.validation.filesNumber": "Vous avez atteint la limite de téléchargement par lot de {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "रद्द करें",
"stepOne.uploader.change": "बदलें",
"stepOne.uploader.failed": "अपलोड विफल रहा",
"stepOne.uploader.tip": "समर्थित {{supportTypes}}। प्रत्येक अधिकतम {{size}}MB।",
"stepOne.uploader.tip": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB। कुल अधिकतम {{totalCount}} फ़ाइलें।",
"stepOne.uploader.title": "फ़ाइल अपलोड करें",
"stepOne.uploader.validation.count": "एकाधिक फ़ाइलें समर्थित नहीं हैं",
"stepOne.uploader.validation.filesNumber": "आपने {{filesNumber}} की बैच अपलोड सीमा तक पहुँच गए हैं।",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Annulla",
"stepOne.uploader.change": "Cambia",
"stepOne.uploader.failed": "Caricamento fallito",
"stepOne.uploader.tip": "Supporta {{supportTypes}}. Max {{size}}MB ciascuno.",
"stepOne.uploader.tip": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno. Totale massimo {{totalCount}} file.",
"stepOne.uploader.title": "Carica file",
"stepOne.uploader.validation.count": "Più file non supportati",
"stepOne.uploader.validation.filesNumber": "Hai raggiunto il limite di caricamento batch di {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "キャンセル",
"stepOne.uploader.change": "変更",
"stepOne.uploader.failed": "アップロードに失敗しました",
"stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1 つあたりの最大サイズは{{size}}MB です。",
"stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。合計最大{{totalCount}}ファイル。",
"stepOne.uploader.title": "テキストファイルをアップロード",
"stepOne.uploader.validation.count": "複数のファイルはサポートされていません",
"stepOne.uploader.validation.filesNumber": "バッチアップロードの制限({{filesNumber}}個)に達しました。",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "취소",
"stepOne.uploader.change": "변경",
"stepOne.uploader.failed": "업로드에 실패했습니다",
"stepOne.uploader.tip": "{{supportTypes}}을 (를) 지원합니다. 파일당 최대 크기는 {{size}}MB 입니다.",
"stepOne.uploader.tip": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지. 총 최대 {{totalCount}}개 파일.",
"stepOne.uploader.title": "텍스트 파일 업로드",
"stepOne.uploader.validation.count": "여러 파일은 지원되지 않습니다",
"stepOne.uploader.validation.filesNumber": "일괄 업로드 제한 ({{filesNumber}}개) 에 도달했습니다.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Anuluj",
"stepOne.uploader.change": "Zmień",
"stepOne.uploader.failed": "Przesyłanie nie powiodło się",
"stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{size}}MB każdy.",
"stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB. Łącznie maksymalnie {{totalCount}} plików.",
"stepOne.uploader.title": "Prześlij plik tekstowy",
"stepOne.uploader.validation.count": "Nieobsługiwane przesyłanie wielu plików",
"stepOne.uploader.validation.filesNumber": "Osiągnąłeś limit przesłania partii {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Cancelar",
"stepOne.uploader.change": "Alterar",
"stepOne.uploader.failed": "Falha no envio",
"stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{size}}MB cada.",
"stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada. Total máximo de {{totalCount}} arquivos.",
"stepOne.uploader.title": "Enviar arquivo de texto",
"stepOne.uploader.validation.count": "Vários arquivos não suportados",
"stepOne.uploader.validation.filesNumber": "Limite de upload em massa {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Anulează",
"stepOne.uploader.change": "Schimbă",
"stepOne.uploader.failed": "Încărcarea a eșuat",
"stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{size}}MB fiecare.",
"stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare. Total maxim {{totalCount}} fișiere.",
"stepOne.uploader.title": "Încărcați fișier text",
"stepOne.uploader.validation.count": "Nu se acceptă mai multe fișiere",
"stepOne.uploader.validation.filesNumber": "Ați atins limita de încărcare în lot de {{filesNumber}} fișiere.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Отмена",
"stepOne.uploader.change": "Изменить",
"stepOne.uploader.failed": "Ошибка загрузки",
"stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{size}} МБ каждый.",
"stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ. Всего максимум {{totalCount}} файлов.",
"stepOne.uploader.title": "Загрузить файл",
"stepOne.uploader.validation.count": "Несколько файлов не поддерживаются",
"stepOne.uploader.validation.filesNumber": "Вы достигли лимита пакетной загрузки {{filesNumber}} файлов.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Prekliči",
"stepOne.uploader.change": "Zamenjaj",
"stepOne.uploader.failed": "Nalaganje ni uspelo",
"stepOne.uploader.tip": "Podprti tipi datotek: {{supportTypes}}. Največ {{size}}MB na datoteko.",
"stepOne.uploader.tip": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB. Skupaj največ {{totalCount}} datotek.",
"stepOne.uploader.title": "Naloži datoteko",
"stepOne.uploader.validation.count": "Podprta je le ena datoteka",
"stepOne.uploader.validation.filesNumber": "Dosegli ste omejitev za pošiljanje {{filesNumber}} datotek.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "ยกเลิก",
"stepOne.uploader.change": "เปลี่ยน",
"stepOne.uploader.failed": "อัปโหลดล้มเหลว",
"stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{size}}MB แต่ละตัว",
"stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์ รวมสูงสุด {{totalCount}} ไฟล์",
"stepOne.uploader.title": "อัปโหลดไฟล์",
"stepOne.uploader.validation.count": "ไม่รองรับหลายไฟล์",
"stepOne.uploader.validation.filesNumber": "คุณถึงขีดจํากัดการอัปโหลดเป็นชุดของ {{filesNumber}} แล้ว",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "İptal",
"stepOne.uploader.change": "Değiştir",
"stepOne.uploader.failed": "Yükleme başarısız",
"stepOne.uploader.tip": "Destekler {{supportTypes}}. Her biri en fazla {{size}}MB.",
"stepOne.uploader.tip": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB. Toplam en fazla {{totalCount}} dosya.",
"stepOne.uploader.title": "Dosya yükle",
"stepOne.uploader.validation.count": "Birden fazla dosya desteklenmiyor",
"stepOne.uploader.validation.filesNumber": "Toplu yükleme sınırına ulaştınız, {{filesNumber}} dosya.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Скасувати",
"stepOne.uploader.change": "Змінити",
"stepOne.uploader.failed": "Завантаження не вдалося",
"stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{size}} МБ кожен.",
"stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ. Загалом максимум {{totalCount}} файлів.",
"stepOne.uploader.title": "Завантажити текстовий файл",
"stepOne.uploader.validation.count": "Не підтримується завантаження кількох файлів",
"stepOne.uploader.validation.filesNumber": "Ліміт масового завантаження {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Hủy",
"stepOne.uploader.change": "Thay đổi",
"stepOne.uploader.failed": "Tải lên thất bại",
"stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{size}}MB mỗi tệp.",
"stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp. Tổng tối đa {{totalCount}} tệp.",
"stepOne.uploader.title": "Tải lên tệp văn bản",
"stepOne.uploader.validation.count": "Không hỗ trợ tải lên nhiều tệp",
"stepOne.uploader.validation.filesNumber": "Bạn đã đạt đến giới hạn tải lên lô của {{filesNumber}} tệp.",

View File

@ -91,6 +91,7 @@
"apiBasedExtension.title": "API 扩展提供了一个集中式的 API 管理,在此统一添加 API 配置后,方便在 Dify 上的各类应用中直接使用。",
"apiBasedExtension.type": "类型",
"appMenus.apiAccess": "访问 API",
"appMenus.apiAccessTip": "此知识库可通过服务 API 访问",
"appMenus.logAndAnn": "日志与标注",
"appMenus.logs": "日志",
"appMenus.overview": "监测",

View File

@ -170,7 +170,7 @@
"serviceApi.card.endpoint": "API 端点",
"serviceApi.card.title": "后端服务 API",
"serviceApi.disabled": "已停用",
"serviceApi.enabled": "运行中",
"serviceApi.enabled": "已启用",
"serviceApi.title": "服务 API",
"unavailable": "不可用",
"updated": "更新于",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "取消",
"stepOne.uploader.change": "更改檔案",
"stepOne.uploader.failed": "上傳失敗",
"stepOne.uploader.tip": "支援 {{supportTypes}},每個檔案不超過 {{size}}MB。",
"stepOne.uploader.tip": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB,總數不超過 {{totalCount}} 個檔案。",
"stepOne.uploader.title": "上傳文字檔案",
"stepOne.uploader.validation.count": "暫不支援多個檔案",
"stepOne.uploader.validation.filesNumber": "批次上傳限制 {{filesNumber}}。",

View File

@ -40,6 +40,15 @@ type CommonDocReq = {
documentId: string
}
export type DocumentDownloadResponse = {
url: string
}
export type DocumentDownloadZipRequest = {
datasetId: string
documentIds: string[]
}
type BatchReq = {
datasetId: string
batchId: string
@ -158,6 +167,18 @@ export const resumeDocIndexing = ({ datasetId, documentId }: CommonDocReq): Prom
return patch<CommonResponse>(`/datasets/${datasetId}/documents/${documentId}/processing/resume`)
}
export const fetchDocumentDownloadUrl = ({ datasetId, documentId }: CommonDocReq): Promise<DocumentDownloadResponse> => {
return get<DocumentDownloadResponse>(`/datasets/${datasetId}/documents/${documentId}/download`, {})
}
export const downloadDocumentsZip = ({ datasetId, documentIds }: DocumentDownloadZipRequest): Promise<Blob> => {
return post<Blob>(`/datasets/${datasetId}/documents/download-zip`, {
body: {
document_ids: documentIds,
},
})
}
export const preImportNotionPages = ({ url, datasetId }: { url: string, datasetId?: string }): Promise<{ notion_info: DataSourceNotionWorkspace[] }> => {
return get<{ notion_info: DataSourceNotionWorkspace[] }>(url, { params: { dataset_id: datasetId } })
}

View File

@ -1,4 +1,4 @@
import type { MetadataType, SortType } from '../datasets'
import type { DocumentDownloadResponse, DocumentDownloadZipRequest, MetadataType, SortType } from '../datasets'
import type { CommonResponse } from '@/models/common'
import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets'
import {
@ -8,7 +8,7 @@ import {
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
import { DocumentActionType } from '@/models/datasets'
import { del, get, patch, post } from '../base'
import { pauseDocIndexing, resumeDocIndexing } from '../datasets'
import { downloadDocumentsZip, fetchDocumentDownloadUrl, pauseDocIndexing, resumeDocIndexing } from '../datasets'
import { useInvalid } from '../use-base'
const NAME_SPACE = 'knowledge/document'
@ -164,6 +164,26 @@ export const useDocumentResume = () => {
})
}
export const useDocumentDownload = () => {
return useMutation({
mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => {
if (!datasetId || !documentId)
throw new Error('datasetId and documentId are required')
return fetchDocumentDownloadUrl({ datasetId, documentId }) as Promise<DocumentDownloadResponse>
},
})
}
export const useDocumentDownloadZip = () => {
return useMutation({
mutationFn: ({ datasetId, documentIds }: DocumentDownloadZipRequest) => {
if (!datasetId || !documentIds?.length)
throw new Error('datasetId and documentIds are required')
return downloadDocumentsZip({ datasetId, documentIds })
},
})
}
export const useDocumentBatchRetryIndex = () => {
return useMutation({
mutationFn: ({ datasetId, documentIds }: { datasetId: string, documentIds: string[] }) => {

34
web/utils/download.ts Normal file
View File

@ -0,0 +1,34 @@
export type DownloadUrlOptions = {
url: string
fileName?: string
rel?: string
target?: string
}
const triggerDownload = ({ url, fileName, rel, target }: DownloadUrlOptions) => {
if (!url)
return
const anchor = document.createElement('a')
anchor.href = url
if (fileName)
anchor.download = fileName
if (rel)
anchor.rel = rel
if (target)
anchor.target = target
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
export const downloadUrl = ({ url, fileName, rel = 'noopener noreferrer', target }: DownloadUrlOptions) => {
triggerDownload({ url, fileName, rel, target })
}
export const downloadBlob = ({ data, fileName }: { data: Blob, fileName: string }) => {
const url = window.URL.createObjectURL(data)
triggerDownload({ url, fileName, rel: 'noopener noreferrer' })
window.URL.revokeObjectURL(url)
}