mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat/hitl-frontend
This commit is contained in:
commit
1014852ebd
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
## Purpose
|
||||
|
||||
Unit tests for `api/services/file_service.py` helper methods that are not covered by higher-level controller tests.
|
||||
|
||||
## What’s 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.
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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__:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
|
|
|
|||
|
|
@ -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}} رسیدهاید.",
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
|
|
|
|||
|
|
@ -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}} की बैच अपलोड सीमा तक पहुँच गए हैं।",
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
|
|
|
|||
|
|
@ -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}}個)に達しました。",
|
||||
|
|
|
|||
|
|
@ -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}}개) 에 도달했습니다.",
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}} файлов.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}} แล้ว",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}}.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@
|
|||
"apiBasedExtension.title": "API 扩展提供了一个集中式的 API 管理,在此统一添加 API 配置后,方便在 Dify 上的各类应用中直接使用。",
|
||||
"apiBasedExtension.type": "类型",
|
||||
"appMenus.apiAccess": "访问 API",
|
||||
"appMenus.apiAccessTip": "此知识库可通过服务 API 访问",
|
||||
"appMenus.logAndAnn": "日志与标注",
|
||||
"appMenus.logs": "日志",
|
||||
"appMenus.overview": "监测",
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@
|
|||
"serviceApi.card.endpoint": "API 端点",
|
||||
"serviceApi.card.title": "后端服务 API",
|
||||
"serviceApi.disabled": "已停用",
|
||||
"serviceApi.enabled": "运行中",
|
||||
"serviceApi.enabled": "已启用",
|
||||
"serviceApi.title": "服务 API",
|
||||
"unavailable": "不可用",
|
||||
"updated": "更新于",
|
||||
|
|
|
|||
|
|
@ -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}}。",
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] }) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue