Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andhyoban 266e887fb1 chore: temporarily remove lint-staged to enable incremental i18n fixes
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 10:10:10 +00:00
copilot-swe-agent[bot]andhyoban 337701badb Changes before error encountered
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 10:00:18 +00:00
copilot-swe-agent[bot]andhyoban 0d11745909 Changes before error encountered
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 09:48:20 +00:00
copilot-swe-agent[bot]andhyoban 6a6997094d fix: correct i18n placeholders while preserving all translations
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 09:33:30 +00:00
copilot-swe-agent[bot]andhyoban 3f0719e50f fix: sync i18n placeholder mismatches with en-US
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-01-20 09:21:58 +00:00
copilot-swe-agent[bot] 67bab85fa6 Initial plan 2026-01-20 09:14:16 +00:00
Stephen Zhou 84a1d6a948 chore: lint for i18n place holder 2026-01-20 17:02:27 +08:00
3ebe53ada1 ci: label web changes (#31261)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 13:46:23 +08:00
76b64dda52 test: add tests for dataset list (#31231)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-20 13:07:00 +08:00
cxhelloandGitHub a715c015e7 chore(web): remove redundant optimizePackageImports config (#31257) 2026-01-20 12:24:16 +08:00
45b8d033be chore: init tsslint (#31209)
Co-authored-by: Johnson Chu <johnsoncodehk@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 11:08:50 +08:00
kurokoboandGitHub cb51a449d3 fix: correct i18n for stepOne.uploader.tip (#31177) 2026-01-20 09:30:50 +08:00
盐粒 YanliGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Cursor AgentCopilot
62ac02a568 feat: Download the uploaded files (#31068)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-19 16:48:13 +08:00
zxhlyhandGitHub 2d4289a925 chore: relocate datasets api form (#31224) 2026-01-19 16:15:51 +08:00
wangxiaoleiandGitHub 88780c7eb7 fix: Revert "fix: fix create app xss issue" (#31219) 2026-01-19 16:07:24 +08:00
wangxiaoleiandGitHub 0f1db88dcb fix: fix dify-plugin-daemon error message (#31218) 2026-01-19 16:00:44 +08:00
92dbc94f2f test: add unit tests for plugin detail panel components including action lists, strategy lists, and endpoint management (#31053)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2026-01-19 14:40:32 +08:00
wangxiaoleiandGitHub 9f09414dbe refactor: make url in email template more better (#31166) 2026-01-19 14:28:41 +08:00
165 changed files with 26983 additions and 481 deletions
+3
View File
@@ -0,0 +1,3 @@
web:
- changed-files:
- any-glob-to-any-file: 'web/**'
+14
View File
@@ -0,0 +1,14 @@
name: "Pull Request Labeler"
on:
pull_request_target:
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
sync-labels: true
+5
View File
@@ -117,6 +117,11 @@ jobs:
# eslint-report: web/eslint_report.json
# github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run lint:tss
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
@@ -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.
## Whats covered
- `FileService.build_upload_files_zip_tempfile(...)`
- ZIP entry name sanitization (no directory components / traversal)
- name deduplication while preserving extensions
- writing streamed bytes from `storage.load(...)` into ZIP entries
- yields a tempfile path so callers can open/stream the ZIP without holding a live file handle
- `FileService.get_upload_files_by_ids(...)`
- returns `{}` for empty id lists
- returns an id-keyed mapping for non-empty lists
## Notes
- These tests intentionally stub `storage.load` and `db.session.scalars(...).all()` to avoid needing a real DB/storage.
-58
View File
@@ -1,4 +1,3 @@
import re
import uuid
from datetime import datetime
from typing import Any, Literal, TypeAlias
@@ -68,48 +67,6 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
# XSS prevention: patterns that could lead to XSS attacks
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
_XSS_PATTERNS = [
r"<script[^>]*>.*?</script>", # Script tags
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
r"javascript:", # JavaScript protocol
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
r"<embed[^>]*>", # Embed tags (self-closing)
r"<link[^>]*>", # Link tags with javascript
]
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
"""
Validate that a string value doesn't contain potential XSS payloads.
Args:
value: The string value to validate
field_name: Name of the field for error messages
Returns:
The original value if safe
Raises:
ValueError: If the value contains XSS patterns
"""
if value is None:
return None
value_lower = value.lower()
for pattern in _XSS_PATTERNS:
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
raise ValueError(
f"{field_name} contains invalid characters or patterns. "
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
)
return value
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@@ -118,11 +75,6 @@ class CreateAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
@@ -133,11 +85,6 @@ class UpdateAppPayload(BaseModel):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
max_active_requests: int | None = Field(default=None, description="Maximum active requests")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app")
@@ -146,11 +93,6 @@ class CopyAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class AppExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Include secrets in export")
@@ -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")
+7 -8
View File
@@ -320,18 +320,17 @@ class BasePluginClient:
case PluginInvokeError.__name__:
error_object = json.loads(message)
invoke_error_type = error_object.get("error_type")
args = error_object.get("args")
match invoke_error_type:
case InvokeRateLimitError.__name__:
raise InvokeRateLimitError(description=args.get("description"))
raise InvokeRateLimitError(description=error_object.get("message"))
case InvokeAuthorizationError.__name__:
raise InvokeAuthorizationError(description=args.get("description"))
raise InvokeAuthorizationError(description=error_object.get("message"))
case InvokeBadRequestError.__name__:
raise InvokeBadRequestError(description=args.get("description"))
raise InvokeBadRequestError(description=error_object.get("message"))
case InvokeConnectionError.__name__:
raise InvokeConnectionError(description=args.get("description"))
raise InvokeConnectionError(description=error_object.get("message"))
case InvokeServerUnavailableError.__name__:
raise InvokeServerUnavailableError(description=args.get("description"))
raise InvokeServerUnavailableError(description=error_object.get("message"))
case CredentialsValidateFailedError.__name__:
raise CredentialsValidateFailedError(error_object.get("message"))
case EndpointSetupFailedError.__name__:
@@ -339,11 +338,11 @@ class BasePluginClient:
case TriggerProviderCredentialValidationError.__name__:
raise TriggerProviderCredentialValidationError(error_object.get("message"))
case TriggerPluginInvokeError.__name__:
raise TriggerPluginInvokeError(description=error_object.get("description"))
raise TriggerPluginInvokeError(description=error_object.get("message"))
case TriggerInvokeError.__name__:
raise TriggerInvokeError(error_object.get("message"))
case EventIgnoreError.__name__:
raise EventIgnoreError(description=error_object.get("description"))
raise EventIgnoreError(description=error_object.get("message"))
case _:
raise PluginInvokeError(description=message)
case PluginDaemonInternalServerError.__name__:
+141 -1
View File
@@ -13,10 +13,11 @@ import sqlalchemy as sa
from redis.exceptions import LockNotOwnedError
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.file import helpers as file_helpers
from core.helper.name_generator import generate_incremental_name
from core.model_manager import ModelManager
from core.model_runtime.entities.model_entities import ModelFeature, ModelType
@@ -73,6 +74,7 @@ from services.errors.document import DocumentIndexingError
from services.errors.file import FileNotExistsError
from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureModel, FeatureService
from services.file_service import FileService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.tag_service import TagService
from services.vector_service import VectorService
@@ -1162,6 +1164,7 @@ class DocumentService:
Document.archived.is_(True),
),
}
DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip"
@classmethod
def normalize_display_status(cls, status: str | None) -> str | None:
@@ -1288,6 +1291,143 @@ class DocumentService:
else:
return None
@staticmethod
def get_documents_by_ids(dataset_id: str, document_ids: Sequence[str]) -> Sequence[Document]:
"""Fetch documents for a dataset in a single batch query."""
if not document_ids:
return []
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
# Fetch all requested documents in one query to avoid N+1 lookups.
documents: Sequence[Document] = db.session.scalars(
select(Document).where(
Document.dataset_id == dataset_id,
Document.id.in_(document_id_list),
)
).all()
return documents
@staticmethod
def get_document_download_url(document: Document) -> str:
"""
Return a signed download URL for an upload-file document.
"""
upload_file = DocumentService._get_upload_file_for_upload_file_document(document)
return file_helpers.get_signed_file_url(upload_file_id=upload_file.id, as_attachment=True)
@staticmethod
def prepare_document_batch_download_zip(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
current_user: Account,
) -> tuple[list[UploadFile], str]:
"""
Resolve upload files for batch ZIP downloads and generate a client-visible filename.
"""
dataset = DatasetService.get_dataset(dataset_id)
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user)
except NoPermissionError as e:
raise Forbidden(str(e))
upload_files_by_document_id = DocumentService._get_upload_files_by_document_id_for_zip_download(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=tenant_id,
)
upload_files = [upload_files_by_document_id[document_id] for document_id in document_ids]
download_name = DocumentService._generate_document_batch_download_zip_filename()
return upload_files, download_name
@staticmethod
def _generate_document_batch_download_zip_filename() -> str:
"""
Generate a random attachment filename for the batch download ZIP.
"""
return f"{uuid.uuid4().hex}{DocumentService.DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION}"
@staticmethod
def _get_upload_file_id_for_upload_file_document(
document: Document,
*,
invalid_source_message: str,
missing_file_message: str,
) -> str:
"""
Normalize and validate `Document -> UploadFile` linkage for download flows.
"""
if document.data_source_type != "upload_file":
raise NotFound(invalid_source_message)
data_source_info: dict[str, Any] = document.data_source_info_dict or {}
upload_file_id: str | None = data_source_info.get("upload_file_id")
if not upload_file_id:
raise NotFound(missing_file_message)
return str(upload_file_id)
@staticmethod
def _get_upload_file_for_upload_file_document(document: Document) -> UploadFile:
"""
Load the `UploadFile` row for an upload-file document.
"""
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Document does not have an uploaded file to download.",
missing_file_message="Uploaded file not found.",
)
upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id])
upload_file = upload_files_by_id.get(upload_file_id)
if not upload_file:
raise NotFound("Uploaded file not found.")
return upload_file
@staticmethod
def _get_upload_files_by_document_id_for_zip_download(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
) -> dict[str, UploadFile]:
"""
Batch load upload files keyed by document id for ZIP downloads.
"""
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
documents = DocumentService.get_documents_by_ids(dataset_id, document_id_list)
documents_by_id: dict[str, Document] = {str(document.id): document for document in documents}
missing_document_ids: set[str] = set(document_id_list) - set(documents_by_id.keys())
if missing_document_ids:
raise NotFound("Document not found.")
upload_file_ids: list[str] = []
upload_file_ids_by_document_id: dict[str, str] = {}
for document_id, document in documents_by_id.items():
if document.tenant_id != tenant_id:
raise Forbidden("No permission.")
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Only uploaded-file documents can be downloaded as ZIP.",
missing_file_message="Only uploaded-file documents can be downloaded as ZIP.",
)
upload_file_ids.append(upload_file_id)
upload_file_ids_by_document_id[document_id] = upload_file_id
upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)
missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys())
if missing_upload_file_ids:
raise NotFound("Only uploaded-file documents can be downloaded as ZIP.")
return {
document_id: upload_files_by_id[upload_file_id]
for document_id, upload_file_id in upload_file_ids_by_document_id.items()
}
@staticmethod
def get_document_by_id(document_id: str) -> Document | None:
document = db.session.query(Document).where(Document.id == document_id).first()
+106
View File
@@ -2,7 +2,11 @@ import base64
import hashlib
import os
import uuid
from collections.abc import Iterator, Sequence
from contextlib import contextmanager, suppress
from tempfile import NamedTemporaryFile
from typing import Literal, Union
from zipfile import ZIP_DEFLATED, ZipFile
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
@@ -17,6 +21,7 @@ from constants import (
)
from core.file import helpers as file_helpers
from core.rag.extractor.extract_processor import ExtractProcessor
from extensions.ext_database import db
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
from libs.helper import extract_tenant_id
@@ -167,6 +172,9 @@ class FileService:
return upload_file
def get_file_preview(self, file_id: str):
"""
Return a short text preview extracted from a document file.
"""
with self._session_maker(expire_on_commit=False) as session:
upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first()
@@ -253,3 +261,101 @@ class FileService:
return
storage.delete(upload_file.key)
session.delete(upload_file)
@staticmethod
def get_upload_files_by_ids(tenant_id: str, upload_file_ids: Sequence[str]) -> dict[str, UploadFile]:
"""
Fetch `UploadFile` rows for a tenant in a single batch query.
This is a generic `UploadFile` lookup helper (not dataset/document specific), so it lives in `FileService`.
"""
if not upload_file_ids:
return {}
# Normalize and deduplicate ids before using them in the IN clause.
upload_file_id_list: list[str] = [str(upload_file_id) for upload_file_id in upload_file_ids]
unique_upload_file_ids: list[str] = list(set(upload_file_id_list))
# Fetch upload files in one query for efficient batch access.
upload_files: Sequence[UploadFile] = db.session.scalars(
select(UploadFile).where(
UploadFile.tenant_id == tenant_id,
UploadFile.id.in_(unique_upload_file_ids),
)
).all()
return {str(upload_file.id): upload_file for upload_file in upload_files}
@staticmethod
def _sanitize_zip_entry_name(name: str) -> str:
"""
Sanitize a ZIP entry name to avoid path traversal and weird separators.
We keep this conservative: the upload flow already rejects `/` and `\\`, but older rows (or imported data)
could still contain unsafe names.
"""
# Drop any directory components and prevent empty names.
base = os.path.basename(name).strip() or "file"
# ZIP uses forward slashes as separators; remove any residual separator characters.
return base.replace("/", "_").replace("\\", "_")
@staticmethod
def _dedupe_zip_entry_name(original_name: str, used_names: set[str]) -> str:
"""
Return a unique ZIP entry name, inserting suffixes before the extension.
"""
# Keep the original name when it's not already used.
if original_name not in used_names:
return original_name
# Insert suffixes before the extension (e.g., "doc.txt" -> "doc (1).txt").
stem, extension = os.path.splitext(original_name)
suffix = 1
while True:
candidate = f"{stem} ({suffix}){extension}"
if candidate not in used_names:
return candidate
suffix += 1
@staticmethod
@contextmanager
def build_upload_files_zip_tempfile(
*,
upload_files: Sequence[UploadFile],
) -> Iterator[str]:
"""
Build a ZIP from `UploadFile`s and yield a tempfile path.
We yield a path (rather than an open file handle) to avoid "read of closed file" issues when Flask/Werkzeug
streams responses. The caller is expected to keep this context open until the response is fully sent, then
close it (e.g., via `response.call_on_close(...)`) to delete the tempfile.
"""
used_names: set[str] = set()
# Build a ZIP in a temp file and keep it on disk until the caller finishes streaming it.
tmp_path: str | None = None
try:
with NamedTemporaryFile(mode="w+b", suffix=".zip", delete=False) as tmp:
tmp_path = tmp.name
with ZipFile(tmp, mode="w", compression=ZIP_DEFLATED) as zf:
for upload_file in upload_files:
# Ensure the entry name is safe and unique.
safe_name = FileService._sanitize_zip_entry_name(upload_file.name)
arcname = FileService._dedupe_zip_entry_name(safe_name, used_names)
used_names.add(arcname)
# Stream file bytes from storage into the ZIP entry.
with zf.open(arcname, "w") as entry:
for chunk in storage.load(upload_file.key, stream=True):
entry.write(chunk)
# Flush so `send_file(path, ...)` can re-open it safely on all platforms.
tmp.flush()
assert tmp_path is not None
yield tmp_path
finally:
# Remove the temp file when the context is closed (typically after the response finishes streaming).
if tmp_path is not None:
with suppress(FileNotFoundError):
os.remove(tmp_path)
@@ -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
+3 -1
View File
@@ -1,6 +1,8 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"kisstkondoros.vscode-codemetrics"
"kisstkondoros.vscode-codemetrics",
"johnsoncodehk.vscode-tsslint",
"dbaeumer.vscode-eslint"
]
}
@@ -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,239 @@
import type { CreateExternalAPIReq, FormSchema } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Form from './Form'
// Mock context for i18n doc link
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
describe('Form', () => {
const defaultFormSchemas: FormSchema[] = [
{
variable: 'name',
type: 'text',
label: { en_US: 'Name', zh_CN: '名称' },
required: true,
},
{
variable: 'endpoint',
type: 'text',
label: { en_US: 'API Endpoint', zh_CN: 'API 端点' },
required: true,
},
{
variable: 'api_key',
type: 'secret',
label: { en_US: 'API Key', zh_CN: 'API 密钥' },
required: true,
},
]
const defaultValue: CreateExternalAPIReq = {
name: '',
settings: {
endpoint: '',
api_key: '',
},
}
const defaultProps = {
value: defaultValue,
onChange: vi.fn(),
formSchemas: defaultFormSchemas,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<Form {...defaultProps} />)
expect(container.querySelector('form')).toBeInTheDocument()
})
it('should render all form fields based on formSchemas', () => {
render(<Form {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render required indicator for required fields', () => {
render(<Form {...defaultProps} />)
const labels = screen.getAllByText('*')
expect(labels.length).toBe(3) // All 3 fields are required
})
it('should render documentation link for endpoint field', () => {
render(<Form {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com'))
})
it('should render password type input for secret fields', () => {
render(<Form {...defaultProps} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
expect(apiKeyInput).toHaveAttribute('type', 'password')
})
it('should render text type input for text fields', () => {
render(<Form {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
expect(nameInput).toHaveAttribute('type', 'text')
})
})
describe('Props', () => {
it('should apply custom className to form', () => {
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
expect(container.querySelector('form')).toHaveClass('custom-form-class')
})
it('should apply itemClassName to form items', () => {
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
const items = container.querySelectorAll('.custom-item-class')
expect(items.length).toBe(3)
})
it('should apply fieldLabelClassName to labels', () => {
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
const labels = container.querySelectorAll('label.custom-label-class')
expect(labels.length).toBe(3)
})
it('should apply inputClassName to inputs', () => {
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toHaveClass('custom-input-class')
})
})
it('should display initial values', () => {
const valueWithData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'secret-key',
},
}
render(<Form {...defaultProps} value={valueWithData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key')
})
})
describe('User Interactions', () => {
it('should call onChange when name field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(onChange).toHaveBeenCalledWith({
name: 'New API Name',
settings: { endpoint: '', api_key: '' },
})
})
it('should call onChange when endpoint field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new-api.example.com', api_key: '' },
})
})
it('should call onChange when api_key field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: '', api_key: 'new-secret-key' },
})
})
it('should update settings without affecting name', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: 'Existing Name',
settings: { endpoint: '', api_key: '' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: 'Existing Name',
settings: { endpoint: 'https://api.example.com', api_key: '' },
})
})
})
describe('Edge Cases', () => {
it('should handle empty formSchemas', () => {
const { container } = render(<Form {...defaultProps} formSchemas={[]} />)
expect(container.querySelector('form')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle optional field (required: false)', () => {
const schemasWithOptional: FormSchema[] = [
{
variable: 'description',
type: 'text',
label: { en_US: 'Description' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should fallback to en_US label when current language label is not available', () => {
const schemasWithEnOnly: FormSchema[] = [
{
variable: 'test',
type: 'text',
label: { en_US: 'Test Field' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
expect(screen.getByLabelText(/test field/i)).toBeInTheDocument()
})
it('should preserve existing settings when updating one field', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: '',
settings: { endpoint: 'https://existing.com', api_key: 'existing-key' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new.com', api_key: 'existing-key' },
})
})
})
})
@@ -0,0 +1,424 @@
import type { CreateExternalAPIReq } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked service
import { createExternalAPI } from '@/service/datasets'
import AddExternalAPIModal from './index'
// Mock API service
vi.mock('@/service/datasets', () => ({
createExternalAPI: vi.fn(),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
describe('AddExternalAPIModal', () => {
const defaultProps = {
onSave: vi.fn(),
onCancel: vi.fn(),
isEditMode: false,
}
const initialData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render create title when not in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render edit title when in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
})
it('should render form fields', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render cancel and save buttons', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
})
it('should render encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
})
it('should render close button', () => {
render(<AddExternalAPIModal {...defaultProps} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')
expect(closeButton).toBeInTheDocument()
})
})
describe('Edit Mode with Dataset Bindings', () => {
it('should show warning when editing with dataset bindings', () => {
const datasetBindings = [
{ id: 'ds-1', name: 'Dataset 1' },
{ id: 'ds-2', name: 'Dataset 2' },
]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument()
// Verify the count is displayed in the warning section
const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement
expect(warningElement?.textContent).toContain('2')
})
it('should not show warning when no dataset bindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update form values when input changes', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(nameInput).toHaveValue('New API Name')
})
it('should initialize form with data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345')
})
it('should disable save button when form has empty inputs', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).toBeDisabled()
})
it('should enable save button when all fields are filled', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).not.toBeDisabled()
})
})
describe('Create Mode - Save', () => {
it('should create API and call onSave on success', async () => {
const mockResponse = {
id: 'new-api-123',
tenant_id: 'tenant-1',
name: 'Test',
description: '',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(createExternalAPI).mockResolvedValue(mockResponse)
const onSave = vi.fn()
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(createExternalAPI).toHaveBeenCalledWith({
body: {
name: 'Test',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
},
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External API saved successfully',
})
expect(onSave).toHaveBeenCalledWith(mockResponse)
expect(onCancel).toHaveBeenCalled()
})
})
it('should show error notification when API key is too short', async () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.apiBasedExtension.modal.apiKey.lengthError',
})
})
})
it('should handle create API error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed'))
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to save/update External API',
})
})
consoleSpy.mockRestore()
})
})
describe('Edit Mode - Save', () => {
it('should call onEdit directly when editing without dataset bindings', async () => {
const onEdit = vi.fn().mockResolvedValue(undefined)
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
onEdit={onEdit}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
// When no datasetBindings, onEdit is called directly with original form data
expect(onEdit).toHaveBeenCalledWith({
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
})
})
})
it('should show confirm dialog when editing with dataset bindings', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onEdit = vi.fn().mockResolvedValue(undefined)
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onEdit={onEdit}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
})
it('should proceed with save after confirming in edit mode with bindings', async () => {
vi.mocked(createExternalAPI).mockResolvedValue({
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test API',
description: '',
settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
})
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
// There are multiple cancel buttons, find the one in the confirm dialog
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1]
fireEvent.click(confirmDialogCancelButton)
await waitFor(() => {
// Confirm button should be gone after canceling
expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0)
})
})
})
describe('Cancel', () => {
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')!
fireEvent.click(cancelButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')!
fireEvent.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('')
})
it('should handle null datasetBindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={undefined}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
it('should render documentation link in encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
expect(link).toHaveAttribute('target', '_blank')
})
})
})
@@ -0,0 +1,207 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExternalAPIPanel from './index'
// Mock external contexts (only mock context providers, not base components)
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
let mockIsLoading = false
let mockExternalKnowledgeApiList: ExternalAPIItem[] = []
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
externalKnowledgeApiList: mockExternalKnowledgeApiList,
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
isLoading: mockIsLoading,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies
vi.mock('../external-knowledge-api-card', () => ({
default: ({ api }: { api: ExternalAPIItem }) => (
<div data-testid={`api-card-${api.id}`}>{api.name}</div>
),
}))
// i18n mock returns 'namespace.key' format
describe('ExternalAPIPanel', () => {
const defaultProps = {
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsLoading = false
mockExternalKnowledgeApiList = []
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
})
it('should render panel title and description', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
})
it('should render documentation link', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base')
})
it('should render create button', () => {
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0]
expect(closeButton).toBeInTheDocument()
})
})
describe('Loading State', () => {
it('should render loading indicator when isLoading is true', () => {
mockIsLoading = true
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
// Loading component should be rendered
const loadingElement = container.querySelector('[class*="loading"]')
|| container.querySelector('.animate-spin')
|| screen.queryByRole('status')
expect(loadingElement || container.textContent).toBeTruthy()
})
})
describe('API List Rendering', () => {
it('should render empty list when no APIs exist', () => {
mockExternalKnowledgeApiList = []
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument()
})
it('should render API cards when APIs exist', () => {
mockExternalKnowledgeApiList = [
{
id: 'api-1',
tenant_id: 'tenant-1',
name: 'Test API 1',
description: '',
settings: { endpoint: 'https://api1.example.com', api_key: 'key1' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
{
id: 'api-2',
tenant_id: 'tenant-1',
name: 'Test API 2',
description: '',
settings: { endpoint: 'https://api2.example.com', api_key: 'key2' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument()
expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument()
expect(screen.getByText('Test API 1')).toBeInTheDocument()
expect(screen.getByText('Test API 2')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const onClose = vi.fn()
render(<ExternalAPIPanel onClose={onClose} />)
// Find the close button (ActionButton with close icon)
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]'))
|| buttons[0]
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should open external API modal when create button is clicked', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1)
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
datasetBindings: [],
isEditMode: false,
}),
)
})
})
it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => {
render(<ExternalAPIPanel {...defaultProps} />)
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
fireEvent.click(createButton)
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
callArgs.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle single API in list', () => {
mockExternalKnowledgeApiList = [
{
id: 'single-api',
tenant_id: 'tenant-1',
name: 'Single API',
description: '',
settings: { endpoint: 'https://single.example.com', api_key: 'key' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
},
]
render(<ExternalAPIPanel {...defaultProps} />)
expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
})
it('should render documentation link with correct target', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
expect(docLink).toHaveAttribute('target', '_blank')
})
})
})
@@ -0,0 +1,382 @@
import type { ExternalAPIItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked services
import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets'
import ExternalKnowledgeAPICard from './index'
// Mock API services
vi.mock('@/service/datasets', () => ({
fetchExternalAPI: vi.fn(),
updateExternalAPI: vi.fn(),
deleteExternalAPI: vi.fn(),
checkUsageExternalAPI: vi.fn(),
}))
// Mock contexts
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const mockMutateExternalKnowledgeApis = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
vi.mock('@/context/external-knowledge-api-context', () => ({
useExternalKnowledgeApi: () => ({
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
}),
}))
describe('ExternalKnowledgeAPICard', () => {
const mockApi: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
const defaultProps = {
api: mockApi,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API name', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should render API endpoint', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} />)
expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
})
it('should render edit and delete buttons', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBe(2)
})
it('should render API connection icon', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
describe('User Interactions - Edit', () => {
it('should fetch API details and open modal when edit button is clicked', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: 'Test External API',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
},
isEditMode: true,
datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
}),
)
})
})
it('should handle fetch error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const editButton = buttons[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error fetching external knowledge API data:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should call mutate on save callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate save callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onSaveCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
it('should call mutate on cancel callback', async () => {
const mockResponse: ExternalAPIItem = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = container.querySelectorAll('button')[0]
fireEvent.click(editButton)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
// Simulate cancel callback
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
modalCall.onCancelCallback()
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
describe('User Interactions - Delete', () => {
it('should check usage and show confirm dialog when delete button is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const buttons = container.querySelectorAll('button')
const deleteButton = buttons[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
})
// Confirm dialog should be shown
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
it('should show usage count in confirm dialog when API is in use', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByText(/3/)).toBeInTheDocument()
})
})
it('should delete API and refresh list when confirmed', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
})
})
it('should close confirm dialog when cancel is clicked', async () => {
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
})
})
it('should handle delete error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error deleting external knowledge API:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
it('should handle check usage error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error checking external API usage:',
expect.any(Error),
)
})
consoleSpy.mockRestore()
})
})
describe('Hover State', () => {
it('should apply hover styles when delete button is hovered', () => {
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
const cardContainer = container.querySelector('[class*="shadows-shadow"]')
fireEvent.mouseEnter(deleteButton)
expect(cardContainer).toHaveClass('border-state-destructive-border')
expect(cardContainer).toHaveClass('bg-state-destructive-hover')
fireEvent.mouseLeave(deleteButton)
expect(cardContainer).not.toHaveClass('border-state-destructive-border')
})
})
describe('Edge Cases', () => {
it('should handle API with empty endpoint', () => {
const apiWithEmptyEndpoint: ExternalAPIItem = {
...mockApi,
settings: { endpoint: '', api_key: 'key' },
}
render(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} />)
expect(screen.getByText('Test External API')).toBeInTheDocument()
})
it('should handle delete response with unsuccessful result', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' })
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = container.querySelectorAll('button')[1]
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API')
})
consoleSpy.mockRestore()
})
})
})
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import CornerLabels from './corner-labels'
describe('CornerLabels', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<CornerLabels dataset={dataset} />)
// Should render null when embedding is available and not pipeline
expect(container.firstChild).toBeNull()
})
it('should render unavailable label when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
})
it('should render pipeline label when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
expect(screen.getByText(/pipeline/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should not render when embedding is available and not pipeline', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should prioritize unavailable label over pipeline label', () => {
const dataset = createMockDataset({
embedding_available: false,
runtime_mode: 'rag_pipeline',
})
render(<CornerLabels dataset={dataset} />)
// Should show unavailable since embedding_available is checked first
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
expect(screen.queryByText(/pipeline/i)).not.toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct positioning for unavailable label', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
it('should have correct positioning for pipeline label', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'rag_pipeline',
})
const { container } = render(<CornerLabels dataset={dataset} />)
const labelContainer = container.firstChild as HTMLElement
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
})
})
describe('Edge Cases', () => {
it('should handle undefined runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: undefined,
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle empty string runtime_mode', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: '' as DataSet['runtime_mode'],
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
it('should handle all false conditions', () => {
const dataset = createMockDataset({
embedding_available: true,
runtime_mode: 'general',
})
const { container } = render(<CornerLabels dataset={dataset} />)
expect(container.firstChild).toBeNull()
})
})
})
@@ -0,0 +1,177 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardFooter from './dataset-card-footer'
// Mock the useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: vi.fn((timestamp: number) => {
const date = new Date(timestamp)
return `${date.toLocaleDateString()}`
}),
}),
}))
describe('DatasetCardFooter', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
total_available_documents: 10,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render document count', () => {
const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('25')).toBeInTheDocument()
})
it('should render app count for non-external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('8')).toBeInTheDocument()
})
it('should not render app count for external provider', () => {
const dataset = createMockDataset({ app_count: 8, provider: 'external' })
render(<DatasetCardFooter dataset={dataset} />)
// App count should not be rendered
const appCounts = screen.queryAllByText('8')
expect(appCounts.length).toBe(0)
})
it('should render update time', () => {
const dataset = createMockDataset()
render(<DatasetCardFooter dataset={dataset} />)
// Check for "updated" text with i18n key
expect(screen.getByText(/updated/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show partial document count when total_available_documents < document_count', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 15,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('15 / 20')).toBeInTheDocument()
})
it('should show full document count when all documents are available', () => {
const dataset = createMockDataset({
document_count: 20,
total_available_documents: 20,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('20')).toBeInTheDocument()
})
it('should handle zero documents', () => {
const dataset = createMockDataset({
document_count: 0,
total_available_documents: 0,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
const footer = container.firstChild as HTMLElement
expect(footer).not.toHaveClass('opacity-30')
})
})
describe('Icons', () => {
it('should render document icon', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// RiFileTextFill icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(1)
})
it('should render robot icon for non-external provider', () => {
const dataset = createMockDataset({ provider: 'vendor' })
const { container } = render(<DatasetCardFooter dataset={dataset} />)
// Should have both file and robot icons
const icons = container.querySelectorAll('svg')
expect(icons.length).toBe(2)
})
})
describe('Edge Cases', () => {
it('should handle undefined total_available_documents', () => {
const dataset = createMockDataset({
document_count: 10,
total_available_documents: undefined,
})
render(<DatasetCardFooter dataset={dataset} />)
// Should show 0 / 10 since total_available_documents defaults to 0
expect(screen.getByText('0 / 10')).toBeInTheDocument()
})
it('should handle very large numbers', () => {
const dataset = createMockDataset({
document_count: 999999,
total_available_documents: 999999,
app_count: 888888,
})
render(<DatasetCardFooter dataset={dataset} />)
expect(screen.getByText('999999')).toBeInTheDocument()
expect(screen.getByText('888888')).toBeInTheDocument()
})
it('should handle zero app count', () => {
const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 })
render(<DatasetCardFooter dataset={dataset} />)
// Both document count and app count are shown
const zeros = screen.getAllByText('0')
expect(zeros.length).toBeGreaterThanOrEqual(1)
})
})
})
@@ -0,0 +1,254 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCardHeader from './dataset-card-header'
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: (technique: string, _method: string) => {
if (technique === 'high_quality')
return 'High Quality'
if (technique === 'economy')
return 'Economy'
return ''
},
}),
}))
describe('DatasetCardHeader', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model_dict'],
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
author_name: 'Test User',
...overrides,
})
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
})
it('should render author name', () => {
const dataset = createMockDataset({ author_name: 'John Doe' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render edit time', () => {
const dataset = createMockDataset()
render(<DatasetCardHeader dataset={dataset} />)
// Should contain the formatted time
expect(screen.getByText(/segment\.editedAt/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show external knowledge base text for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
})
it('should show chunking mode for text_model doc_form', () => {
const dataset = createMockDataset({ doc_form: ChunkingMode.text })
render(<DatasetCardHeader dataset={dataset} />)
// text_model maps to 'general' in DOC_FORM_TEXT
expect(screen.getByText(/chunkingMode\.general/)).toBeInTheDocument()
})
it('should show multimodal text when is_multimodal is true', () => {
const dataset = createMockDataset({ is_multimodal: true })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/multimodal/)).toBeInTheDocument()
})
it('should not show multimodal when is_multimodal is false', () => {
const dataset = createMockDataset({ is_multimodal: false })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
})
})
describe('Icon', () => {
it('should render AppIcon component', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// AppIcon should be rendered
const iconContainer = container.querySelector('.relative.shrink-0')
expect(iconContainer).toBeInTheDocument()
})
it('should use default icon when icon_info is missing', () => {
const dataset = createMockDataset({ icon_info: undefined })
render(<DatasetCardHeader dataset={dataset} />)
// Should still render without crashing
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render chunking mode icon for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
runtime_mode: 'rag_pipeline',
is_published: true,
})
const { container } = render(<DatasetCardHeader dataset={dataset} />)
// Should have the icon badge
const iconBadge = container.querySelector('.absolute.-bottom-1.-right-1')
expect(iconBadge).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).not.toHaveClass('opacity-30')
})
it('should have correct base styling', () => {
const dataset = createMockDataset()
const { container } = render(<DatasetCardHeader dataset={dataset} />)
const header = container.firstChild as HTMLElement
expect(header).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
})
})
describe('DocModeInfo', () => {
it('should show doc mode info when all conditions are met', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'general',
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
it('should not show doc mode info for unpublished pipeline', () => {
const dataset = createMockDataset({
runtime_mode: 'rag_pipeline',
is_published: false,
})
render(<DatasetCardHeader dataset={dataset} />)
// DocModeInfo should not be rendered since isShowDocModeInfo is false
expect(screen.queryByText(/High Quality/)).not.toBeInTheDocument()
})
it('should show doc mode info for published pipeline', () => {
const dataset = createMockDataset({
doc_form: ChunkingMode.text,
indexing_technique: IndexingType.QUALIFIED,
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
runtime_mode: 'rag_pipeline',
is_published: true,
})
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle missing author_name', () => {
const dataset = createMockDataset({ author_name: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle empty name', () => {
const dataset = createMockDataset({ name: '' })
render(<DatasetCardHeader dataset={dataset} />)
// Should render without crashing
const { container } = render(<DatasetCardHeader dataset={dataset} />)
expect(container).toBeInTheDocument()
})
it('should handle missing retrieval_model_dict', () => {
const dataset = createMockDataset({ retrieval_model_dict: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined doc_form', () => {
const dataset = createMockDataset({ doc_form: undefined })
render(<DatasetCardHeader dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,237 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import DatasetCardModals from './dataset-card-modals'
// Mock RenameDatasetModal since it's from a different feature folder
vi.mock('../../../rename-modal', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => (
show
? (
<div data-testid="rename-modal">
<button onClick={onClose}>Close Rename</button>
<button onClick={onSuccess}>Success</button>
</div>
)
: null
),
}))
describe('DatasetCardModals', () => {
const mockDataset: DataSet = {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
indexing_status: 'completed',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
total_document_count: 10,
word_count: 1000,
updated_at: 1609545600,
updated_by: 'user-1',
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
built_in_field_enabled: false,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
retrieval_model: {} as DataSet['retrieval_model'],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
}
const defaultProps = {
dataset: mockDataset,
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
onCloseRename: vi.fn(),
onCloseConfirm: vi.fn(),
onConfirmDelete: vi.fn(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing when no modals are shown', () => {
const { container } = render(<DatasetCardModals {...defaultProps} />)
// Should render empty fragment
expect(container.innerHTML).toBe('')
})
it('should render rename modal when showRenameModal is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should render confirm modal when showConfirmDelete is true', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Are you sure?',
}}
/>,
)
// Confirm modal should be rendered
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass dataset to rename modal', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should display confirmMessage in confirm modal', () => {
const confirmMessage = 'This is a custom confirm message'
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage,
}}
/>,
)
expect(screen.getByText(confirmMessage)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCloseRename when closing rename modal', () => {
const onCloseRename = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseRename={onCloseRename}
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
/>,
)
fireEvent.click(screen.getByText('Close Rename'))
expect(onCloseRename).toHaveBeenCalledTimes(1)
})
it('should call onConfirmDelete when confirming deletion', () => {
const onConfirmDelete = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onConfirmDelete={onConfirmDelete}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the confirm button
const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i })
|| screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm'))
if (confirmButton)
fireEvent.click(confirmButton)
expect(onConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should call onCloseConfirm when canceling deletion', () => {
const onCloseConfirm = vi.fn()
render(
<DatasetCardModals
{...defaultProps}
onCloseConfirm={onCloseConfirm}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: 'Delete?',
}}
/>,
)
// Find and click the cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i })
fireEvent.click(cancelButton)
expect(onCloseConfirm).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle both modals being true (render both)', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
showRenameModal: true,
showConfirmDelete: true,
confirmMessage: 'Delete this dataset?',
}}
/>,
)
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
})
it('should handle empty confirmMessage', () => {
render(
<DatasetCardModals
{...defaultProps}
modalState={{
...defaultProps.modalState,
showConfirmDelete: true,
confirmMessage: '',
}}
/>,
)
// Should still render confirm modal
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
})
})
@@ -0,0 +1,107 @@
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import Description from './description'
describe('Description', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'This is a test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
describe('Rendering', () => {
it('should render without crashing', () => {
const dataset = createMockDataset()
render(<Description dataset={dataset} />)
expect(screen.getByText('This is a test description')).toBeInTheDocument()
})
it('should render the description text', () => {
const dataset = createMockDataset({ description: 'Custom description text' })
render(<Description dataset={dataset} />)
expect(screen.getByText('Custom description text')).toBeInTheDocument()
})
it('should set title attribute for tooltip', () => {
const dataset = createMockDataset({ description: 'Tooltip description' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('Tooltip description')
expect(descDiv).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display dataset description', () => {
const description = 'A very detailed description of this dataset'
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct base styling when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary')
})
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle(dataset.description)
expect(descDiv).not.toHaveClass('opacity-30')
})
})
describe('Edge Cases', () => {
it('should handle empty description', () => {
const dataset = createMockDataset({ description: '' })
render(<Description dataset={dataset} />)
const descDiv = screen.getByTitle('')
expect(descDiv).toBeInTheDocument()
expect(descDiv).toHaveTextContent('')
})
it('should handle very long description', () => {
const longDescription = 'A'.repeat(500)
const dataset = createMockDataset({ description: longDescription })
render(<Description dataset={dataset} />)
expect(screen.getByText(longDescription)).toBeInTheDocument()
})
it('should handle description with special characters', () => {
const description = '<script>alert("XSS")</script> & "quotes" \'single\''
const dataset = createMockDataset({ description })
render(<Description dataset={dataset} />)
expect(screen.getByText(description)).toBeInTheDocument()
})
})
})
@@ -0,0 +1,162 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import OperationsPopover from './operations-popover'
describe('OperationsPopover', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
isCurrentWorkspaceDatasetOperator: false,
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render the more icon button', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const moreIcon = container.querySelector('svg')
expect(moreIcon).toBeInTheDocument()
})
it('should render in hidden state initially (group-hover)', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('hidden', 'group-hover:block')
})
})
describe('Props', () => {
it('should show delete option when not workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator)
// This means delete operation will be visible
})
it('should hide delete option when is workspace dataset operator', () => {
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showDelete should be false
})
it('should show export pipeline when runtime_mode is rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be true
})
it('should hide export pipeline when runtime_mode is not rag_pipeline', () => {
const dataset = createMockDataset({ runtime_mode: 'general' })
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
// Click to open popover
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
if (triggerButton)
fireEvent.click(triggerButton)
// showExportPipeline should be false
})
})
describe('Styles', () => {
it('should have correct positioning styles', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-[15]')
})
it('should have icon with correct size classes', () => {
const { container } = render(<OperationsPopover {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary')
})
})
describe('User Interactions', () => {
it('should pass openRenameModal to Operations', () => {
const openRenameModal = vi.fn()
render(<OperationsPopover {...defaultProps} openRenameModal={openRenameModal} />)
// The openRenameModal should be passed to Operations component
expect(openRenameModal).not.toHaveBeenCalled() // Initially not called
})
it('should pass handleExportPipeline to Operations', () => {
const handleExportPipeline = vi.fn()
render(<OperationsPopover {...defaultProps} handleExportPipeline={handleExportPipeline} />)
expect(handleExportPipeline).not.toHaveBeenCalled()
})
it('should pass detectIsUsedByApp to Operations', () => {
const detectIsUsedByApp = vi.fn()
render(<OperationsPopover {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
expect(detectIsUsedByApp).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle dataset with external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle dataset with undefined runtime_mode', () => {
const dataset = createMockDataset({ runtime_mode: undefined })
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})
@@ -0,0 +1,198 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import TagArea from './tag-area'
// Mock TagSelector as it's a complex component from base
vi.mock('@/app/components/base/tag-management/selector', () => ({
default: ({ value, selectedTags, onCacheUpdate, onChange }: {
value: string[]
selectedTags: Tag[]
onCacheUpdate: (tags: Tag[]) => void
onChange?: () => void
}) => (
<div data-testid="tag-selector">
<div data-testid="tag-values">{value.join(',')}</div>
<div data-testid="selected-count">
{selectedTags.length}
{' '}
tags
</div>
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
Update Tags
</button>
<button onClick={onChange}>
Trigger Change
</button>
</div>
),
}))
describe('TagArea', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
...overrides,
} as DataSet)
const mockTags: Tag[] = [
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
]
const defaultProps = {
dataset: createMockDataset(),
tags: mockTags,
setTags: vi.fn(),
onSuccess: vi.fn(),
isHoveringTagSelector: false,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render TagSelector with correct value', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
})
it('should display selected tags count', () => {
render(<TagArea {...defaultProps} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
})
})
describe('Props', () => {
it('should pass dataset id to TagSelector', () => {
const dataset = createMockDataset({ id: 'custom-dataset-id' })
render(<TagArea {...defaultProps} dataset={dataset} />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
it('should render with empty tags', () => {
render(<TagArea {...defaultProps} tags={[]} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
})
it('should forward ref correctly', () => {
const TestComponent = () => {
const ref = useRef<HTMLDivElement>(null)
return <TagArea {...defaultProps} ref={ref} />
}
render(<TestComponent />)
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when container is clicked', () => {
const onClick = vi.fn()
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.click(wrapper)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should call setTags when tags are updated', () => {
const setTags = vi.fn()
render(<TagArea {...defaultProps} setTags={setTags} />)
fireEvent.click(screen.getByText('Update Tags'))
expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
})
it('should call onSuccess when onChange is triggered', () => {
const onSuccess = vi.fn()
render(<TagArea {...defaultProps} onSuccess={onSuccess} />)
fireEvent.click(screen.getByText('Trigger Change'))
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
describe('Styles', () => {
it('should have opacity class when embedding is not available', () => {
const dataset = createMockDataset({ embedding_available: false })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('opacity-30')
})
it('should not have opacity class when embedding is available', () => {
const dataset = createMockDataset({ embedding_available: true })
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-30')
})
it('should show mask when not hovering and has tags', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
expect(maskDiv).toBeInTheDocument()
expect(maskDiv).not.toHaveClass('hidden')
})
it('should hide mask when hovering', () => {
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
// When hovering, the mask div should have 'hidden' class
const maskDiv = container.querySelector('.absolute.right-0.top-0')
expect(maskDiv).toHaveClass('hidden')
})
it('should make TagSelector visible when tags exist', () => {
const { container } = render(<TagArea {...defaultProps} tags={mockTags} />)
const tagSelectorWrapper = container.querySelector('.visible')
expect(tagSelectorWrapper).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined onSuccess', () => {
render(<TagArea {...defaultProps} onSuccess={undefined} />)
// Should not throw when clicking Trigger Change
expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
})
it('should handle many tags', () => {
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
id: `tag-${i}`,
name: `Tag ${i}`,
type: 'knowledge' as const,
binding_count: 0,
}))
render(<TagArea {...defaultProps} tags={manyTags} />)
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
})
})
})
@@ -0,0 +1,427 @@
import type { DataSet } from '@/models/datasets'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { useDatasetCardState } from './use-dataset-card-state'
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock service hooks
const mockCheckUsage = vi.fn()
const mockDeleteDataset = vi.fn()
const mockExportPipeline = vi.fn()
vi.mock('@/service/use-dataset-card', () => ({
useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
}))
describe('useDatasetCardState', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
pipeline_id: 'pipeline-1',
...overrides,
} as DataSet)
beforeEach(() => {
vi.clearAllMocks()
mockCheckUsage.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Initial State', () => {
it('should return tags from dataset', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual(dataset.tags)
})
it('should have initial modal state closed', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.modalState.showRenameModal).toBe(false)
expect(result.current.modalState.showConfirmDelete).toBe(false)
expect(result.current.modalState.confirmMessage).toBe('')
})
it('should not be exporting initially', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.exporting).toBe(false)
})
})
describe('Tags State', () => {
it('should update tags when setTags is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
})
it('should sync tags when dataset tags change', () => {
const dataset = createMockDataset()
const { result, rerender } = renderHook(
({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
{ initialProps: { dataset } },
)
const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
const updatedDataset = createMockDataset({ tags: newTags })
rerender({ dataset: updatedDataset })
expect(result.current.tags).toEqual(newTags)
})
})
describe('Modal Handlers', () => {
it('should open rename modal when openRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(true)
})
it('should close rename modal when closeRenameModal is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
act(() => {
result.current.openRenameModal()
})
act(() => {
result.current.closeRenameModal()
})
expect(result.current.modalState.showRenameModal).toBe(false)
})
it('should close confirm delete modal when closeConfirmDelete is called', () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First trigger show confirm delete
act(() => {
result.current.detectIsUsedByApp()
})
waitFor(() => {
expect(result.current.modalState.showConfirmDelete).toBe(true)
})
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('detectIsUsedByApp', () => {
it('should check usage and show confirm modal with not-in-use message', async () => {
mockCheckUsage.mockResolvedValue({ is_using: false })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
expect(result.current.modalState.showConfirmDelete).toBe(true)
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
})
it('should show in-use message when dataset is used by app', async () => {
mockCheckUsage.mockResolvedValue({ is_using: true })
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
})
})
describe('onConfirmDelete', () => {
it('should delete dataset and call onSuccess', async () => {
const onSuccess = vi.fn()
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess }),
)
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
expect(onSuccess).toHaveBeenCalled()
})
it('should close confirm modal after delete', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// First open confirm modal
await act(async () => {
await result.current.detectIsUsedByApp()
})
await act(async () => {
await result.current.onConfirmDelete()
})
expect(result.current.modalState.showConfirmDelete).toBe(false)
})
})
describe('handleExportPipeline', () => {
it('should not export if pipeline_id is missing', async () => {
const dataset = createMockDataset({ pipeline_id: undefined })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).not.toHaveBeenCalled()
})
it('should export pipeline with correct parameters', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline(true)
})
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: true,
})
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
const dataset = createMockDataset({ tags: [] })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
expect(result.current.tags).toEqual([])
})
it('should handle undefined onSuccess', async () => {
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset }),
)
// Should not throw when onSuccess is undefined
await act(async () => {
await result.current.onConfirmDelete()
})
expect(mockDeleteDataset).toHaveBeenCalled()
})
})
describe('Error Handling', () => {
it('should show error toast when export pipeline fails', async () => {
const Toast = await import('@/app/components/base/toast')
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
it('should handle Response error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
status: 400,
})
mockCheckUsage.mockRejectedValue(mockResponse)
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('API Error'),
})
})
it('should handle generic Error in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue(new Error('Network error'))
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Network error',
})
})
it('should handle error without message in detectIsUsedByApp', async () => {
const Toast = await import('@/app/components/base/toast')
mockCheckUsage.mockRejectedValue({})
const dataset = createMockDataset()
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.detectIsUsedByApp()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Unknown error',
})
})
it('should handle exporting state correctly', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
// Exporting should initially be false
expect(result.current.exporting).toBe(false)
// Export should work when not exporting
await act(async () => {
await result.current.handleExportPipeline()
})
expect(mockExportPipeline).toHaveBeenCalled()
})
it('should reset exporting state after export completes', async () => {
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
it('should reset exporting state even when export fails', async () => {
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
const { result } = renderHook(() =>
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
)
await act(async () => {
await result.current.handleExportPipeline()
})
expect(result.current.exporting).toBe(false)
})
})
})
@@ -0,0 +1,256 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetCard from './index'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock ahooks useHover
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useHover: () => false,
}
})
// Mock app context
vi.mock('@/context/app-context', () => ({
useSelector: () => false,
}))
// Mock the useDatasetCardState hook
vi.mock('./hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
tags: [],
setTags: vi.fn(),
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
openRenameModal: vi.fn(),
closeRenameModal: vi.fn(),
closeConfirmDelete: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
onConfirmDelete: vi.fn(),
}),
}))
// Mock the RenameDatasetModal
vi.mock('../../rename-modal', () => ({
default: () => null,
}))
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleDateString()
},
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
describe('DatasetCard', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
total_available_documents: 10,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
},
author_name: 'Test User',
...overrides,
} as DataSet)
const defaultProps = {
dataset: createMockDataset(),
onSuccess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should render dataset name', () => {
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
})
it('should render dataset description', () => {
const dataset = createMockDataset({ description: 'Custom Description' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Custom Description')).toBeInTheDocument()
})
it('should render document count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should render app count', () => {
render(<DatasetCard {...defaultProps} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle rag_pipeline runtime mode', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should navigate to documents page on click for regular dataset', () => {
const dataset = createMockDataset({ provider: 'vendor' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
it('should navigate to hitTesting page on click for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
})
it('should navigate to pipeline page when pipeline is unpublished', () => {
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
fireEvent.click(card!)
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
})
})
describe('Styles', () => {
it('should have correct card styling', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('.group')
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have data-disable-nprogress attribute', () => {
render(<DatasetCard {...defaultProps} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
})
})
describe('Edge Cases', () => {
it('should handle dataset without description', () => {
const dataset = createMockDataset({ description: '' })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle embedding not available', () => {
const dataset = createMockDataset({ embedding_available: false })
render(<DatasetCard {...defaultProps} dataset={dataset} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
it('should handle undefined onSuccess', () => {
render(<DatasetCard dataset={createMockDataset()} />)
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
})
})
describe('Tag Area Click', () => {
it('should stop propagation and prevent default when tag area is clicked', () => {
render(<DatasetCard {...defaultProps} />)
// Find tag area element (it's inside the card)
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
if (tagAreaWrapper) {
const stopPropagationSpy = vi.fn()
const preventDefaultSpy = vi.fn()
const clickEvent = new MouseEvent('click', { bubbles: true })
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
tagAreaWrapper.dispatchEvent(clickEvent)
expect(stopPropagationSpy).toHaveBeenCalled()
expect(preventDefaultSpy).toHaveBeenCalled()
}
})
it('should not navigate when clicking on tag area', () => {
render(<DatasetCard {...defaultProps} />)
// Click on tag area should not trigger card navigation
const tagArea = document.querySelector('[class*="px-3"]')
if (tagArea) {
fireEvent.click(tagArea)
// mockPush should NOT be called when clicking tag area
// (stopPropagation prevents it from reaching the card click handler)
}
})
})
})
@@ -0,0 +1,87 @@
import { RiEditLine } from '@remixicon/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationItem from './operation-item'
describe('OperationItem', () => {
const defaultProps = {
Icon: RiEditLine,
name: 'Edit',
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OperationItem {...defaultProps} />)
expect(screen.getByText('Edit')).toBeInTheDocument()
})
it('should render the icon', () => {
const { container } = render(<OperationItem {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
})
it('should render the name text', () => {
render(<OperationItem {...defaultProps} />)
const nameSpan = screen.getByText('Edit')
expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
})
})
describe('Props', () => {
it('should render different name', () => {
render(<OperationItem {...defaultProps} name="Delete" />)
expect(screen.getByText('Delete')).toBeInTheDocument()
})
it('should be callable without handleClick', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(() => fireEvent.click(item!)).not.toThrow()
})
})
describe('User Interactions', () => {
it('should call handleClick when clicked', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should prevent default and stop propagation on click', () => {
const handleClick = vi.fn()
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
const item = screen.getByText('Edit').closest('div')
const clickEvent = new MouseEvent('click', { bubbles: true })
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
item!.dispatchEvent(clickEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
render(<OperationItem {...defaultProps} />)
const item = screen.getByText('Edit').closest('div')
expect(item).toHaveClass('flex', 'cursor-pointer', 'items-center', 'gap-x-1', 'rounded-lg')
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<OperationItem {...defaultProps} name="" />)
const container = document.querySelector('.cursor-pointer')
expect(container).toBeInTheDocument()
})
})
})
@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Operations from './operations'
describe('Operations', () => {
const defaultProps = {
showDelete: true,
showExportPipeline: true,
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
// Edit operation should always be visible
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render edit operation', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
})
it('should render export pipeline operation when showExportPipeline is true', () => {
render(<Operations {...defaultProps} showExportPipeline={true} />)
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
})
it('should not render export pipeline operation when showExportPipeline is false', () => {
render(<Operations {...defaultProps} showExportPipeline={false} />)
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
})
it('should render delete operation when showDelete is true', () => {
render(<Operations {...defaultProps} showDelete={true} />)
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
})
it('should not render delete operation when showDelete is false', () => {
render(<Operations {...defaultProps} showDelete={false} />)
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should render divider when showDelete is true', () => {
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
const divider = container.querySelector('.bg-divider-subtle')
expect(divider).toBeInTheDocument()
})
it('should not render divider when showDelete is false', () => {
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
// Should not have the divider-subtle one (the separator before delete)
expect(container.querySelector('.bg-divider-subtle')).toBeNull()
})
})
describe('User Interactions', () => {
it('should call openRenameModal when edit is clicked', () => {
const openRenameModal = vi.fn()
render(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
const editItem = screen.getByText(/operation\.edit/).closest('div')
fireEvent.click(editItem!)
expect(openRenameModal).toHaveBeenCalledTimes(1)
})
it('should call handleExportPipeline when export is clicked', () => {
const handleExportPipeline = vi.fn()
render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
const exportItem = screen.getByText(/exportPipeline/).closest('div')
fireEvent.click(exportItem!)
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
})
it('should call detectIsUsedByApp when delete is clicked', () => {
const detectIsUsedByApp = vi.fn()
render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
const deleteItem = screen.getByText(/operation\.delete/).closest('div')
fireEvent.click(deleteItem!)
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
const { container } = render(<Operations {...defaultProps} />)
const operationsContainer = container.firstChild
expect(operationsContainer).toHaveClass(
'relative',
'flex',
'w-full',
'flex-col',
'rounded-xl',
)
})
})
describe('Edge Cases', () => {
it('should render only edit when both showDelete and showExportPipeline are false', () => {
render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
})
})
})
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DatasetFooter from './index'
describe('DatasetFooter', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DatasetFooter />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
it('should render the main heading', () => {
render(<DatasetFooter />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
})
it('should render description paragraph', () => {
render(<DatasetFooter />)
// The paragraph contains multiple text spans
expect(screen.getByText(/intro1/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should be memoized', () => {
// DatasetFooter is wrapped with React.memo
expect(DatasetFooter).toBeDefined()
})
})
describe('Styles', () => {
it('should have correct footer styling', () => {
render(<DatasetFooter />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
})
it('should have gradient text on heading', () => {
render(<DatasetFooter />)
const heading = screen.getByRole('heading', { level: 3 })
expect(heading).toHaveClass('text-gradient')
})
})
describe('Content Structure', () => {
it('should render accent spans for highlighted text', () => {
render(<DatasetFooter />)
const accentSpans = document.querySelectorAll('.text-text-accent')
expect(accentSpans.length).toBe(2)
})
})
})
@@ -0,0 +1,485 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import Datasets from './datasets'
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
// Mock ahooks
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useHover: () => false,
}
})
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
// Mock service hooks - will be overridden in individual tests
const mockFetchNextPage = vi.fn()
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetList: vi.fn(() => ({
data: {
pages: [
{
data: [
createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
],
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
})),
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// Mock app context - will be overridden in tests
vi.mock('@/context/app-context', () => ({
useSelector: vi.fn(() => true),
}))
// Mock useDatasetCardState hook
vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
tags: [],
setTags: vi.fn(),
modalState: {
showRenameModal: false,
showConfirmDelete: false,
confirmMessage: '',
},
openRenameModal: vi.fn(),
closeRenameModal: vi.fn(),
closeConfirmDelete: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
onConfirmDelete: vi.fn(),
}),
}))
// Mock RenameDatasetModal
vi.mock('../rename-modal', () => ({
default: () => null,
}))
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
return {
id: 'dataset-1',
name: 'Test Dataset',
description: 'Test description',
provider: 'vendor',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
embedding_available: true,
app_count: 5,
document_count: 10,
word_count: 1000,
created_at: 1609459200,
updated_at: 1609545600,
tags: [],
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
created_by: 'user-1',
doc_form: ChunkingMode.text,
runtime_mode: 'general',
is_published: true,
total_available_documents: 10,
icon_info: {
icon: '📙',
icon_type: 'emoji' as const,
icon_background: '#FFF4ED',
icon_url: '',
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
},
author_name: 'Test User',
...overrides,
} as DataSet
}
// Store IntersectionObserver callbacks for testing
let intersectionObserverCallback: IntersectionObserverCallback | null = null
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
const mockUnobserve = vi.fn()
// Custom IntersectionObserver mock
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
intersectionObserverCallback = callback
}
observe = mockObserve
disconnect = mockDisconnect
unobserve = mockUnobserve
root = null
rootMargin = ''
thresholds = []
takeRecords = () => []
}
describe('Datasets', () => {
const defaultProps = {
tags: [],
keywords: '',
includeAll: false,
}
beforeEach(() => {
vi.clearAllMocks()
intersectionObserverCallback = null
document.title = ''
// Setup IntersectionObserver mock
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should render NewDatasetCard when user is editor', async () => {
const { useSelector } = await import('@/context/app-context')
vi.mocked(useSelector).mockReturnValue(true)
render(<Datasets {...defaultProps} />)
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
it('should NOT render NewDatasetCard when user is NOT editor', async () => {
const { useSelector } = await import('@/context/app-context')
vi.mocked(useSelector).mockReturnValue(false)
render(<Datasets {...defaultProps} />)
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
})
it('should render dataset cards from data', () => {
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
})
it('should render anchor div for infinite scroll', () => {
render(<Datasets {...defaultProps} />)
const anchor = document.querySelector('.h-0')
expect(anchor).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass tags to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
tag_ids: ['tag-1', 'tag-2'],
}),
)
})
it('should pass keywords to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} keywords="search term" />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'search term',
}),
)
})
it('should pass includeAll to useDatasetList', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
render(<Datasets {...defaultProps} includeAll={true} />)
expect(useDatasetList).toHaveBeenCalledWith(
expect.objectContaining({
include_all: true,
}),
)
})
})
describe('Document Title', () => {
it('should set document title on mount', async () => {
render(<Datasets {...defaultProps} />)
await waitFor(() => {
expect(document.title).toContain('dataset.knowledge')
})
})
})
describe('Loading States', () => {
it('should show Loading component when isFetchingNextPage is true', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: true,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Loading component renders a div with loading classes
const nav = screen.getByRole('navigation')
expect(nav).toBeInTheDocument()
})
it('should NOT show Loading component when isFetchingNextPage is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
})
describe('DatasetList null handling', () => {
it('should handle null datasetList gracefully', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: null,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle undefined datasetList gracefully', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: undefined,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty pages array', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
})
describe('IntersectionObserver', () => {
it('should setup IntersectionObserver on mount', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Should observe the anchor element
expect(mockObserve).toHaveBeenCalled()
})
it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
// Simulate intersection
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('should NOT call fetchNextPage when isIntersecting is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should NOT call fetchNextPage when hasNextPage is false', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: false, // No more pages
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should NOT call fetchNextPage when isFetching is true', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: true, // Already fetching
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
if (intersectionObserverCallback) {
intersectionObserverCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should disconnect observer on unmount', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [] }] },
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
const { unmount } = render(<Datasets {...defaultProps} />)
// Unmount the component
unmount()
// disconnect should be called during cleanup
expect(mockDisconnect).toHaveBeenCalled()
})
})
describe('Styles', () => {
it('should have correct grid styling', () => {
render(<Datasets {...defaultProps} />)
const nav = screen.getByRole('navigation')
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
})
})
describe('Edge Cases', () => {
it('should handle empty tags array', () => {
render(<Datasets {...defaultProps} tags={[]} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty keywords', () => {
render(<Datasets {...defaultProps} keywords="" />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle multiple pages of data', async () => {
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: {
pages: [
{ data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
{ data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<Datasets {...defaultProps} />)
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,368 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from './index'
// Mock next/navigation
const mockPush = vi.fn()
const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
}),
}))
// Mock ahooks
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
...actual,
useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
useDebounceFn: (fn: () => void) => ({ run: fn }),
useHover: () => false,
}
})
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'admin' },
isCurrentWorkspaceOwner: true,
}),
useSelector: () => true,
}))
// Mock global public context
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
// Mock external api panel context
const mockSetShowExternalApiPanel = vi.fn()
vi.mock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: false,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
// Mock tag management store
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: () => false,
}))
// Mock useDocumentTitle hook
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
// Mock useFormatTimeFromNow hook
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
}),
}))
// Mock useKnowledge hook
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
// Mock service hooks
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetList: vi.fn(() => ({
data: { pages: [{ data: [] }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
})),
useInvalidDatasetList: () => vi.fn(),
useDatasetApiBaseUrl: () => ({
data: { api_base_url: 'https://api.example.com' },
}),
}))
// Mock Datasets component
vi.mock('./datasets', () => ({
default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
<div data-testid="datasets-component">
<span data-testid="tags">{tags.join(',')}</span>
<span data-testid="keywords">{keywords}</span>
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
</div>
),
}))
// Mock DatasetFooter component
vi.mock('./dataset-footer', () => ({
default: () => <footer data-testid="dataset-footer">Footer</footer>,
}))
// Mock ExternalAPIPanel component
vi.mock('../external-api/external-api-panel', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="external-api-panel">
<button onClick={onClose}>Close Panel</button>
</div>
),
}))
// Mock TagManagementModal
vi.mock('@/app/components/base/tag-management', () => ({
default: () => <div data-testid="tag-management-modal" />,
}))
// Mock TagFilter
vi.mock('@/app/components/base/tag-management/filter', () => ({
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
<div data-testid="tag-filter">
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
</div>
),
}))
// Mock CheckboxWithLabel
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onChange}
data-testid="include-all-checkbox"
/>
{label}
</label>
),
}))
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
})
it('should render the search input', () => {
render(<List />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should render external API panel button', () => {
render(<List />)
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
})
it('should render dataset footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass includeAll prop to Datasets', () => {
render(<List />)
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
})
it('should pass empty keywords initially', () => {
render(<List />)
expect(screen.getByTestId('keywords')).toHaveTextContent('')
})
it('should pass empty tags initially', () => {
render(<List />)
expect(screen.getByTestId('tags')).toHaveTextContent('')
})
})
describe('User Interactions', () => {
it('should open external API panel when button is clicked', () => {
render(<List />)
const button = screen.getByText(/externalAPIPanelTitle/)
fireEvent.click(button)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
})
it('should update search input value', () => {
render(<List />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(input).toHaveValue('test search')
})
it('should trigger tag filter change', () => {
render(<List />)
// Tag filter is rendered and interactive
const selectTagsBtn = screen.getByText('Select Tags')
expect(selectTagsBtn).toBeInTheDocument()
fireEvent.click(selectTagsBtn)
// The onChange callback was triggered (debounced)
})
})
describe('Conditional Rendering', () => {
it('should show include all checkbox for workspace owner', () => {
render(<List />)
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
})
})
describe('Styles', () => {
it('should have correct container styling', () => {
const { container } = render(<List />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
})
})
describe('Edge Cases', () => {
it('should handle empty state gracefully', () => {
render(<List />)
// Should render without errors even with empty data
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
})
})
describe('Branch Coverage', () => {
it('should redirect normal role users to /apps', async () => {
// Re-mock useAppContext with normal role
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'normal' },
isCurrentWorkspaceOwner: false,
}),
useSelector: () => true,
}))
// Clear module cache and re-import
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should clear search input when onClear is called', () => {
render(<List />)
const input = screen.getByRole('textbox')
// First set a value
fireEvent.change(input, { target: { value: 'test search' } })
expect(input).toHaveValue('test search')
// Find and click the clear button
const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
if (clearButton) {
fireEvent.click(clearButton)
expect(input).toHaveValue('')
}
})
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
// Re-mock to show external API panel
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
})
it('should close ExternalAPIPanel when onClose is called', async () => {
vi.doMock('@/context/external-api-panel-context', () => ({
useExternalApiPanel: () => ({
showExternalApiPanel: true,
setShowExternalApiPanel: mockSetShowExternalApiPanel,
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
const closeButton = screen.getByText('Close Panel')
fireEvent.click(closeButton)
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
})
it('should show TagManagementModal when showTagManagementModal is true', async () => {
vi.doMock('@/app/components/base/tag-management/store', () => ({
useStore: () => true, // showTagManagementModal is true
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('should not show DatasetFooter when branding is enabled', async () => {
vi.doMock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: true },
},
}),
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
})
it('should not show include all checkbox when not workspace owner', async () => {
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'editor' },
isCurrentWorkspaceOwner: false,
}),
useSelector: () => true,
}))
vi.resetModules()
const { default: ListComponent } = await import('./index')
render(<ListComponent />)
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
})
})
})
+11 -3
View File
@@ -14,13 +14,14 @@ import TagFilter from '@/app/components/base/tag-management/filter'
// Hooks
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { useAppContext } from '@/context/app-context'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
// Components
import ExternalAPIPanel from '../external-api/external-api-panel'
import ServiceApi from '../extra-info/service-api'
import DatasetFooter from './dataset-footer'
import Datasets from './datasets'
@@ -58,6 +59,9 @@ const List = () => {
return router.replace('/apps')
}, [currentWorkspace, router])
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<div className="scroll-container relative flex grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex items-center justify-end gap-x-1 bg-background-body px-12 pb-2 pt-4">
@@ -81,6 +85,11 @@ const List = () => {
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
{
isCurrentWorkspaceManager && (
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
)
}
<div className="h-4 w-[1px] bg-divider-regular" />
<Button
className="shadows-shadow-xs gap-0.5"
@@ -96,7 +105,6 @@ const List = () => {
{showTagManagementModal && (
<TagManagementModal type="knowledge" show={showTagManagementModal} />
)}
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
</div>
)
@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import CreateAppCard from './index'
describe('CreateAppCard', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateAppCard />)
expect(screen.getAllByRole('link')).toHaveLength(3)
})
it('should render create dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
})
it('should render create from pipeline option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
})
it('should render connect dataset option', () => {
render(<CreateAppCard />)
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should have correct displayName', () => {
expect(CreateAppCard.displayName).toBe('CreateAppCard')
})
})
describe('Links', () => {
it('should have correct href for create dataset', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[0]).toHaveAttribute('href', '/datasets/create')
})
it('should have correct href for create from pipeline', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
})
it('should have correct href for connect dataset', () => {
render(<CreateAppCard />)
const links = screen.getAllByRole('link')
expect(links[2]).toHaveAttribute('href', '/datasets/connect')
})
})
describe('Styles', () => {
it('should have correct card styling', () => {
const { container } = render(<CreateAppCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
})
it('should have border separator for connect option', () => {
const { container } = render(<CreateAppCard />)
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
expect(borderDiv).toBeInTheDocument()
})
})
describe('Icons', () => {
it('should render three icons for three options', () => {
const { container } = render(<CreateAppCard />)
// Each option has an icon
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThanOrEqual(3)
})
})
})
@@ -0,0 +1,78 @@
import { RiAddLine } from '@remixicon/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Option from './option'
describe('Option', () => {
const defaultProps = {
Icon: RiAddLine,
text: 'Test Option',
href: '/test-path',
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Option {...defaultProps} />)
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should render the text content', () => {
render(<Option {...defaultProps} />)
expect(screen.getByText('Test Option')).toBeInTheDocument()
})
it('should render the icon', () => {
render(<Option {...defaultProps} />)
// Icon should be rendered with correct size class
const icon = document.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
})
describe('Props', () => {
it('should have correct href attribute', () => {
render(<Option {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/test-path')
})
it('should render different text based on props', () => {
render(<Option {...defaultProps} text="Different Text" />)
expect(screen.getByText('Different Text')).toBeInTheDocument()
})
it('should render different href based on props', () => {
render(<Option {...defaultProps} href="/different-path" />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/different-path')
})
})
describe('Styles', () => {
it('should have correct base styling', () => {
render(<Option {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
})
it('should have text span with correct styling', () => {
render(<Option {...defaultProps} />)
const textSpan = screen.getByText('Test Option')
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<Option {...defaultProps} text="" />)
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle long text', () => {
const longText = 'A'.repeat(100)
render(<Option {...defaultProps} text={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
})
})
@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import AddedMetadataButton from './add-metadata-button'
describe('AddedMetadataButton', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddedMetadataButton />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with translated text', () => {
render(<AddedMetadataButton />)
// The button should contain text from i18n
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<AddedMetadataButton />)
// Check if there's an SVG element (the RiAddLine icon)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<AddedMetadataButton className="custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('should apply default classes', () => {
render(<AddedMetadataButton />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center')
})
it('should merge custom className with default classes', () => {
render(<AddedMetadataButton className="my-custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
})
})
describe('User Interactions', () => {
it('should call onClick when button is clicked', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not throw when onClick is not provided and button is clicked', () => {
render(<AddedMetadataButton />)
expect(() => {
fireEvent.click(screen.getByRole('button'))
}).not.toThrow()
})
it('should call onClick multiple times on multiple clicks', () => {
const handleClick = vi.fn()
render(<AddedMetadataButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(3)
})
})
describe('Edge Cases', () => {
it('should render with undefined className', () => {
render(<AddedMetadataButton className={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<AddedMetadataButton className="" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render with undefined onClick', () => {
render(<AddedMetadataButton onClick={undefined} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,287 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import WrappedDatePicker from './date-picker'
type TriggerArgs = {
handleClickTrigger: () => void
}
type DatePickerProps = {
onChange: (value: Date | null) => void
onClear: () => void
renderTrigger: (args: TriggerArgs) => React.ReactNode
value?: Date
}
// Mock the base date picker component
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
const trigger = renderTrigger({
handleClickTrigger: () => {},
})
return (
<div data-testid="date-picker-wrapper">
{trigger}
<button data-testid="select-date" onClick={() => onChange(value || null)}>
Select Date
</button>
<button data-testid="clear-date" onClick={() => onClear()}>
Clear
</button>
</div>
)
},
}))
// Mock useTimestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (timestamp: number) => {
if (!timestamp)
return ''
return new Date(timestamp * 1000).toLocaleDateString()
},
}),
}))
describe('WrappedDatePicker', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render placeholder text when no value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
// When no value, should show placeholder from i18n
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render formatted date when value is provided', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should render calendar icon', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render select date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('select-date')).toBeInTheDocument()
})
it('should render clear date button', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
expect(screen.getByTestId('clear-date')).toBeInTheDocument()
})
it('should render close icon for clearing', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// RiCloseCircleFill should be rendered
const closeIcon = container.querySelectorAll('svg')
expect(closeIcon.length).toBeGreaterThan(0)
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const { container } = render(
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
)
const triggerElement = container.querySelector('.custom-class')
expect(triggerElement).toBeInTheDocument()
})
it('should accept undefined value', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should accept number value', () => {
const handleChange = vi.fn()
const timestamp = 1609459200 // 2021-01-01
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange with timestamp when date is selected', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('select-date'))
expect(handleChange).toHaveBeenCalled()
})
it('should call onChange with null when date is cleared via onClear', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('clear-date'))
expect(handleChange).toHaveBeenCalledWith(null)
})
it('should call onChange with null when close icon is clicked directly', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// Find the RiCloseCircleFill icon (it has specific classes)
const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
if (closeIcon) {
fireEvent.click(closeIcon)
expect(handleChange).toHaveBeenCalledWith(null)
}
})
it('should show close button on hover when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
// The close icon should be present but hidden initially
const triggerGroup = container.querySelector('.group')
expect(triggerGroup).toBeInTheDocument()
})
it('should handle clicking on trigger element', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const trigger = container.querySelector('.group.flex')
if (trigger)
fireEvent.click(trigger)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have tertiary text color when no value', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const textElement = container.querySelector('.text-text-tertiary')
expect(textElement).toBeInTheDocument()
})
it('should have secondary text color when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const textElement = container.querySelector('.text-text-secondary')
expect(textElement).toBeInTheDocument()
})
it('should have input background styling', () => {
const handleChange = vi.fn()
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
const bgElement = container.querySelector('.bg-components-input-bg-normal')
expect(bgElement).toBeInTheDocument()
})
it('should have quaternary text color for close icon when value exists', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
const { container } = render(
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
)
const closeIcon = container.querySelector('.text-text-quaternary')
expect(closeIcon).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle timestamp of 0', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker value={0} onChange={handleChange} />)
// 0 is falsy but is a valid timestamp (epoch)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle very large timestamp', () => {
const handleChange = vi.fn()
const farFuture = 4102444800 // 2100-01-01
render(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle switching between no value and value', () => {
const handleChange = vi.fn()
const { rerender } = render(
<WrappedDatePicker onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
const timestamp = Math.floor(Date.now() / 1000)
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
})
it('should handle clearing date multiple times', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('clear-date'))
fireEvent.click(screen.getByTestId('clear-date'))
fireEvent.click(screen.getByTestId('clear-date'))
expect(handleChange).toHaveBeenCalledTimes(3)
})
it('should handle rapid date selections', () => {
const handleChange = vi.fn()
const timestamp = Math.floor(Date.now() / 1000)
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
fireEvent.click(screen.getByTestId('select-date'))
fireEvent.click(screen.getByTestId('select-date'))
fireEvent.click(screen.getByTestId('select-date'))
expect(handleChange).toHaveBeenCalledTimes(3)
})
it('should handle onChange with date object that has unix method', () => {
const handleChange = vi.fn()
render(<WrappedDatePicker onChange={handleChange} />)
// The mock triggers onChange with the value prop
fireEvent.click(screen.getByTestId('select-date'))
// onChange should have been called
expect(handleChange).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,257 @@
import type { MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import AddRow from './add-row'
type InputCombinedProps = {
type: DataType
value: string | number | null
onChange: (value: string | number) => void
}
type LabelProps = {
text: string
}
// Mock InputCombined component
vi.mock('./input-combined', () => ({
default: ({ type, value, onChange }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>,
}))
describe('AddRow', () => {
const mockPayload: MetadataItemWithEdit = {
id: 'test-id',
name: 'test_field',
type: DataType.string,
value: 'test value',
}
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined component', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render remove button icon', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should pass correct type to input combined', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string)
})
it('should pass correct value to input combined', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveValue('test value')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow
payload={mockPayload}
onChange={handleChange}
onRemove={handleRemove}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should have default flex styling', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(container.firstChild).toHaveClass('flex', 'h-6', 'items-center', 'space-x-0.5')
})
it('should handle different data types', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const numberPayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.number,
value: 42,
}
render(
<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
})
})
describe('User Interactions', () => {
it('should call onChange with updated payload when input changes', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith({
...mockPayload,
value: 'new value',
})
})
it('should call onRemove when remove button is clicked', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
if (removeButton)
fireEvent.click(removeButton)
expect(handleRemove).toHaveBeenCalledTimes(1)
})
it('should preserve other payload properties on change', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } })
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-id',
name: 'test_field',
type: DataType.string,
}),
)
})
})
describe('Remove Button Styling', () => {
it('should have hover styling on remove button', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive')
})
})
describe('Edge Cases', () => {
it('should handle null value', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const nullPayload: MetadataItemWithEdit = {
...mockPayload,
value: null,
}
render(
<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should handle empty string value', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const emptyPayload: MetadataItemWithEdit = {
...mockPayload,
value: '',
}
render(
<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveValue('')
})
it('should handle time type payload', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const timePayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.time,
value: 1609459200,
}
render(
<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
})
it('should handle multiple onRemove calls', () => {
const handleChange = vi.fn()
const handleRemove = vi.fn()
const { container } = render(
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
)
const removeButton = container.querySelector('.cursor-pointer')
if (removeButton) {
fireEvent.click(removeButton)
fireEvent.click(removeButton)
fireEvent.click(removeButton)
}
expect(handleRemove).toHaveBeenCalledTimes(3)
})
})
})
@@ -0,0 +1,395 @@
import type { MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import EditMetadatabatchItem from './edit-row'
type InputCombinedProps = {
type: DataType
value: string | number | null
onChange: (value: string | number) => void
readOnly?: boolean
}
type MultipleValueInputProps = {
onClear: () => void
readOnly?: boolean
}
type LabelProps = {
text: string
isDeleted?: boolean
}
type EditedBeaconProps = {
onReset: () => void
}
// Mock InputCombined component
vi.mock('./input-combined', () => ({
default: ({ type, value, onChange, readOnly }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
),
}))
// Mock InputHasSetMultipleValue component
vi.mock('./input-has-set-multiple-value', () => ({
default: ({ onClear, readOnly }: MultipleValueInputProps) => (
<div data-testid="multiple-value-input" data-readonly={readOnly}>
<button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button>
</div>
),
}))
// Mock Label component
vi.mock('./label', () => ({
default: ({ text, isDeleted }: LabelProps) => (
<div data-testid="label" data-deleted={isDeleted}>{text}</div>
),
}))
// Mock EditedBeacon component
vi.mock('./edited-beacon', () => ({
default: ({ onReset }: EditedBeaconProps) => (
<button data-testid="edited-beacon" onClick={onReset}>Reset</button>
),
}))
describe('EditMetadatabatchItem', () => {
const mockPayload: MetadataItemWithEdit = {
id: 'test-id',
name: 'test_field',
type: DataType.string,
value: 'test value',
isMultipleValue: false,
isUpdated: false,
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render label with payload name', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
})
it('should render input combined for single value', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
it('should render multiple value input when isMultipleValue is true', () => {
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
})
it('should render delete button icon', () => {
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Updated State', () => {
it('should show edited beacon when isUpdated is true', () => {
const updatedPayload: MetadataItemWithEdit = {
...mockPayload,
isUpdated: true,
}
render(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
})
it('should not show edited beacon when isUpdated is false', () => {
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.queryByTestId('edited-beacon')).not.toBeInTheDocument()
})
})
describe('Deleted State', () => {
it('should pass isDeleted to label when updateType is delete', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
})
it('should set readOnly on input when deleted', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('readonly')
})
it('should have destructive styling on delete button when deleted', () => {
const deletedPayload: MetadataItemWithEdit = {
...mockPayload,
updateType: UpdateType.delete,
}
const { container } = render(
<EditMetadatabatchItem
payload={deletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
const deleteButton = container.querySelector('.bg-state-destructive-hover')
expect(deleteButton).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange with updated payload when input changes', () => {
const handleChange = vi.fn()
render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
...mockPayload,
value: 'new value',
}),
)
})
it('should call onRemove with id when delete button is clicked', () => {
const handleRemove = vi.fn()
const { container } = render(
<EditMetadatabatchItem
payload={mockPayload}
onChange={vi.fn()}
onRemove={handleRemove}
onReset={vi.fn()}
/>,
)
const deleteButton = container.querySelector('.cursor-pointer')
if (deleteButton)
fireEvent.click(deleteButton)
expect(handleRemove).toHaveBeenCalledWith('test-id')
})
it('should call onReset with id when reset beacon is clicked', () => {
const handleReset = vi.fn()
const updatedPayload: MetadataItemWithEdit = {
...mockPayload,
isUpdated: true,
}
render(
<EditMetadatabatchItem
payload={updatedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={handleReset}
/>,
)
fireEvent.click(screen.getByTestId('edited-beacon'))
expect(handleReset).toHaveBeenCalledWith('test-id')
})
it('should call onChange to clear multiple value', () => {
const handleChange = vi.fn()
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={handleChange}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('clear-multiple'))
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
value: null,
isMultipleValue: false,
}),
)
})
})
describe('Multiple Value State', () => {
it('should render multiple value input when isMultipleValue is true', () => {
const multiplePayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
}
render(
<EditMetadatabatchItem
payload={multiplePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
expect(screen.queryByTestId('input-combined')).not.toBeInTheDocument()
})
it('should pass readOnly to multiple value input when deleted', () => {
const multipleDeletedPayload: MetadataItemWithEdit = {
...mockPayload,
isMultipleValue: true,
updateType: UpdateType.delete,
}
render(
<EditMetadatabatchItem
payload={multipleDeletedPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('multiple-value-input')).toHaveAttribute('data-readonly', 'true')
})
})
describe('Edge Cases', () => {
it('should handle payload with number type', () => {
const numberPayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.number,
value: 42,
}
render(
<EditMetadatabatchItem
payload={numberPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
})
it('should handle payload with time type', () => {
const timePayload: MetadataItemWithEdit = {
...mockPayload,
type: DataType.time,
value: 1609459200,
}
render(
<EditMetadatabatchItem
payload={timePayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
})
it('should handle null value', () => {
const nullPayload: MetadataItemWithEdit = {
...mockPayload,
value: null,
}
render(
<EditMetadatabatchItem
payload={nullPayload}
onChange={vi.fn()}
onRemove={vi.fn()}
onReset={vi.fn()}
/>,
)
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,179 @@
import { fireEvent, render, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EditedBeacon from './edited-beacon'
describe('EditedBeacon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct size', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
})
it('should render beacon dot by default (not hovering)', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// When not hovering, should show the small beacon dot
const beaconDot = container.querySelector('.size-1')
expect(beaconDot).toBeInTheDocument()
})
})
describe('Hover State', () => {
it('should show reset icon on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
// On hover, should show the reset icon (RiResetLeftLine)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
it('should show beacon dot when not hovering', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// By default (not hovering), should show beacon dot
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).toBeInTheDocument()
})
it('should hide beacon dot on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
// On hover, the small beacon dot should be hidden
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).not.toBeInTheDocument()
})
})
it('should show beacon dot again on mouse leave', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Hover
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
// Leave
fireEvent.mouseLeave(wrapper)
await waitFor(() => {
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
expect(beaconDot).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onReset when reset button is clicked', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Hover to show reset button
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
const resetButton = container.querySelector('.bg-text-accent-secondary')
expect(resetButton).toBeInTheDocument()
})
// Find and click the reset button (the clickable element with onClick)
const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary')
if (clickableElement) {
fireEvent.click(clickableElement)
}
expect(handleReset).toHaveBeenCalledTimes(1)
})
it('should not call onReset when clicking beacon dot (not hovering)', () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
// Click on the wrapper when not hovering
const wrapper = container.firstChild as HTMLElement
fireEvent.click(wrapper)
// onReset should not be called because we're not hovering
expect(handleReset).not.toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should render tooltip on hover', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
fireEvent.mouseEnter(wrapper)
// Tooltip should be rendered (it wraps the reset button)
await waitFor(() => {
const resetIcon = container.querySelector('svg')
expect(resetIcon).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle multiple hover/leave cycles', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
for (let i = 0; i < 3; i++) {
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
expect(container.querySelector('svg')).toBeInTheDocument()
})
fireEvent.mouseLeave(wrapper)
await waitFor(() => {
expect(container.querySelector('.size-1.rounded-full')).toBeInTheDocument()
})
}
})
it('should handle rapid hover/leave', async () => {
const handleReset = vi.fn()
const { container } = render(<EditedBeacon onReset={handleReset} />)
const wrapper = container.firstChild as HTMLElement
// Rapid hover/leave
fireEvent.mouseEnter(wrapper)
fireEvent.mouseLeave(wrapper)
fireEvent.mouseEnter(wrapper)
await waitFor(() => {
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})
})
@@ -0,0 +1,269 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import InputCombined from './input-combined'
type DatePickerProps = {
value: number | null
onChange: (value: number) => void
className?: string
}
// Mock the base date-picker component
vi.mock('../base/date-picker', () => ({
default: ({ value, onChange, className }: DatePickerProps) => (
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
{value || 'Pick date'}
</div>
),
}))
describe('InputCombined', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render text input for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
const input = screen.getByDisplayValue('test')
expect(input).toBeInTheDocument()
expect(input.tagName.toLowerCase()).toBe('input')
})
it('should render number input for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByDisplayValue('42')
expect(input).toBeInTheDocument()
})
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
})
describe('String Input', () => {
it('should call onChange with input value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new value' } })
expect(handleChange).toHaveBeenCalledWith('new value')
})
it('should display current value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
)
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
})
it('should apply readOnly prop to string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('readonly')
})
})
describe('Number Input', () => {
it('should call onChange with number value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '123' } })
expect(handleChange).toHaveBeenCalled()
})
it('should display current value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
})
it('should apply readOnly prop to number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('readonly')
})
})
describe('Time/Date Input', () => {
it('should render date picker for time type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
)
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
})
it('should call onChange when date is selected', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
)
fireEvent.click(screen.getByTestId('date-picker'))
expect(handleChange).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const handleChange = vi.fn()
const { container } = render(
<InputCombined
type={DataType.string}
value=""
onChange={handleChange}
className="custom-class"
/>,
)
// Check that custom class is applied to wrapper
const wrapper = container.querySelector('.custom-class')
expect(wrapper).toBeInTheDocument()
})
it('should handle null value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle undefined value for string type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should handle null value for number type', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct base styling for string input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveClass('h-6', 'grow', 'p-0.5', 'text-xs', 'rounded-md')
})
it('should have correct styling for number input', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('spinbutton')
expect(input).toHaveClass('rounded-l-md')
})
})
describe('Edge Cases', () => {
it('should handle empty string value', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should handle zero value for number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle negative number', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
})
it('should handle special characters in string', () => {
const handleChange = vi.fn()
render(
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
)
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle switching between types', () => {
const handleChange = vi.fn()
const { rerender } = render(
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
rerender(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,147 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import InputHasSetMultipleValue from './input-has-set-multiple-value'
describe('InputHasSetMultipleValue', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with correct wrapper styling', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5')
})
it('should render multiple value text', () => {
const handleClear = vi.fn()
render(<InputHasSetMultipleValue onClear={handleClear} />)
// The text should come from i18n
expect(screen.getByText(/multipleValue|Multiple/i)).toBeInTheDocument()
})
it('should render close icon when not readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
// Should have close icon (RiCloseLine)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should not show close icon when readOnly is true', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// Should not have close icon
const svg = container.querySelector('svg')
expect(svg).not.toBeInTheDocument()
})
it('should show close icon when readOnly is false', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should show close icon when readOnly is undefined', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should apply pr-1.5 padding when readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('pr-1.5')
})
it('should apply pr-0.5 padding when not readOnly', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('pr-0.5')
})
})
describe('User Interactions', () => {
it('should call onClear when close icon is clicked', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeIcon = container.querySelector('svg')
expect(closeIcon).toBeInTheDocument()
if (closeIcon) {
fireEvent.click(closeIcon)
}
expect(handleClear).toHaveBeenCalledTimes(1)
})
it('should not call onClear when readOnly and clicking on component', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
// Click on the wrapper
fireEvent.click(container.firstChild as HTMLElement)
expect(handleClear).not.toHaveBeenCalled()
})
it('should call onClear multiple times on multiple clicks', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeIcon = container.querySelector('svg')
if (closeIcon) {
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
}
expect(handleClear).toHaveBeenCalledTimes(3)
})
})
describe('Styling', () => {
it('should have badge styling', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const badge = container.querySelector('.inline-flex')
expect(badge).toHaveClass('h-5', 'items-center', 'rounded-[5px]', 'border-[0.5px]')
})
it('should have hover styles on close button wrapper', () => {
const handleClear = vi.fn()
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
const closeWrapper = container.querySelector('.cursor-pointer')
expect(closeWrapper).toHaveClass('hover:bg-state-base-hover', 'hover:text-text-secondary')
})
})
describe('Edge Cases', () => {
it('should render correctly when switching readOnly state', () => {
const handleClear = vi.fn()
const { container, rerender } = render(<InputHasSetMultipleValue onClear={handleClear} />)
// Initially not readOnly
expect(container.querySelector('svg')).toBeInTheDocument()
// Switch to readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
expect(container.querySelector('svg')).not.toBeInTheDocument()
// Switch back to not readOnly
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Label from './label'
describe('Label', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Label text="Test Label" />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render text with correct styling', () => {
render(<Label text="My Label" />)
const labelElement = screen.getByText('My Label')
expect(labelElement).toHaveClass('system-xs-medium', 'w-[136px]', 'shrink-0', 'truncate', 'text-text-tertiary')
})
it('should not have deleted styling by default', () => {
render(<Label text="Label" />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<Label text="Label" className="custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
render(<Label text="Label" className="my-custom-class" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('system-xs-medium', 'my-custom-class')
})
it('should apply deleted styling when isDeleted is true', () => {
render(<Label text="Label" isDeleted />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('text-text-quaternary', 'line-through')
})
it('should not apply deleted styling when isDeleted is false', () => {
render(<Label text="Label" isDeleted={false} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
})
it('should render different text values', () => {
const { rerender } = render(<Label text="First" />)
expect(screen.getByText('First')).toBeInTheDocument()
rerender(<Label text="Second" />)
expect(screen.getByText('Second')).toBeInTheDocument()
})
})
describe('Deleted State', () => {
it('should have strikethrough when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('line-through')
})
it('should have quaternary text color when deleted', () => {
render(<Label text="Deleted Label" isDeleted />)
const labelElement = screen.getByText('Deleted Label')
expect(labelElement).toHaveClass('text-text-quaternary')
})
it('should combine deleted styling with custom className', () => {
render(<Label text="Label" isDeleted className="custom" />)
const labelElement = screen.getByText('Label')
expect(labelElement).toHaveClass('line-through', 'custom')
})
})
describe('Edge Cases', () => {
it('should render with empty text', () => {
const { container } = render(<Label text="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with long text (truncation)', () => {
const longText = 'This is a very long label text that should be truncated'
render(<Label text={longText} />)
const labelElement = screen.getByText(longText)
expect(labelElement).toHaveClass('truncate')
})
it('should render with undefined className', () => {
render(<Label text="Label" className={undefined} />)
expect(screen.getByText('Label')).toBeInTheDocument()
})
it('should render with undefined isDeleted', () => {
render(<Label text="Label" isDeleted={undefined} />)
const labelElement = screen.getByText('Label')
expect(labelElement).not.toHaveClass('line-through')
})
it('should handle special characters in text', () => {
render(<Label text={'Label & "chars"'} />)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
it('should handle numbers in text', () => {
render(<Label text="Label 123" />)
expect(screen.getByText('Label 123')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,548 @@
import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import EditMetadataBatchModal from './modal'
// Mock service/API calls
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useCreateMetaData: () => ({
mutate: mockDoAddMetaData,
}),
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: 'existing-1', name: 'existing_field', type: DataType.string },
{ id: 'existing-2', name: 'another_field', type: DataType.number },
],
},
}),
}))
// Mock check name hook to control validation
let mockCheckNameResult = { errorMsg: '' }
vi.mock('../hooks/use-check-metadata-name', () => ({
default: () => ({
checkName: () => mockCheckNameResult,
}),
}))
// Mock Toast to verify notifications
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: unknown) => mockToastNotify(args),
},
}))
// Type definitions for mock props
type EditRowProps = {
payload: MetadataItemWithEdit
onChange: (item: MetadataItemWithEdit) => void
onRemove: (id: string) => void
onReset: (id: string) => void
}
type AddRowProps = {
payload: MetadataItemWithEdit
onChange: (item: MetadataItemWithEdit) => void
onRemove: () => void
}
type SelectModalProps = {
trigger: React.ReactNode
onSelect: (item: MetadataItemInBatchEdit) => void
onSave: (data: { name: string, type: DataType }) => Promise<void>
onManage: () => void
}
// Mock child components with callback exposure
vi.mock('./edit-row', () => ({
default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
<div data-testid="edit-row" data-id={payload.id}>
<span data-testid="edit-row-name">{payload.name}</span>
<button data-testid={`change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>Change</button>
<button data-testid={`remove-${payload.id}`} onClick={() => onRemove(payload.id)}>Remove</button>
<button data-testid={`reset-${payload.id}`} onClick={() => onReset(payload.id)}>Reset</button>
</div>
),
}))
vi.mock('./add-row', () => ({
default: ({ payload, onChange, onRemove }: AddRowProps) => (
<div data-testid="add-row" data-id={payload.id}>
<span data-testid="add-row-name">{payload.name}</span>
<button data-testid={`add-change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'new_value' })}>Change</button>
<button data-testid="add-remove" onClick={onRemove}>Remove</button>
</div>
),
}))
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-modal">
{trigger}
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => {})}>Save</button>
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
</div>
),
}))
describe('EditMetadataBatchModal', () => {
const mockList: MetadataItemInBatchEdit[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1', isMultipleValue: false },
{ id: '2', name: 'field_two', type: DataType.number, value: 42, isMultipleValue: false },
]
const defaultProps = {
datasetId: 'ds-1',
documentNum: 5,
list: mockList,
onSave: vi.fn(),
onHide: vi.fn(),
onShowManage: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockCheckNameResult = { errorMsg: '' }
})
describe('Rendering', () => {
it('should render without crashing', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should render document count', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText(/5/)).toBeInTheDocument()
})
})
it('should render all edit rows for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const editRows = screen.getAllByTestId('edit-row')
expect(editRows).toHaveLength(2)
})
})
it('should render field names for existing items', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
})
it('should render checkbox for apply to all', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0)
})
})
it('should render select metadata modal', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('select-modal')).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onHide when cancel button is clicked', async () => {
const onHide = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const cancelButton = screen.getByText(/cancel/i)
fireEvent.click(cancelButton)
expect(onHide).toHaveBeenCalled()
})
it('should call onSave when save button is clicked', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find the primary save button (not the one in SelectMetadataModal)
const saveButtons = screen.getAllByText(/save/i)
const modalSaveButton = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (modalSaveButton)
fireEvent.click(modalSaveButton)
expect(onSave).toHaveBeenCalled()
})
it('should toggle apply to all checkbox', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
expect(checkboxContainer).toBeInTheDocument()
if (checkboxContainer) {
fireEvent.click(checkboxContainer)
await waitFor(() => {
const checkIcon = checkboxContainer.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
}
})
it('should call onHide when modal close button is clicked', async () => {
const onHide = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Edit Row Operations', () => {
it('should update item value when change is triggered', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('change-1'))
// The component should update internally
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
it('should mark item as deleted when remove is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('remove-1'))
// The component should update internally - item marked as deleted
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
it('should reset item when reset is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First change the item
fireEvent.click(screen.getByTestId('change-1'))
// Then reset it
fireEvent.click(screen.getByTestId('reset-1'))
expect(screen.getAllByTestId('edit-row').length).toBe(2)
})
})
describe('Add Metadata Operations', () => {
it('should add new item when metadata is selected', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('select-metadata'))
// Should now have add-row for the new item
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
})
it('should remove added item when remove is clicked', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Then remove it
fireEvent.click(screen.getByTestId('add-remove'))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
})
})
it('should update added item when change is triggered', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// First add an item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Then change it
fireEvent.click(screen.getByTestId('add-change-new-1'))
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
it('should call doAddMetaData when saving new metadata with valid name', async () => {
mockCheckNameResult = { errorMsg: '' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
})
})
it('should show success toast when saving with valid name', async () => {
mockCheckNameResult = { errorMsg: '' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockDoAddMetaData).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should show error toast when saving with invalid name', async () => {
mockCheckNameResult = { errorMsg: 'Name already exists' }
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('save-metadata'))
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: 'Name already exists',
}),
)
})
})
it('should call onShowManage when manage is clicked', async () => {
const onShowManage = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onShowManage={onShowManage} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('manage-metadata'))
expect(onShowManage).toHaveBeenCalled()
})
})
describe('Props', () => {
it('should pass correct datasetId', async () => {
render(<EditMetadataBatchModal {...defaultProps} datasetId="custom-ds" />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should display correct document number', async () => {
render(<EditMetadataBatchModal {...defaultProps} documentNum={10} />)
await waitFor(() => {
expect(screen.getByText(/10/)).toBeInTheDocument()
})
})
it('should handle empty list', async () => {
render(<EditMetadataBatchModal {...defaultProps} list={[]} />)
await waitFor(() => {
expect(screen.queryByTestId('edit-row')).not.toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle list with multiple value items', async () => {
const multipleValueList: MetadataItemInBatchEdit[] = [
{ id: '1', name: 'field', type: DataType.string, value: null, isMultipleValue: true },
]
render(<EditMetadataBatchModal {...defaultProps} list={multipleValueList} />)
await waitFor(() => {
expect(screen.getByTestId('edit-row')).toBeInTheDocument()
})
})
it('should handle rapid save clicks', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find the primary save button
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn) {
fireEvent.click(saveBtn)
fireEvent.click(saveBtn)
fireEvent.click(saveBtn)
}
expect(onSave).toHaveBeenCalledTimes(3)
})
it('should pass correct arguments to onSave', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
expect(onSave).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Array),
expect.any(Boolean),
)
})
it('should pass isApplyToAllSelectDocument as true when checked', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
if (checkboxContainer)
fireEvent.click(checkboxContainer)
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Array),
true,
)
})
})
it('should filter out deleted items when saving', async () => {
const onSave = vi.fn()
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Remove an item
fireEvent.click(screen.getByTestId('remove-1'))
// Save
const saveButtons = screen.getAllByText(/save/i)
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
if (saveBtn)
fireEvent.click(saveBtn)
expect(onSave).toHaveBeenCalled()
// The first argument should not contain the deleted item (id '1')
const savedList = onSave.mock.calls[0][0] as MetadataItemInBatchEdit[]
const hasDeletedItem = savedList.some(item => item.id === '1')
expect(hasDeletedItem).toBe(false)
})
it('should handle multiple add and remove operations', async () => {
render(<EditMetadataBatchModal {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Add first item
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
// Remove it
fireEvent.click(screen.getByTestId('add-remove'))
await waitFor(() => {
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
})
// Add again
fireEvent.click(screen.getByTestId('select-metadata'))
await waitFor(() => {
expect(screen.getByTestId('add-row')).toBeInTheDocument()
})
})
})
})
@@ -0,0 +1,647 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType, UpdateType } from '../types'
import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata'
type DocMetadataItem = {
id: string
name: string
type: DataType
value: string | number | null
}
type DocListItem = {
id: string
name?: string
doc_metadata?: DocMetadataItem[] | null
}
type MetadataItemWithEdit = {
id: string
name: string
type: DataType
value: string | number | null
isMultipleValue?: boolean
updateType?: UpdateType
}
// Mock useBatchUpdateDocMetadata
const mockMutateAsync = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useBatchUpdateDocMetadata: () => ({
mutateAsync: mockMutateAsync,
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
describe('useBatchEditDocumentMetadata', () => {
const mockDocList: DocListItem[] = [
{
id: 'doc-1',
name: 'Document 1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
],
},
{
id: 'doc-2',
name: 'Document 2',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' },
],
},
]
const defaultProps = {
datasetId: 'ds-1',
docList: mockDocList as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
onUpdate: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Hook Initialization', () => {
it('should initialize with isShowEditModal as false', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(result.current.isShowEditModal).toBe(false)
})
it('should return showEditModal function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.showEditModal).toBe('function')
})
it('should return hideEditModal function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.hideEditModal).toBe('function')
})
it('should return originalList', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(Array.isArray(result.current.originalList)).toBe(true)
})
it('should return handleSave function', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(typeof result.current.handleSave).toBe('function')
})
})
describe('Modal Control', () => {
it('should show modal when showEditModal is called', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
})
it('should hide modal when hideEditModal is called', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
act(() => {
result.current.hideEditModal()
})
expect(result.current.isShowEditModal).toBe(false)
})
})
describe('Original List Processing', () => {
it('should compute originalList from docList metadata', () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
expect(result.current.originalList.length).toBeGreaterThan(0)
})
it('should filter out built-in metadata', () => {
const docListWithBuiltIn: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 123 },
{ id: '1', name: 'custom', type: DataType.string, value: 'test' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithBuiltIn as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const hasBuiltIn = result.current.originalList.some(item => item.id === 'built-in')
expect(hasBuiltIn).toBe(false)
})
it('should mark items with multiple values', () => {
const docListWithDifferentValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const fieldItem = result.current.originalList.find(item => item.id === '1')
expect(fieldItem?.isMultipleValue).toBe(true)
})
it('should not mark items with same values as multiple', () => {
const docListWithSameValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListWithSameValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const fieldItem = result.current.originalList.find(item => item.id === '1')
expect(fieldItem?.isMultipleValue).toBe(false)
})
it('should skip already marked multiple value items', () => {
// Three docs with same field but different values
const docListThreeDocs: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
],
},
{
id: 'doc-3',
doc_metadata: [
{ id: '1', name: 'field', type: DataType.string, value: 'Value C' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListThreeDocs as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Should only have one item for field '1', marked as multiple
const fieldItems = result.current.originalList.filter(item => item.id === '1')
expect(fieldItems.length).toBe(1)
expect(fieldItems[0].isMultipleValue).toBe(true)
})
})
describe('handleSave', () => {
it('should call mutateAsync with correct data', async () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should call onUpdate after successful save', async () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
})
})
it('should hide modal after successful save', async () => {
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
await act(async () => {
await result.current.handleSave([], [], false)
})
await waitFor(() => {
expect(result.current.isShowEditModal).toBe(false)
})
})
it('should handle edited items with changeValue updateType', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'New Value',
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-1',
metadata_list: expect.arrayContaining([
expect.objectContaining({
id: '1',
value: 'New Value',
}),
]),
}),
]),
}),
)
})
it('should handle removed items', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Only pass field_one in editedList, field_two should be removed
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'Value 1',
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should handle added items', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const addedList = [
{
id: 'new-1',
name: 'new_field',
type: DataType.string,
value: 'New Value',
isMultipleValue: false,
},
]
await act(async () => {
await result.current.handleSave([], addedList, false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
name: 'new_field',
}),
]),
}),
]),
}),
)
})
it('should add missing metadata when isApplyToAllSelectDocument is true', async () => {
// Doc 1 has field, Doc 2 doesn't have it
const docListMissingField: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
],
},
{
id: 'doc-2',
doc_metadata: [],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListMissingField as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'Updated Value',
isMultipleValue: false,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], true)
})
// Both documents should have the field after applying to all
expect(mockMutateAsync).toHaveBeenCalled()
const callArgs = mockMutateAsync.mock.calls[0][0]
expect(callArgs.metadata_list.length).toBe(2)
})
it('should not add missing metadata for multiple value items when isApplyToAllSelectDocument is true', async () => {
// Two docs with different values for same field
const docListDifferentValues: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value A' },
],
},
{
id: 'doc-2',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value B' },
],
},
{
id: 'doc-3',
doc_metadata: [],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Mark it as multiple value item - should not be added to doc-3
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: null,
isMultipleValue: true,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], true)
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should update existing items in the list', async () => {
const docListSingleDoc: DocListItem[] = [
{
id: 'doc-1',
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
{ id: '2', name: 'field_two', type: DataType.number, value: 100 },
],
},
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
// Edit both items
const editedList: MetadataItemWithEdit[] = [
{
id: '1',
name: 'field_one',
type: DataType.string,
value: 'New Value 1',
updateType: UpdateType.changeValue,
},
{
id: '2',
name: 'field_two',
type: DataType.number,
value: 200,
updateType: UpdateType.changeValue,
},
]
await act(async () => {
await result.current.handleSave(editedList, [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({ id: '1', value: 'New Value 1' }),
expect.objectContaining({ id: '2', value: 200 }),
]),
}),
]),
}),
)
})
})
describe('Selected Document IDs', () => {
it('should use selectedDocumentIds when provided', async () => {
const selectedIds = ['doc-1']
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
selectedDocumentIds: selectedIds,
}),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
dataset_id: 'ds-1',
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-1',
}),
]),
}),
)
})
it('should handle selectedDocumentIds not in docList', async () => {
// Select a document that's not in docList
const selectedIds = ['doc-1', 'doc-not-in-list']
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
selectedDocumentIds: selectedIds,
}),
)
await act(async () => {
await result.current.handleSave([], [], false)
})
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
metadata_list: expect.arrayContaining([
expect.objectContaining({
document_id: 'doc-not-in-list',
partial_update: true,
}),
]),
}),
)
})
})
describe('Edge Cases', () => {
it('should handle empty docList', () => {
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: [] as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
expect(result.current.originalList).toEqual([])
})
it('should handle documents without metadata', () => {
const docListNoMetadata: DocListItem[] = [
{ id: 'doc-1', name: 'Doc 1' },
{ id: 'doc-2', name: 'Doc 2', doc_metadata: null },
]
const { result } = renderHook(() =>
useBatchEditDocumentMetadata({
...defaultProps,
docList: docListNoMetadata as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
}),
)
expect(result.current.originalList).toEqual([])
})
})
})
@@ -0,0 +1,166 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import useCheckMetadataName from './use-check-metadata-name'
describe('useCheckMetadataName', () => {
describe('Hook Initialization', () => {
it('should return an object with checkName function', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current).toHaveProperty('checkName')
expect(typeof result.current.checkName).toBe('function')
})
})
describe('checkName - Empty Name Validation', () => {
it('should return error for empty string', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('')
expect(errorMsg).toBeTruthy()
})
it('should return error for whitespace-only string', () => {
const { result } = renderHook(() => useCheckMetadataName())
// Whitespace is not valid since it doesn't match the pattern
const { errorMsg } = result.current.checkName(' ')
expect(errorMsg).toBeTruthy()
})
})
describe('checkName - Pattern Validation', () => {
it('should return error for name starting with number', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('1name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name starting with uppercase', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('Name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name starting with underscore', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('_name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with spaces', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('my name')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with special characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name-with-dash')
expect(errorMsg).toBeTruthy()
})
it('should return error for name with dots', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name.with.dot')
expect(errorMsg).toBeTruthy()
})
it('should accept valid name starting with lowercase letter', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('validname')
expect(errorMsg).toBe('')
})
it('should accept valid name with numbers after first character', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name123')
expect(errorMsg).toBe('')
})
it('should accept valid name with underscores after first character', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name_with_underscore')
expect(errorMsg).toBe('')
})
it('should accept single lowercase letter', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('a')
expect(errorMsg).toBe('')
})
})
describe('checkName - Length Validation', () => {
it('should return error for name longer than 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const longName = 'a'.repeat(256)
const { errorMsg } = result.current.checkName(longName)
expect(errorMsg).toBeTruthy()
})
it('should accept name with exactly 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const maxLengthName = 'a'.repeat(255)
const { errorMsg } = result.current.checkName(maxLengthName)
expect(errorMsg).toBe('')
})
it('should accept name with less than 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const shortName = 'a'.repeat(100)
const { errorMsg } = result.current.checkName(shortName)
expect(errorMsg).toBe('')
})
})
describe('checkName - Edge Cases', () => {
it('should validate all lowercase letters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('abcdefghijklmnopqrstuvwxyz')
expect(errorMsg).toBe('')
})
it('should validate name with mixed numbers and underscores', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('a1_2_3_test')
expect(errorMsg).toBe('')
})
it('should reject uppercase letters anywhere in name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('nameWithUppercase')
expect(errorMsg).toBeTruthy()
})
it('should reject unicode characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('名字')
expect(errorMsg).toBeTruthy()
})
it('should reject emoji characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('name😀')
expect(errorMsg).toBeTruthy()
})
})
describe('Return Value Structure', () => {
it('should return object with errorMsg property', () => {
const { result } = renderHook(() => useCheckMetadataName())
const returnValue = result.current.checkName('test')
expect(returnValue).toHaveProperty('errorMsg')
})
it('should return empty string for valid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('valid_name')
expect(errorMsg).toBe('')
})
it('should return non-empty string for invalid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const { errorMsg } = result.current.checkName('')
expect(typeof errorMsg).toBe('string')
expect(errorMsg.length).toBeGreaterThan(0)
})
})
})
@@ -0,0 +1,308 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import useEditDatasetMetadata from './use-edit-dataset-metadata'
// Mock service hooks
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
const mockDoRenameMetaData = vi.fn().mockResolvedValue({})
const mockDoDeleteMetaData = vi.fn().mockResolvedValue({})
const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
],
built_in_field_enabled: false,
},
}),
useCreateMetaData: () => ({
mutate: mockDoAddMetaData,
}),
useRenameMeta: () => ({
mutate: mockDoRenameMetaData,
}),
useDeleteMetaData: () => ({
mutateAsync: mockDoDeleteMetaData,
}),
useUpdateBuiltInStatus: () => ({
mutateAsync: mockToggleBuiltInStatus,
}),
useBuiltInMetaDataFields: () => ({
data: {
fields: [
{ name: 'created_at', type: DataType.time },
{ name: 'modified_at', type: DataType.time },
],
},
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock useCheckMetadataName
vi.mock('./use-check-metadata-name', () => ({
default: () => ({
checkName: (name: string) => ({
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
}),
}),
}))
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
describe('useEditDatasetMetadata', () => {
const defaultProps = {
datasetId: 'ds-1',
onUpdateDocList: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.getItem.mockReturnValue(null)
})
describe('Hook Initialization', () => {
it('should initialize with isShowEditModal as false', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.isShowEditModal).toBe(false)
})
it('should return showEditModal function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.showEditModal).toBe('function')
})
it('should return hideEditModal function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.hideEditModal).toBe('function')
})
it('should return datasetMetaData', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.datasetMetaData).toBeDefined()
})
it('should return handleAddMetaData function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleAddMetaData).toBe('function')
})
it('should return handleRename function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleRename).toBe('function')
})
it('should return handleDeleteMetaData function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.handleDeleteMetaData).toBe('function')
})
it('should return builtInMetaData', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(result.current.builtInMetaData).toBeDefined()
})
it('should return builtInEnabled', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.builtInEnabled).toBe('boolean')
})
it('should return setBuiltInEnabled function', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
expect(typeof result.current.setBuiltInEnabled).toBe('function')
})
})
describe('Modal Control', () => {
it('should show modal when showEditModal is called', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
expect(result.current.isShowEditModal).toBe(true)
})
it('should hide modal when hideEditModal is called', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
act(() => {
result.current.showEditModal()
})
act(() => {
result.current.hideEditModal()
})
expect(result.current.isShowEditModal).toBe(false)
})
it('should handle toggle of modal state', () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
// Initially closed
expect(result.current.isShowEditModal).toBe(false)
// Show, hide, show
act(() => result.current.showEditModal())
expect(result.current.isShowEditModal).toBe(true)
act(() => result.current.hideEditModal())
expect(result.current.isShowEditModal).toBe(false)
act(() => result.current.showEditModal())
expect(result.current.isShowEditModal).toBe(true)
})
})
describe('handleAddMetaData', () => {
it('should call doAddMetaData with valid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleAddMetaData({
name: 'valid_name',
type: DataType.string,
})
})
expect(mockDoAddMetaData).toHaveBeenCalled()
})
it('should reject invalid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await expect(
act(async () => {
await result.current.handleAddMetaData({
name: '',
type: DataType.string,
})
}),
).rejects.toThrow()
})
})
describe('handleRename', () => {
it('should call doRenameMetaData with valid name', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleRename({
id: '1',
name: 'new_valid_name',
type: DataType.string,
count: 5,
})
})
expect(mockDoRenameMetaData).toHaveBeenCalled()
})
it('should call onUpdateDocList after rename', async () => {
const onUpdateDocList = vi.fn()
const { result } = renderHook(() =>
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
)
await act(async () => {
await result.current.handleRename({
id: '1',
name: 'renamed',
type: DataType.string,
count: 5,
})
})
await waitFor(() => {
expect(onUpdateDocList).toHaveBeenCalled()
})
})
it('should reject invalid name for rename', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await expect(
act(async () => {
await result.current.handleRename({
id: '1',
name: 'Invalid Name',
type: DataType.string,
count: 5,
})
}),
).rejects.toThrow()
})
})
describe('handleDeleteMetaData', () => {
it('should call doDeleteMetaData', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.handleDeleteMetaData('1')
})
expect(mockDoDeleteMetaData).toHaveBeenCalledWith('1')
})
it('should call onUpdateDocList after delete', async () => {
const onUpdateDocList = vi.fn()
const { result } = renderHook(() =>
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
)
await act(async () => {
await result.current.handleDeleteMetaData('1')
})
await waitFor(() => {
expect(onUpdateDocList).toHaveBeenCalled()
})
})
})
describe('Built-in Status', () => {
it('should toggle built-in status', async () => {
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
await act(async () => {
await result.current.setBuiltInEnabled(true)
})
expect(mockToggleBuiltInStatus).toHaveBeenCalledWith(true)
})
})
describe('Edge Cases', () => {
it('should handle different datasetIds', () => {
const { result, rerender } = renderHook(
props => useEditDatasetMetadata(props),
{ initialProps: defaultProps },
)
expect(result.current).toBeDefined()
rerender({ ...defaultProps, datasetId: 'ds-2' })
expect(result.current).toBeDefined()
})
})
})
@@ -0,0 +1,587 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import useMetadataDocument from './use-metadata-document'
type DocDetail = {
id: string
name: string
data_source_type: string
word_count: number
language?: string
hit_count?: number
segment_count?: number
}
// Mock service hooks
const mockMutateAsync = vi.fn().mockResolvedValue({})
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
vi.mock('@/service/knowledge/use-metadata', () => ({
useBatchUpdateDocMetadata: () => ({
mutateAsync: mockMutateAsync,
}),
useCreateMetaData: () => ({
mutateAsync: mockDoAddMetaData,
}),
useDocumentMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 },
],
},
}),
useDatasetMetaData: () => ({
data: {
built_in_field_enabled: true,
},
}),
}))
// Mock useDatasetDetailContext
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContext: () => ({
dataset: {
embedding_available: true,
},
}),
}))
// Mock useMetadataMap and useLanguages with comprehensive field definitions
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
originInfo: {
subFieldsMap: {
data_source_type: { label: 'Source Type', inputType: 'text' },
language: { label: 'Language', inputType: 'select' },
empty_field: { label: 'Empty Field', inputType: 'text' },
},
},
technicalParameters: {
subFieldsMap: {
word_count: { label: 'Word Count', inputType: 'text' },
hit_count: {
label: 'Hit Count',
inputType: 'text',
render: (val: number, segmentCount?: number) => `${val}/${segmentCount || 0}`,
},
custom_render: {
label: 'Custom Render',
inputType: 'text',
render: (val: string) => `Rendered: ${val}`,
},
},
},
}),
useLanguages: () => ({
en: 'English',
zh: 'Chinese',
ja: 'Japanese',
}),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock useCheckMetadataName
vi.mock('./use-check-metadata-name', () => ({
default: () => ({
checkName: (name: string) => ({
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
}),
}),
}))
describe('useMetadataDocument', () => {
const mockDocDetail: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
language: 'en',
hit_count: 50,
segment_count: 10,
}
const defaultProps = {
datasetId: 'ds-1',
documentId: 'doc-1',
docDetail: mockDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Hook Initialization', () => {
it('should return embeddingAvailable', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.embeddingAvailable).toBe(true)
})
it('should return isEdit as false initially', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.isEdit).toBe(false)
})
it('should return setIsEdit function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.setIsEdit).toBe('function')
})
it('should return list without built-in items', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
const hasBuiltIn = result.current.list.some(item => item.id === 'built-in')
expect(hasBuiltIn).toBe(false)
})
it('should return builtList with only built-in items', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
const allBuiltIn = result.current.builtList.every(item => item.id === 'built-in')
expect(allBuiltIn).toBe(true)
})
it('should return tempList', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.tempList)).toBe(true)
})
it('should return setTempList function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.setTempList).toBe('function')
})
it('should return hasData based on list length', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.hasData).toBe(result.current.list.length > 0)
})
it('should return builtInEnabled', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(typeof result.current.builtInEnabled).toBe('boolean')
})
it('should return originInfo', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.originInfo)).toBe(true)
})
it('should return technicalParameters', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(Array.isArray(result.current.technicalParameters)).toBe(true)
})
})
describe('Edit Mode', () => {
it('should enter edit mode when startToEdit is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
expect(result.current.isEdit).toBe(true)
})
it('should exit edit mode when handleCancel is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
act(() => {
result.current.handleCancel()
})
expect(result.current.isEdit).toBe(false)
})
it('should reset tempList when handleCancel is called', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const originalLength = result.current.list.length
act(() => {
result.current.setTempList([])
})
act(() => {
result.current.handleCancel()
})
expect(result.current.tempList.length).toBe(originalLength)
})
})
describe('handleSelectMetaData', () => {
it('should add metadata to tempList if not exists', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const initialLength = result.current.tempList.length
act(() => {
result.current.handleSelectMetaData({
id: 'new-id',
name: 'new_field',
type: DataType.string,
value: null,
})
})
expect(result.current.tempList.length).toBe(initialLength + 1)
})
it('should not add duplicate metadata', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
const initialLength = result.current.tempList.length
// Try to add existing item
if (result.current.tempList.length > 0) {
act(() => {
result.current.handleSelectMetaData(result.current.tempList[0])
})
expect(result.current.tempList.length).toBe(initialLength)
}
})
})
describe('handleAddMetaData', () => {
it('should call doAddMetaData with valid name', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
await act(async () => {
await result.current.handleAddMetaData({
name: 'valid_field',
type: DataType.string,
})
})
expect(mockDoAddMetaData).toHaveBeenCalled()
})
it('should reject invalid name', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
await expect(
act(async () => {
await result.current.handleAddMetaData({
name: '',
type: DataType.string,
})
}),
).rejects.toThrow()
})
})
describe('handleSave', () => {
it('should call mutateAsync to save metadata', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
await act(async () => {
await result.current.handleSave()
})
expect(mockMutateAsync).toHaveBeenCalled()
})
it('should exit edit mode after save', async () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
act(() => {
result.current.startToEdit()
})
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(result.current.isEdit).toBe(false)
})
})
})
describe('getReadOnlyMetaData - originInfo', () => {
it('should return origin info with correct structure', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.originInfo).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DataType.string,
}),
]),
)
})
it('should use languageMap for language field (select type)', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find language field in originInfo
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
// If language field exists and docDetail has language 'en', value should be 'English'
if (languageField)
expect(languageField.value).toBe('English')
})
it('should return dash for empty field values', () => {
const docDetailWithEmpty: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithEmpty as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Check if there's any field with '-' value (meaning empty)
const hasEmptyField = result.current.originInfo.some(
item => item.value === '-',
)
// language field should return '-' since it's not set
expect(hasEmptyField).toBe(true)
})
it('should return empty object for non-language select fields', () => {
// This tests the else branch of getTargetMap where field !== 'language'
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// The data_source_type field is a text field, not select
const sourceTypeField = result.current.originInfo.find(
item => item.name === 'Source Type',
)
// It should return the raw value since it's not a select type
if (sourceTypeField)
expect(sourceTypeField.value).toBe('upload_file')
})
})
describe('getReadOnlyMetaData - technicalParameters', () => {
it('should return technical parameters with correct structure', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
expect(result.current.technicalParameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: DataType.string,
}),
]),
)
})
it('should use render function when available', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find hit_count field which has a render function
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
// The render function should format as "val/segmentCount"
if (hitCountField)
expect(hitCountField.value).toBe('50/10')
})
it('should return raw value when no render function', () => {
const { result } = renderHook(() => useMetadataDocument(defaultProps))
// Find word_count field which has no render function
const wordCountField = result.current.technicalParameters.find(
item => item.name === 'Word Count',
)
if (wordCountField)
expect(wordCountField.value).toBe(100)
})
it('should handle fields with render function and undefined segment_count', () => {
const docDetailNoSegment: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 100,
hit_count: 25,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailNoSegment as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
// Should use 0 as default for segment_count
if (hitCountField)
expect(hitCountField.value).toBe('25/0')
})
it('should return dash for null/undefined values', () => {
const docDetailWithNull: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: '',
word_count: 0,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithNull as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// 0 should still be shown, but empty string should show '-'
const sourceTypeField = result.current.originInfo.find(
item => item.name === 'Source Type',
)
if (sourceTypeField)
expect(sourceTypeField.value).toBe('-')
})
it('should handle 0 value correctly (not treated as empty)', () => {
const docDetailWithZero: DocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
word_count: 0,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: docDetailWithZero as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// word_count of 0 should still show 0, not '-'
const wordCountField = result.current.technicalParameters.find(
item => item.name === 'Word Count',
)
if (wordCountField)
expect(wordCountField.value).toBe(0)
})
})
describe('Edge Cases', () => {
it('should handle empty docDetail', () => {
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: {} as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
expect(result.current).toBeDefined()
})
it('should handle different datasetIds', () => {
const { result, rerender } = renderHook(
props => useMetadataDocument(props),
{ initialProps: defaultProps },
)
expect(result.current).toBeDefined()
rerender({ ...defaultProps, datasetId: 'ds-2' })
expect(result.current).toBeDefined()
})
it('should handle docDetail with all fields', () => {
const fullDocDetail: DocDetail = {
id: 'doc-1',
name: 'Full Document',
data_source_type: 'website',
word_count: 500,
language: 'zh',
hit_count: 100,
segment_count: 20,
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: fullDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Language should be mapped
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
if (languageField)
expect(languageField.value).toBe('Chinese')
// Hit count should be rendered
const hitCountField = result.current.technicalParameters.find(
item => item.name === 'Hit Count',
)
if (hitCountField)
expect(hitCountField.value).toBe('100/20')
})
it('should handle unknown language', () => {
const unknownLangDetail: DocDetail = {
id: 'doc-1',
name: 'Unknown Lang Document',
data_source_type: 'upload_file',
word_count: 100,
language: 'unknown_lang',
}
const { result } = renderHook(() =>
useMetadataDocument({
...defaultProps,
docDetail: unknownLangDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
}),
)
// Unknown language should return undefined from the map
const languageField = result.current.originInfo.find(
item => item.name === 'Language',
)
// When language is not in map, it returns undefined
expect(languageField?.value).toBeUndefined()
})
})
})
@@ -0,0 +1,268 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import CreateContent from './create-content'
type ModalLikeWrapProps = {
children: React.ReactNode
title: string
onClose?: () => void
onConfirm: () => void
beforeHeader?: React.ReactNode
}
type OptionCardProps = {
title: string
selected: boolean
onSelect: () => void
}
type FieldProps = {
label: string
children: React.ReactNode
}
// Mock ModalLikeWrap
vi.mock('../../../base/modal-like-wrap', () => ({
default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => (
<div data-testid="modal-wrap">
<div data-testid="modal-title">{title}</div>
{beforeHeader && <div data-testid="before-header">{beforeHeader}</div>}
<div data-testid="modal-content">{children}</div>
<button data-testid="close-btn" onClick={onClose}>Close</button>
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
</div>
),
}))
// Mock OptionCard
vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({
default: ({ title, selected, onSelect }: OptionCardProps) => (
<button
data-testid={`option-${title.toLowerCase()}`}
data-selected={selected}
onClick={onSelect}
>
{title}
</button>
),
}))
// Mock Field
vi.mock('./field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<label data-testid="field-label">{label}</label>
{children}
</div>
),
}))
describe('CreateContent', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-wrap')).toBeInTheDocument()
})
it('should render modal title', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
})
it('should render type selection options', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('option-string')).toBeInTheDocument()
expect(screen.getByTestId('option-number')).toBeInTheDocument()
expect(screen.getByTestId('option-time')).toBeInTheDocument()
})
it('should render name input field', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render confirm button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('confirm-btn')).toBeInTheDocument()
})
it('should render close button', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
})
})
describe('Type Selection', () => {
it('should default to string type', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
})
it('should select number type when clicked', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-number'))
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
})
it('should select time type when clicked', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-time'))
expect(screen.getByTestId('option-time')).toHaveAttribute('data-selected', 'true')
})
it('should deselect previous type when new type is selected', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
// Initially string is selected
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
// Select number
fireEvent.click(screen.getByTestId('option-number'))
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'false')
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
})
})
describe('Name Input', () => {
it('should update name when typing', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new_field' } })
expect(input).toHaveValue('new_field')
})
it('should start with empty name', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
expect(screen.getByRole('textbox')).toHaveValue('')
})
})
describe('User Interactions', () => {
it('should call onSave with type and name when confirmed', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test_field' } })
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'test_field',
})
})
it('should call onSave with correct type after changing type', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('option-number'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'num_field' } })
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.number,
name: 'num_field',
})
})
it('should call onClose when close button is clicked', () => {
const handleSave = vi.fn()
const handleClose = vi.fn()
render(<CreateContent onSave={handleSave} onClose={handleClose} />)
fireEvent.click(screen.getByTestId('close-btn'))
expect(handleClose).toHaveBeenCalled()
})
})
describe('Back Button', () => {
it('should show back button when hasBack is true', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} hasBack />)
expect(screen.getByTestId('before-header')).toBeInTheDocument()
})
it('should not show back button when hasBack is false', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} hasBack={false} />)
expect(screen.queryByTestId('before-header')).not.toBeInTheDocument()
})
it('should call onBack when back button is clicked', () => {
const handleSave = vi.fn()
const handleBack = vi.fn()
render(<CreateContent onSave={handleSave} hasBack onBack={handleBack} />)
const backButton = screen.getByTestId('before-header')
// Find the clickable element inside
const clickable = backButton.querySelector('.cursor-pointer') || backButton.firstChild
if (clickable)
fireEvent.click(clickable)
// The back functionality is tested through the actual implementation
expect(screen.getByTestId('before-header')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty name submission', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
fireEvent.click(screen.getByTestId('confirm-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: '',
})
})
it('should handle type cycling', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
// Cycle through all types
fireEvent.click(screen.getByTestId('option-number'))
fireEvent.click(screen.getByTestId('option-time'))
fireEvent.click(screen.getByTestId('option-string'))
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
})
it('should handle special characters in name', () => {
const handleSave = vi.fn()
render(<CreateContent onSave={handleSave} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test_field_123' } })
expect(input).toHaveValue('test_field_123')
})
})
})
@@ -0,0 +1,246 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import CreateMetadataModal from './create-metadata-modal'
type PortalProps = {
children: React.ReactNode
open: boolean
}
type TriggerProps = {
children: React.ReactNode
onClick: () => void
}
type ContentProps = {
children: React.ReactNode
className?: string
}
type CreateContentProps = {
onSave: (data: { type: DataType, name: string }) => void
onClose?: () => void
onBack?: () => void
hasBack?: boolean
}
// Mock PortalToFollowElem components
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: PortalProps) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
// Mock CreateContent component
vi.mock('./create-content', () => ({
default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<span data-testid="has-back">{String(hasBack)}</span>
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'test' })}>Save</button>
<button data-testid="close-btn" onClick={onClose}>Close</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
</div>
),
}))
describe('CreateMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Open Modal</button>
describe('Rendering', () => {
it('should render trigger when closed', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
// Portal wrapper should exist but closed
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
})
it('should render content when open', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass hasBack to CreateContent', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('true')
})
it('should pass hasBack=undefined when not provided', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
})
it('should accept custom popupLeft', () => {
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
popupLeft={50}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={false}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(setOpen).toHaveBeenCalledWith(true)
})
it('should call onSave when save button is clicked', () => {
const handleSave = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={handleSave}
/>,
)
fireEvent.click(screen.getByTestId('save-btn'))
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'test',
})
})
it('should close modal when close button is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('close-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
it('should close modal when back button is clicked', () => {
const setOpen = vi.fn()
render(
<CreateMetadataModal
open={true}
setOpen={setOpen}
trigger={mockTrigger}
onSave={vi.fn()}
hasBack
/>,
)
fireEvent.click(screen.getByTestId('back-btn'))
expect(setOpen).toHaveBeenCalledWith(false)
})
})
describe('Edge Cases', () => {
it('should handle switching open state', () => {
const { rerender } = render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
rerender(
<CreateMetadataModal
open={true}
setOpen={vi.fn()}
trigger={mockTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
})
it('should handle different trigger elements', () => {
const customTrigger = <div data-testid="custom-trigger">Custom</div>
render(
<CreateMetadataModal
open={false}
setOpen={vi.fn()}
trigger={customTrigger}
onSave={vi.fn()}
/>,
)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,587 @@
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import DatasetMetadataDrawer from './dataset-metadata-drawer'
// Mock service/API calls
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'existing_field', type: DataType.string },
],
},
}),
}))
// Mock check name hook
vi.mock('../hooks/use-check-metadata-name', () => ({
default: () => ({
checkName: () => ({ errorMsg: '' }),
}),
}))
// Mock Toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (args: unknown) => mockToastNotify(args),
},
}))
// Type definitions for mock props
type CreateModalProps = {
open: boolean
setOpen: (open: boolean) => void
trigger: React.ReactNode
onSave: (data: BuiltInMetadataItem) => void
}
// Mock CreateModal to expose callbacks
vi.mock('@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal', () => ({
default: ({ open, setOpen, trigger, onSave }: CreateModalProps) => (
<div data-testid="create-modal-wrapper">
<div data-testid="create-trigger" onClick={() => setOpen(true)}>{trigger}</div>
{open && (
<div data-testid="create-modal">
<button data-testid="create-save" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>
Save
</button>
<button data-testid="create-close" onClick={() => setOpen(false)}>Close</button>
</div>
)}
</div>
),
}))
describe('DatasetMetadataDrawer', () => {
const mockUserMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
]
const mockBuiltInMetadata: BuiltInMetadataItem[] = [
{ name: 'created_at', type: DataType.time },
{ name: 'modified_at', type: DataType.time },
]
const defaultProps = {
userMetadata: mockUserMetadata,
builtInMetadata: mockBuiltInMetadata,
isBuiltInEnabled: false,
onIsBuiltInEnabledChange: vi.fn(),
onClose: vi.fn(),
onAdd: vi.fn().mockResolvedValue({}),
onRename: vi.fn().mockResolvedValue({}),
onRemove: vi.fn().mockResolvedValue({}),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should render user metadata items', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
})
it('should render built-in metadata items', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('created_at')).toBeInTheDocument()
expect(screen.getByText('modified_at')).toBeInTheDocument()
})
})
it('should render metadata type for each item', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
})
})
it('should render add metadata button', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('create-trigger')).toBeInTheDocument()
})
})
it('should render switch for built-in toggle', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
const switchBtn = screen.getByRole('switch')
expect(switchBtn).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onIsBuiltInEnabledChange when switch is toggled', async () => {
const onIsBuiltInEnabledChange = vi.fn()
render(
<DatasetMetadataDrawer
{...defaultProps}
onIsBuiltInEnabledChange={onIsBuiltInEnabledChange}
/>,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const switchBtn = screen.getByRole('switch')
fireEvent.click(switchBtn)
expect(onIsBuiltInEnabledChange).toHaveBeenCalled()
})
})
describe('Add Metadata', () => {
it('should open create modal when add button is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const trigger = screen.getByTestId('create-trigger')
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
})
})
it('should call onAdd and show success toast when metadata is added', async () => {
const onAdd = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Open create modal
const trigger = screen.getByTestId('create-trigger')
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
})
// Save new metadata
fireEvent.click(screen.getByTestId('create-save'))
await waitFor(() => {
expect(onAdd).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should close create modal after save', async () => {
const onAdd = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Open create modal
fireEvent.click(screen.getByTestId('create-trigger'))
await waitFor(() => {
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
})
// Save
fireEvent.click(screen.getByTestId('create-save'))
await waitFor(() => {
expect(screen.queryByTestId('create-modal')).not.toBeInTheDocument()
})
})
})
describe('Rename Metadata', () => {
it('should open rename modal when edit icon is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find user metadata items with group/item class (these have edit/delete icons)
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
expect(items.length).toBe(2) // 2 user metadata items
// Find the hidden container with edit/delete icons
const actionsContainer = items[0].querySelector('.hidden.items-center')
expect(actionsContainer).toBeTruthy()
// Find and click the first SVG (edit icon)
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
fireEvent.click(svgs[0])
}
// Wait for rename modal (contains input)
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
})
it('should call onRename when rename is saved', async () => {
const onRename = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onRename={onRename} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click edit icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const actionsContainer = items[0].querySelector('.hidden.items-center')
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
fireEvent.click(svgs[0])
}
// Change name and save
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
const inputs = document.querySelectorAll('input')
fireEvent.change(inputs[0], { target: { value: 'renamed_field' } })
// Find and click save button
const saveBtns = screen.getAllByText(/save/i)
const primaryBtn = saveBtns.find(btn =>
btn.closest('button')?.classList.contains('btn-primary'),
)
if (primaryBtn)
fireEvent.click(primaryBtn)
await waitFor(() => {
expect(onRename).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should close rename modal when cancel is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click edit icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const actionsContainer = items[0].querySelector('.hidden.items-center')
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
fireEvent.click(svgs[0])
}
// Wait for modal and click cancel
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
// Change name first
const inputs = document.querySelectorAll('input')
fireEvent.change(inputs[0], { target: { value: 'changed_name' } })
// Find and click cancel button
const cancelBtns = screen.getAllByText(/cancel/i)
const cancelBtn = cancelBtns.find(btn =>
!btn.closest('button')?.classList.contains('btn-primary'),
)
if (cancelBtn)
fireEvent.click(cancelBtn)
// Verify input resets or modal closes
await waitFor(() => {
const currentInputs = document.querySelectorAll('input')
// Either no inputs (modal closed) or value reset
expect(currentInputs.length === 0 || currentInputs[0].value !== 'changed_name').toBe(true)
})
})
it('should close rename modal when modal close button is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click edit icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const actionsContainer = items[0].querySelector('.hidden.items-center')
if (actionsContainer) {
const svgs = actionsContainer.querySelectorAll('svg')
fireEvent.click(svgs[0])
}
// Wait for rename modal
await waitFor(() => {
const inputs = document.querySelectorAll('input')
expect(inputs.length).toBeGreaterThan(0)
})
// Find and click the modal close button (X button)
// The Modal component has a close button in the header
const dialogs = screen.getAllByRole('dialog')
const renameModal = dialogs.find(d => d.querySelector('input'))
if (renameModal) {
// Find close button by looking for a button with close-related class or X icon
const closeButtons = renameModal.querySelectorAll('button')
for (const btn of Array.from(closeButtons)) {
// Skip cancel/save buttons
if (!btn.textContent?.toLowerCase().includes('cancel')
&& !btn.textContent?.toLowerCase().includes('save')
&& btn.querySelector('svg')) {
fireEvent.click(btn)
break
}
}
}
})
})
describe('Delete Metadata', () => {
it('should show confirm dialog when delete icon is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find user metadata items
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
// Find the delete container
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
expect(deleteContainer).toBeTruthy()
// Click delete icon
if (deleteContainer) {
const deleteIcon = deleteContainer.querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
}
// Confirm dialog should appear
await waitFor(() => {
const confirmBtns = screen.getAllByRole('button')
const hasConfirmBtn = confirmBtns.some(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
expect(hasConfirmBtn).toBe(true)
})
})
it('should call onRemove when delete is confirmed', async () => {
const onRemove = vi.fn().mockResolvedValue({})
render(<DatasetMetadataDrawer {...defaultProps} onRemove={onRemove} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click delete icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
if (deleteContainer) {
const deleteIcon = deleteContainer.querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
}
// Wait for confirm dialog
await waitFor(() => {
const confirmBtns = screen.getAllByRole('button')
const hasConfirmBtn = confirmBtns.some(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
expect(hasConfirmBtn).toBe(true)
})
// Click confirm
const confirmBtns = screen.getAllByRole('button')
const confirmBtn = confirmBtns.find(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
if (confirmBtn)
fireEvent.click(confirmBtn)
await waitFor(() => {
expect(onRemove).toHaveBeenCalledWith('1')
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
render(<DatasetMetadataDrawer {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Find and click delete icon
const dialog = screen.getByRole('dialog')
const items = dialog.querySelectorAll('.group\\/item')
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
if (deleteContainer) {
const deleteIcon = deleteContainer.querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
}
// Wait for confirm dialog
await waitFor(() => {
const confirmBtns = screen.getAllByRole('button')
const hasConfirmBtn = confirmBtns.some(btn =>
btn.textContent?.toLowerCase().includes('confirm'),
)
expect(hasConfirmBtn).toBe(true)
})
// Click cancel
const cancelBtns = screen.getAllByRole('button')
const cancelBtn = cancelBtns.find(btn =>
btn.textContent?.toLowerCase().includes('cancel'),
)
if (cancelBtn)
fireEvent.click(cancelBtn)
})
})
describe('Props', () => {
it('should handle empty userMetadata', async () => {
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={[]} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
it('should handle empty builtInMetadata', async () => {
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={[]} />)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Built-in Items State', () => {
it('should show disabled styling when built-in is disabled', async () => {
render(
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled={false} />,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
const dialog = screen.getByRole('dialog')
const disabledItems = dialog.querySelectorAll('.opacity-30')
expect(disabledItems.length).toBeGreaterThan(0)
})
it('should not show disabled styling when built-in is enabled', async () => {
render(
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled />,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle items with special characters in name', async () => {
const specialMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'field_with_underscore', type: DataType.string, count: 1 },
]
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={specialMetadata} />)
await waitFor(() => {
expect(screen.getByText('field_with_underscore')).toBeInTheDocument()
})
})
it('should handle single user metadata item', async () => {
const singleMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'only_field', type: DataType.string, count: 10 },
]
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={singleMetadata} />)
await waitFor(() => {
expect(screen.getByText('only_field')).toBeInTheDocument()
})
})
it('should handle single built-in metadata item', async () => {
const singleBuiltIn: BuiltInMetadataItem[] = [
{ name: 'created_at', type: DataType.time },
]
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={singleBuiltIn} />)
await waitFor(() => {
expect(screen.getByText('created_at')).toBeInTheDocument()
})
})
it('should handle metadata with zero count', async () => {
const zeroCountMetadata: MetadataItemWithValueLength[] = [
{ id: '1', name: 'empty_field', type: DataType.string, count: 0 },
]
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={zeroCountMetadata} />)
await waitFor(() => {
expect(screen.getByText('empty_field')).toBeInTheDocument()
})
})
})
})
@@ -0,0 +1,122 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Field from './field'
describe('Field', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Field label="Test Label">Content</Field>)
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render label with correct styling', () => {
render(<Field label="My Label">Content</Field>)
const labelElement = screen.getByText('My Label')
expect(labelElement).toHaveClass('system-sm-semibold', 'py-1', 'text-text-secondary')
})
it('should render children in content container', () => {
const { container } = render(<Field label="Label">Child Content</Field>)
// The children wrapper has mt-1 class
const contentWrapper = container.querySelector('.mt-1')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).toHaveTextContent('Child Content')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<Field label="Label" className="custom-class">Content</Field>)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render with string children', () => {
render(<Field label="Label">Simple Text</Field>)
expect(screen.getByText('Simple Text')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<Field label="Label">
<div data-testid="complex-child">
<span>Nested Content</span>
</div>
</Field>,
)
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
expect(screen.getByText('Nested Content')).toBeInTheDocument()
})
it('should render with multiple children', () => {
render(
<Field label="Label">
<span>First</span>
<span>Second</span>
</Field>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render different labels correctly', () => {
const { rerender } = render(<Field label="First Label">Content</Field>)
expect(screen.getByText('First Label')).toBeInTheDocument()
rerender(<Field label="Second Label">Content</Field>)
expect(screen.getByText('Second Label')).toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have label above content', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
expect(wrapper?.children).toHaveLength(2)
})
it('should render label element first', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
const firstChild = wrapper?.firstChild as HTMLElement
expect(firstChild).toHaveClass('system-sm-semibold')
})
})
describe('Edge Cases', () => {
it('should render with undefined className', () => {
render(<Field label="Label" className={undefined}>Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty className', () => {
render(<Field label="Label" className="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty label', () => {
render(<Field label="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with empty children', () => {
const { container } = render(<Field label="Label"><span></span></Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with null children', () => {
const { container } = render(<Field label="Label">{null}</Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with number as children', () => {
render(<Field label="Label">{42}</Field>)
expect(screen.getByText('42')).toBeInTheDocument()
})
it('should handle special characters in label', () => {
render(<Field label={'Label & "chars"'}>Content</Field>)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,348 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import SelectMetadataModal from './select-metadata-modal'
type MetadataItem = {
id: string
name: string
type: DataType
}
type PortalProps = {
children: React.ReactNode
open: boolean
}
type TriggerProps = {
children: React.ReactNode
onClick: () => void
}
type ContentProps = {
children: React.ReactNode
}
type SelectMetadataProps = {
onSelect: (item: MetadataItem) => void
onNew: () => void
onManage: () => void
list: MetadataItem[]
}
type CreateContentProps = {
onSave: (data: { type: DataType, name: string }) => void
onBack?: () => void
onClose?: () => void
hasBack?: boolean
}
// Mock useDatasetMetaData hook
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [
{ id: '1', name: 'field_one', type: DataType.string },
{ id: '2', name: 'field_two', type: DataType.number },
],
},
}),
}))
// Mock PortalToFollowElem components
vi.mock('../../../base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: PortalProps) => (
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: ContentProps) => (
<div data-testid="portal-content">{children}</div>
),
}))
// Mock SelectMetadata component
vi.mock('./select-metadata', () => ({
default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => (
<div data-testid="select-metadata">
<span data-testid="list-count">{list?.length || 0}</span>
<button data-testid="select-item" onClick={() => onSelect({ id: '1', name: 'field_one', type: DataType.string })}>Select</button>
<button data-testid="new-btn" onClick={onNew}>New</button>
<button data-testid="manage-btn" onClick={onManage}>Manage</button>
</div>
),
}))
// Mock CreateContent component
vi.mock('./create-content', () => ({
default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => (
<div data-testid="create-content">
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button>
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
<button data-testid="close-btn" onClick={onClose}>Close</button>
</div>
),
}))
describe('SelectMetadataModal', () => {
const mockTrigger = <button data-testid="trigger-button">Select Metadata</button>
describe('Rendering', () => {
it('should render without crashing', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should render trigger element', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
})
it('should render SelectMetadata by default', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
it('should pass dataset metadata to SelectMetadata', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('list-count')).toHaveTextContent('2')
})
})
describe('User Interactions', () => {
it('should toggle open state when trigger is clicked', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
// State should toggle
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should call onSelect and close when item is selected', () => {
const handleSelect = vi.fn()
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={handleSelect}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('select-item'))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'field_one',
type: DataType.string,
})
})
it('should switch to create step when new button is clicked', async () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
})
it('should call onManage when manage button is clicked', () => {
const handleManage = vi.fn()
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={handleManage}
/>,
)
fireEvent.click(screen.getByTestId('manage-btn'))
expect(handleManage).toHaveBeenCalled()
})
})
describe('Create Flow', () => {
it('should switch back to select when back is clicked in create step', async () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
// Go to create step
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
// Go back to select step
fireEvent.click(screen.getByTestId('back-btn'))
await waitFor(() => {
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
})
})
it('should call onSave and return to select step when save is clicked', async () => {
const handleSave = vi.fn().mockResolvedValue(undefined)
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={handleSave}
onManage={vi.fn()}
/>,
)
// Go to create step
fireEvent.click(screen.getByTestId('new-btn'))
await waitFor(() => {
expect(screen.getByTestId('create-content')).toBeInTheDocument()
})
// Save new metadata
fireEvent.click(screen.getByTestId('save-btn'))
await waitFor(() => {
expect(handleSave).toHaveBeenCalledWith({
type: DataType.string,
name: 'new_field',
})
})
})
})
describe('Props', () => {
it('should accept custom popupPlacement', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupPlacement="bottom-start"
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should accept custom popupOffset', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
popupOffset={{ mainAxis: 10, crossAxis: 5 }}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle different datasetIds', () => {
const { rerender } = render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
rerender(
<SelectMetadataModal
datasetId="dataset-2"
trigger={mockTrigger}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
})
it('should handle empty trigger', () => {
render(
<SelectMetadataModal
datasetId="dataset-1"
trigger={<span data-testid="empty-trigger" />}
onSelect={vi.fn()}
onSave={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByTestId('empty-trigger')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,332 @@
import type { MetadataItem } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import SelectMetadata from './select-metadata'
type IconProps = {
className?: string
}
// Mock getIcon utility
vi.mock('../utils/get-icon', () => ({
getIcon: () => (props: IconProps) => <span data-testid="icon" className={props.className}>Icon</span>,
}))
describe('SelectMetadata', () => {
const mockList: MetadataItem[] = [
{ id: '1', name: 'field_one', type: DataType.string },
{ id: '2', name: 'field_two', type: DataType.number },
{ id: '3', name: 'field_three', type: DataType.time },
]
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render search input', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render all metadata items', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
expect(screen.getByText('field_three')).toBeInTheDocument()
})
it('should render new action button', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
// New action button should be present (from i18n)
expect(screen.getByText(/new/i)).toBeInTheDocument()
})
it('should render manage action button', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
// Manage action button should be present (from i18n)
expect(screen.getByText(/manage/i)).toBeInTheDocument()
})
it('should display type for each item', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
expect(screen.getAllByText(DataType.time).length).toBeGreaterThan(0)
})
})
describe('Search Functionality', () => {
it('should filter items based on search query', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'one' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
})
it('should be case insensitive search', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'ONE' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
})
it('should show all items when search is cleared', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
// Search for something
fireEvent.change(searchInput, { target: { value: 'one' } })
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
// Clear search
fireEvent.change(searchInput, { target: { value: '' } })
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should show no results when search matches nothing', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'xyz' } })
expect(screen.queryByText('field_one')).not.toBeInTheDocument()
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onSelect with item data when item is clicked', () => {
const handleSelect = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={handleSelect}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('field_one'))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'field_one',
type: DataType.string,
})
})
it('should call onNew when new button is clicked', () => {
const handleNew = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={handleNew}
onManage={vi.fn()}
/>,
)
// Find and click the new action button
const newButton = screen.getByText(/new/i)
fireEvent.click(newButton.closest('div') || newButton)
expect(handleNew).toHaveBeenCalled()
})
it('should call onManage when manage button is clicked', () => {
const handleManage = vi.fn()
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={handleManage}
/>,
)
// Find and click the manage action button
const manageButton = screen.getByText(/manage/i)
fireEvent.click(manageButton.closest('div') || manageButton)
expect(handleManage).toHaveBeenCalled()
})
})
describe('Empty State', () => {
it('should render empty list', () => {
const { container } = render(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should still show new and manage buttons with empty list', () => {
render(
<SelectMetadata
list={[]}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText(/new/i)).toBeInTheDocument()
expect(screen.getByText(/manage/i)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling', () => {
const { container } = render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(container.firstChild).toHaveClass('w-[320px]', 'rounded-xl')
})
})
describe('Edge Cases', () => {
it('should handle single item list', () => {
const singleItem: MetadataItem[] = [
{ id: '1', name: 'only_one', type: DataType.string },
]
render(
<SelectMetadata
list={singleItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('only_one')).toBeInTheDocument()
})
it('should handle item with long name', () => {
const longNameItem: MetadataItem[] = [
{ id: '1', name: 'this_is_a_very_long_field_name_that_might_overflow', type: DataType.string },
]
render(
<SelectMetadata
list={longNameItem}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
expect(screen.getByText('this_is_a_very_long_field_name_that_might_overflow')).toBeInTheDocument()
})
it('should handle rapid search input changes', () => {
render(
<SelectMetadata
list={mockList}
onSelect={vi.fn()}
onNew={vi.fn()}
onManage={vi.fn()}
/>,
)
const searchInput = screen.getByRole('textbox')
// Rapid typing
fireEvent.change(searchInput, { target: { value: 'f' } })
fireEvent.change(searchInput, { target: { value: 'fi' } })
fireEvent.change(searchInput, { target: { value: 'fie' } })
fireEvent.change(searchInput, { target: { value: 'fiel' } })
fireEvent.change(searchInput, { target: { value: 'field' } })
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
expect(screen.getByText('field_three')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Field from './field'
describe('Field', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Field label="Test Label">Content</Field>)
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render label with correct styling', () => {
render(<Field label="My Label">Content</Field>)
const labelElement = screen.getByText('My Label')
expect(labelElement).toHaveClass('system-xs-medium', 'w-[128px]', 'shrink-0', 'truncate', 'py-1', 'text-text-tertiary')
})
it('should render children in correct container', () => {
const { container } = render(<Field label="Label">Child Content</Field>)
// The children are wrapped in a div with w-[244px] class
const contentWrapper = container.querySelector('.w-\\[244px\\]')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).toHaveClass('shrink-0')
})
})
describe('Props', () => {
it('should render with string children', () => {
render(<Field label="Label">Simple Text</Field>)
expect(screen.getByText('Simple Text')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<Field label="Label">
<div data-testid="complex-child">
<span>Nested Content</span>
</div>
</Field>,
)
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
expect(screen.getByText('Nested Content')).toBeInTheDocument()
})
it('should render with multiple children', () => {
render(
<Field label="Label">
<span>First</span>
<span>Second</span>
</Field>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render different labels correctly', () => {
const { rerender } = render(<Field label="First Label">Content</Field>)
expect(screen.getByText('First Label')).toBeInTheDocument()
rerender(<Field label="Second Label">Content</Field>)
expect(screen.getByText('Second Label')).toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have flex layout with space between elements', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('flex', 'items-start', 'space-x-2')
})
it('should render label and content side by side', () => {
const { container } = render(<Field label="Label">Content</Field>)
const wrapper = container.firstChild as HTMLElement
expect(wrapper?.children).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('should render with empty label', () => {
render(<Field label="">Content</Field>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should render with long label (truncation)', () => {
const longLabel = 'This is a very long label that should be truncated'
render(<Field label={longLabel}>Content</Field>)
const labelElement = screen.getByText(longLabel)
expect(labelElement).toHaveClass('truncate')
})
it('should render with empty children', () => {
const { container } = render(<Field label="Label"><span></span></Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with null children', () => {
const { container } = render(<Field label="Label">{null}</Field>)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with number as children', () => {
render(<Field label="Label">{42}</Field>)
expect(screen.getByText('42')).toBeInTheDocument()
})
it('should handle special characters in label', () => {
render(<Field label={'Label & "chars"'}>Content</Field>)
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,752 @@
import type { MetadataItemWithValue } from '../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import MetadataDocument from './index'
type MockHookReturn = {
embeddingAvailable: boolean
isEdit: boolean
setIsEdit: ReturnType<typeof vi.fn>
list: MetadataItemWithValue[]
tempList: MetadataItemWithValue[]
setTempList: ReturnType<typeof vi.fn>
handleSelectMetaData: ReturnType<typeof vi.fn>
handleAddMetaData: ReturnType<typeof vi.fn>
hasData: boolean
builtList: MetadataItemWithValue[]
builtInEnabled: boolean
startToEdit: ReturnType<typeof vi.fn>
handleSave: ReturnType<typeof vi.fn>
handleCancel: ReturnType<typeof vi.fn>
originInfo: MetadataItemWithValue[]
technicalParameters: MetadataItemWithValue[]
}
// Mock useMetadataDocument hook - need to control state
const mockUseMetadataDocument = vi.fn<() => MockHookReturn>()
vi.mock('../hooks/use-metadata-document', () => ({
default: () => mockUseMetadataDocument(),
}))
// Mock service calls
vi.mock('@/service/knowledge/use-metadata', () => ({
useDatasetMetaData: () => ({
data: {
doc_metadata: [],
},
}),
}))
// Mock check name hook
vi.mock('../hooks/use-check-metadata-name', () => ({
default: () => ({
checkName: () => ({ errorMsg: '' }),
}),
}))
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
describe('MetadataDocument', () => {
const mockDocDetail = {
id: 'doc-1',
name: 'Test Document',
data_source_type: 'upload_file',
indexing_status: 'completed',
created_at: 1609459200,
word_count: 100,
}
const mockList: MetadataItemWithValue[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
]
const defaultHookReturn: MockHookReturn = {
embeddingAvailable: true,
isEdit: false,
setIsEdit: vi.fn(),
list: mockList,
tempList: mockList,
setTempList: vi.fn(),
handleSelectMetaData: vi.fn(),
handleAddMetaData: vi.fn(),
hasData: true,
builtList: [],
builtInEnabled: false,
startToEdit: vi.fn(),
handleSave: vi.fn(),
handleCancel: vi.fn(),
originInfo: [],
technicalParameters: [],
}
beforeEach(() => {
vi.clearAllMocks()
mockUseMetadataDocument.mockReturnValue(defaultHookReturn)
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render metadata fields when hasData is true', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should render no-data state when hasData is false and not in edit mode', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
hasData: false,
list: [],
tempList: [],
isEdit: false,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getAllByText(/metadata/i).length).toBeGreaterThan(0)
})
it('should render edit UI when in edit mode', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/save/i)).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
})
it('should render built-in section when builtInEnabled is true', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
builtInEnabled: true,
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('created_at')).toBeInTheDocument()
})
it('should render divider when builtInEnabled is true', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
builtInEnabled: true,
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const divider = container.querySelector('[class*="bg-gradient"]')
expect(divider).toBeInTheDocument()
})
it('should render origin info section', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('source')).toBeInTheDocument()
})
it('should render technical parameters section', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('word_count')).toBeInTheDocument()
})
it('should render all sections together', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
builtInEnabled: true,
builtList: [{ id: 'built-1', name: 'created_at', type: DataType.time, value: 1609459200 }],
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('created_at')).toBeInTheDocument()
expect(screen.getByText('source')).toBeInTheDocument()
expect(screen.getByText('word_count')).toBeInTheDocument()
})
})
describe('Edit Mode', () => {
it('should show edit button when not in edit mode and embedding available', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/edit/i)).toBeInTheDocument()
})
it('should call startToEdit when edit button is clicked', () => {
const startToEdit = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: false,
startToEdit,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
fireEvent.click(screen.getByText(/edit/i))
expect(startToEdit).toHaveBeenCalled()
})
it('should call handleSave when save button is clicked', () => {
const handleSave = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleSave,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
fireEvent.click(screen.getByText(/save/i))
expect(handleSave).toHaveBeenCalled()
})
it('should call handleCancel when cancel button is clicked', () => {
const handleCancel = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleCancel,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
fireEvent.click(screen.getByText(/cancel/i))
expect(handleCancel).toHaveBeenCalled()
})
it('should call setIsEdit(true) when start button is clicked in no-data state', () => {
const setIsEdit = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
hasData: false,
list: [],
tempList: [],
isEdit: false,
setIsEdit,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const startBtn = screen.queryByText(/start/i)
if (startBtn) {
fireEvent.click(startBtn)
expect(setIsEdit).toHaveBeenCalledWith(true)
}
})
it('should show InfoGroup when in edit mode without data', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
hasData: false,
list: [],
tempList: [],
isEdit: true,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// Should show save/cancel buttons
expect(screen.getByText(/save/i)).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
})
})
describe('Data Operations', () => {
it('should call setTempList when field value changes', async () => {
const setTempList = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
setTempList,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const inputs = container.querySelectorAll('input')
if (inputs.length > 0) {
fireEvent.change(inputs[0], { target: { value: 'new value' } })
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
})
it('should have handleAddMetaData function available', () => {
const handleAddMetaData = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleAddMetaData,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(typeof handleAddMetaData).toBe('function')
})
it('should have handleSelectMetaData function available', () => {
const handleSelectMetaData = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
handleSelectMetaData,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(typeof handleSelectMetaData).toBe('function')
})
it('should pass onChange callback to InfoGroup', async () => {
const setTempList = vi.fn()
const tempList = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
]
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
tempList,
setTempList,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
const inputs = container.querySelectorAll('input')
if (inputs.length > 0) {
fireEvent.change(inputs[0], { target: { value: 'updated' } })
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
})
it('should pass onDelete callback to InfoGroup', async () => {
const setTempList = vi.fn()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
tempList: mockList,
setTempList,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// Look for delete buttons - they are inside hover:bg-state-destructive-hover divs
const deleteContainers = container.querySelectorAll('.hover\\:bg-state-destructive-hover')
expect(deleteContainers.length).toBeGreaterThan(0)
// Click the delete icon (SVG inside the container)
if (deleteContainers.length > 0) {
const deleteIcon = deleteContainers[0].querySelector('svg')
if (deleteIcon)
fireEvent.click(deleteIcon)
await waitFor(() => {
expect(setTempList).toHaveBeenCalled()
})
}
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should use tempList when in edit mode', () => {
const tempList = [{ id: 'temp-1', name: 'temp_field', type: DataType.string, value: 'temp' }]
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
tempList,
list: mockList,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('temp_field')).toBeInTheDocument()
})
it('should use list when not in edit mode', () => {
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('field_one')).toBeInTheDocument()
expect(screen.getByText('field_two')).toBeInTheDocument()
})
it('should pass datasetId to child components', () => {
render(
<MetadataDocument
datasetId="custom-ds-id"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// Component should render without errors
expect(screen.getByText('field_one')).toBeInTheDocument()
})
})
describe('Embedding Availability', () => {
it('should not show edit button when embedding is not available', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
embeddingAvailable: false,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
})
it('should not show NoData when embedding is not available', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
embeddingAvailable: false,
hasData: false,
list: [],
tempList: [],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// NoData component should not be rendered
expect(screen.queryByText(/start/i)).not.toBeInTheDocument()
})
it('should not show edit buttons in edit mode when embedding not available', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
embeddingAvailable: false,
isEdit: false,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
// headerRight should be null/undefined
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty lists', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [],
tempList: [],
hasData: false,
})
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render correctly with minimal props', () => {
const { container } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle switching between view and edit mode', () => {
const { unmount } = render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/edit/i)).toBeInTheDocument()
unmount()
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
isEdit: true,
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText(/save/i)).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
})
it('should handle multiple items in all sections', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [
{ id: '1', name: 'user_field_1', type: DataType.string, value: 'v1' },
{ id: '2', name: 'user_field_2', type: DataType.number, value: 42 },
],
builtInEnabled: true,
builtList: [
{ id: 'b1', name: 'created_at', type: DataType.time, value: 1609459200 },
{ id: 'b2', name: 'modified_at', type: DataType.time, value: 1609459200 },
],
originInfo: [
{ id: 'o1', name: 'source', type: DataType.string, value: 'file' },
{ id: 'o2', name: 'format', type: DataType.string, value: 'txt' },
],
technicalParameters: [
{ id: 't1', name: 'word_count', type: DataType.number, value: 100 },
{ id: 't2', name: 'char_count', type: DataType.number, value: 500 },
],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('user_field_1')).toBeInTheDocument()
expect(screen.getByText('user_field_2')).toBeInTheDocument()
expect(screen.getByText('created_at')).toBeInTheDocument()
expect(screen.getByText('source')).toBeInTheDocument()
expect(screen.getByText('word_count')).toBeInTheDocument()
})
it('should handle null values in metadata', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [
{ id: '1', name: 'null_field', type: DataType.string, value: null },
],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('null_field')).toBeInTheDocument()
})
it('should handle undefined values in metadata', () => {
mockUseMetadataDocument.mockReturnValue({
...defaultHookReturn,
list: [
{ id: '1', name: 'undefined_field', type: DataType.string, value: undefined as unknown as null },
],
})
render(
<MetadataDocument
datasetId="ds-1"
documentId="doc-1"
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
/>,
)
expect(screen.getByText('undefined_field')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,341 @@
import type { MetadataItemWithValue } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DataType } from '../types'
import InfoGroup from './info-group'
type SelectModalProps = {
trigger: React.ReactNode
onSelect: (item: MetadataItemWithValue) => void
onSave: (data: { name: string, type: DataType }) => void
onManage: () => void
}
type FieldProps = {
label: string
children: React.ReactNode
}
type InputCombinedProps = {
value: string | number | null
onChange: (value: string | number) => void
type: DataType
}
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
// Mock useTimestamp
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (timestamp: number) => {
if (!timestamp)
return ''
return new Date(timestamp * 1000).toLocaleDateString()
},
}),
}))
// Mock AddMetadataButton
vi.mock('../add-metadata-button', () => ({
default: () => <button data-testid="add-metadata-btn">Add Metadata</button>,
}))
// Mock InputCombined
vi.mock('../edit-metadata-batch/input-combined', () => ({
default: ({ value, onChange, type }: InputCombinedProps) => (
<input
data-testid="input-combined"
data-type={type}
value={value || ''}
onChange={e => onChange(e.target.value)}
/>
),
}))
// Mock SelectMetadataModal
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
<div data-testid="select-metadata-modal">
{trigger}
<button data-testid="select-action" onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button>
<button data-testid="save-action" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button>
<button data-testid="manage-action" onClick={onManage}>Manage</button>
</div>
),
}))
// Mock Field
vi.mock('./field', () => ({
default: ({ label, children }: FieldProps) => (
<div data-testid="field">
<span data-testid="field-label">{label}</span>
<div data-testid="field-content">{children}</div>
</div>
),
}))
describe('InfoGroup', () => {
const mockList: MetadataItemWithValue[] = [
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
{ id: '3', name: 'built-in', type: DataType.time, value: 1609459200 },
]
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render title when provided', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should not render header when noHeader is true', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" noHeader />,
)
expect(screen.queryByText('Test Title')).not.toBeInTheDocument()
})
it('should render all list items', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} />,
)
const fields = screen.getAllByTestId('field')
expect(fields).toHaveLength(3)
})
it('should render tooltip when titleTooltip is provided', () => {
render(
<InfoGroup
dataSetId="ds-1"
list={mockList}
title="Test"
titleTooltip="This is a tooltip"
/>,
)
// Tooltip icon should be present
const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg')
expect(tooltipIcon).toBeInTheDocument()
})
it('should render headerRight content', () => {
render(
<InfoGroup
dataSetId="ds-1"
list={mockList}
title="Test"
headerRight={<button data-testid="header-right-btn">Action</button>}
/>,
)
expect(screen.getByTestId('header-right-btn')).toBeInTheDocument()
})
})
describe('Edit Mode', () => {
it('should render add metadata button when isEdit is true', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
expect(screen.getByTestId('add-metadata-btn')).toBeInTheDocument()
})
it('should not render add metadata button when isEdit is false', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />,
)
expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument()
})
it('should render input combined for each item in edit mode', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
const inputs = screen.getAllByTestId('input-combined')
expect(inputs).toHaveLength(3)
})
it('should render delete icons in edit mode', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
const deleteIcons = container.querySelectorAll('.cursor-pointer svg')
expect(deleteIcons.length).toBeGreaterThan(0)
})
})
describe('User Interactions', () => {
it('should call onChange when input value changes', () => {
const handleChange = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />,
)
const inputs = screen.getAllByTestId('input-combined')
fireEvent.change(inputs[0], { target: { value: 'New Value' } })
expect(handleChange).toHaveBeenCalled()
})
it('should call onDelete when delete icon is clicked', () => {
const handleDelete = vi.fn()
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />,
)
// Find delete icons (RiDeleteBinLine SVGs inside cursor-pointer divs)
const deleteButtons = container.querySelectorAll('svg.size-4')
if (deleteButtons.length > 0)
fireEvent.click(deleteButtons[0])
expect(handleDelete).toHaveBeenCalled()
})
it('should call onSelect when metadata is selected', () => {
const handleSelect = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />,
)
fireEvent.click(screen.getByTestId('select-action'))
expect(handleSelect).toHaveBeenCalledWith({
id: '1',
name: 'test',
type: DataType.string,
value: null,
})
})
it('should call onAdd when new metadata is saved', () => {
const handleAdd = vi.fn()
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />,
)
fireEvent.click(screen.getByTestId('save-action'))
expect(handleAdd).toHaveBeenCalledWith({
name: 'new_field',
type: DataType.string,
})
})
it('should navigate to documents page when manage is clicked', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
)
fireEvent.click(screen.getByTestId('manage-action'))
// The onManage callback triggers the navigation
expect(screen.getByTestId('manage-action')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should apply contentClassName', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={mockList} contentClassName="content-custom" />,
)
const contentDiv = container.querySelector('.content-custom')
expect(contentDiv).toBeInTheDocument()
})
it('should use uppercase title by default', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-xs-semibold-uppercase')
})
it('should not use uppercase when uppercaseTitle is false', () => {
render(
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" uppercaseTitle={false} />,
)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-md-semibold')
})
})
describe('Value Display', () => {
it('should display string value directly', () => {
const stringList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.string, value: 'Test Value' },
]
render(
<InfoGroup dataSetId="ds-1" list={stringList} />,
)
expect(screen.getByText('Test Value')).toBeInTheDocument()
})
it('should display number value', () => {
const numberList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.number, value: 123 },
]
render(
<InfoGroup dataSetId="ds-1" list={numberList} />,
)
expect(screen.getByText('123')).toBeInTheDocument()
})
it('should format time value', () => {
const timeList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.time, value: 1609459200 },
]
render(
<InfoGroup dataSetId="ds-1" list={timeList} />,
)
// The mock formatTime returns formatted date
expect(screen.getByTestId('field-content')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty list', () => {
const { container } = render(
<InfoGroup dataSetId="ds-1" list={[]} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle null value in list', () => {
const nullList: MetadataItemWithValue[] = [
{ id: '1', name: 'field', type: DataType.string, value: null },
]
render(
<InfoGroup dataSetId="ds-1" list={nullList} />,
)
expect(screen.getByTestId('field')).toBeInTheDocument()
})
it('should handle items with built-in id', () => {
const builtInList: MetadataItemWithValue[] = [
{ id: 'built-in', name: 'field', type: DataType.string, value: 'test' },
]
render(
<InfoGroup dataSetId="ds-1" list={builtInList} />,
)
expect(screen.getByTestId('field')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,131 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import NoData from './no-data'
describe('NoData', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with gradient background', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
expect(container.firstChild).toHaveClass('rounded-xl', 'bg-gradient-to-r', 'p-4', 'pt-3')
})
it('should render title text', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
// Title should have correct styling
const title = container.querySelector('.text-xs.font-semibold')
expect(title).toBeInTheDocument()
})
it('should render description text', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
// Description should have correct styling
const description = container.querySelector('.system-xs-regular.mt-1')
expect(description).toBeInTheDocument()
})
it('should render start labeling button', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should render arrow icon in button', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
// RiArrowRightLine icon should be present
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})
describe('Props', () => {
it('should accept onStart prop', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onStart when button is clicked', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
fireEvent.click(screen.getByRole('button'))
expect(handleStart).toHaveBeenCalledTimes(1)
})
it('should call onStart multiple times on multiple clicks', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
expect(handleStart).toHaveBeenCalledTimes(3)
})
it('should have been called when button is clicked', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
fireEvent.click(screen.getByRole('button'))
// The onClick handler passes the event to onStart
expect(handleStart).toHaveBeenCalled()
})
})
describe('Button Styling', () => {
it('should have primary variant button', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
// Button should have primary styling
expect(button).toHaveClass('mt-2')
})
})
describe('Layout', () => {
it('should have correct title styling', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
const title = container.querySelector('.text-xs.font-semibold')
expect(title).toBeInTheDocument()
})
it('should have correct description styling', () => {
const handleStart = vi.fn()
const { container } = render(<NoData onStart={handleStart} />)
const description = container.querySelector('.system-xs-regular.mt-1')
expect(description).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle rapid clicks', () => {
const handleStart = vi.fn()
render(<NoData onStart={handleStart} />)
const button = screen.getByRole('button')
for (let i = 0; i < 10; i++) {
fireEvent.click(button)
}
expect(handleStart).toHaveBeenCalledTimes(10)
})
})
})
@@ -0,0 +1,45 @@
import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
import { describe, expect, it } from 'vitest'
import { DataType } from '../types'
import { getIcon } from './get-icon'
describe('getIcon', () => {
describe('Rendering', () => {
it('should return RiTextSnippet for DataType.string', () => {
const result = getIcon(DataType.string)
expect(result).toBe(RiTextSnippet)
})
it('should return RiHashtag for DataType.number', () => {
const result = getIcon(DataType.number)
expect(result).toBe(RiHashtag)
})
it('should return RiTimeLine for DataType.time', () => {
const result = getIcon(DataType.time)
expect(result).toBe(RiTimeLine)
})
})
describe('Edge Cases', () => {
it('should return RiTextSnippet as fallback for unknown type', () => {
const result = getIcon('unknown' as DataType)
expect(result).toBe(RiTextSnippet)
})
it('should return RiTextSnippet for undefined type', () => {
const result = getIcon(undefined as unknown as DataType)
expect(result).toBe(RiTextSnippet)
})
it('should return RiTextSnippet for null type', () => {
const result = getIcon(null as unknown as DataType)
expect(result).toBe(RiTextSnippet)
})
it('should return RiTextSnippet for empty string type', () => {
const result = getIcon('' as DataType)
expect(result).toBe(RiTextSnippet)
})
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
import { renderHook } from '@testing-library/react'
import { useChunkStructure } from './hooks'
import { EffectColor } from './types'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('useChunkStructure', () => {
describe('Hook Initialization', () => {
it('should return options array', () => {
const { result } = renderHook(() => useChunkStructure())
expect(result.current.options).toBeDefined()
expect(Array.isArray(result.current.options)).toBe(true)
})
it('should return exactly 3 options', () => {
const { result } = renderHook(() => useChunkStructure())
expect(result.current.options).toHaveLength(3)
})
})
describe('General Option', () => {
it('should have correct id for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.id).toBe('text_model')
})
it('should have icon for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.icon).toBeDefined()
})
it('should have correct iconActiveColor for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.iconActiveColor).toBe('text-util-colors-indigo-indigo-600')
})
it('should have title for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.title).toBe('General')
})
it('should have description for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.description).toBeDefined()
})
it('should have indigo effectColor for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.effectColor).toBe(EffectColor.indigo)
})
it('should have showEffectColor true for General option', () => {
const { result } = renderHook(() => useChunkStructure())
const generalOption = result.current.options[0]
expect(generalOption.showEffectColor).toBe(true)
})
})
describe('Parent-Child Option', () => {
it('should have correct id for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.id).toBe('hierarchical_model')
})
it('should have icon for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.icon).toBeDefined()
})
it('should have correct iconActiveColor for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.iconActiveColor).toBe('text-util-colors-blue-light-blue-light-500')
})
it('should have title for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.title).toBe('Parent-Child')
})
it('should have description for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.description).toBeDefined()
})
it('should have blueLight effectColor for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.effectColor).toBe(EffectColor.blueLight)
})
it('should have showEffectColor true for Parent-Child option', () => {
const { result } = renderHook(() => useChunkStructure())
const parentChildOption = result.current.options[1]
expect(parentChildOption.showEffectColor).toBe(true)
})
})
describe('Q&A Option', () => {
it('should have correct id for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.id).toBe('qa_model')
})
it('should have icon for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.icon).toBeDefined()
})
it('should have title for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.title).toBe('Q&A')
})
it('should have description for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.description).toBeDefined()
})
it('should not have effectColor for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.effectColor).toBeUndefined()
})
it('should not have showEffectColor for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.showEffectColor).toBeUndefined()
})
it('should not have iconActiveColor for Q&A option', () => {
const { result } = renderHook(() => useChunkStructure())
const qaOption = result.current.options[2]
expect(qaOption.iconActiveColor).toBeUndefined()
})
})
describe('Options Structure', () => {
it('should return options in correct order', () => {
const { result } = renderHook(() => useChunkStructure())
const ids = result.current.options.map(opt => opt.id)
expect(ids).toEqual(['text_model', 'hierarchical_model', 'qa_model'])
})
it('should return all options with required id property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.id).toBeDefined()
})
})
it('should return all options with required title property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.title).toBeDefined()
expect(typeof option.title).toBe('string')
})
})
it('should return all options with description property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.description).toBeDefined()
})
})
it('should return all options with icon property', () => {
const { result } = renderHook(() => useChunkStructure())
result.current.options.forEach((option) => {
expect(option.icon).toBeDefined()
})
})
})
describe('Hook Stability', () => {
it('should return consistent options on multiple renders', () => {
const { result, rerender } = renderHook(() => useChunkStructure())
const firstRenderOptions = result.current.options.map(opt => opt.id)
rerender()
const secondRenderOptions = result.current.options.map(opt => opt.id)
expect(firstRenderOptions).toEqual(secondRenderOptions)
})
it('should return options with stable structure', () => {
const { result, rerender } = renderHook(() => useChunkStructure())
const firstLength = result.current.options.length
rerender()
const secondLength = result.current.options.length
expect(firstLength).toBe(secondLength)
})
})
})
@@ -0,0 +1,125 @@
import { render, screen } from '@testing-library/react'
import { ChunkingMode } from '@/models/datasets'
import ChunkStructure from './index'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('ChunkStructure', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
it('should render all three options', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.getByText('Parent-Child')).toBeInTheDocument()
expect(screen.getByText('Q&A')).toBeInTheDocument()
})
it('should render in a vertical layout', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('flex-col')
})
})
describe('Active State', () => {
it('should mark General option as active when chunkStructure is text', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// The active card has ring styling
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
it('should mark Parent-Child option as active when chunkStructure is parentChild', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
it('should mark Q&A option as active when chunkStructure is qa', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('Disabled State', () => {
it('should render all options as disabled', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// All cards should have cursor-not-allowed (disabled)
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
expect(disabledCards.length).toBeGreaterThan(0)
})
})
describe('Option Cards', () => {
it('should render option cards with correct structure', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// All options should have descriptions
expect(screen.getByText(/stepTwo\.generalTip/)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.parentChildTip/)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.qaTip/)).toBeInTheDocument()
})
it('should render icons for all options', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Each option card should have an icon (SVG elements)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(3) // At least 3 icons
})
})
describe('Effect Colors', () => {
it('should show effect color for active General option', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(effectElement).toBeInTheDocument()
})
it('should show effect color for active Parent-Child option', () => {
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
expect(effectElement).toBeInTheDocument()
})
})
describe('Props', () => {
it('should update active state when chunkStructure prop changes', () => {
const { rerender, container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Initially one card is active
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
// Change to parentChild
rerender(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
// Still one card should be active
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
// Change to qa
rerender(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
// Still one card should be active
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('Integration with useChunkStructure hook', () => {
it('should use options from useChunkStructure hook', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
// Verify all expected options are rendered
const expectedTitles = ['General', 'Parent-Child', 'Q&A']
expectedTitles.forEach((title) => {
expect(screen.getByText(title)).toBeInTheDocument()
})
})
})
})
@@ -0,0 +1,208 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { IndexingType } from '../../create/step-two'
import IndexMethod from './index'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('IndexMethod', () => {
const defaultProps = {
value: IndexingType.QUALIFIED,
onChange: vi.fn(),
keywordNumber: 10,
onKeywordNumberChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should render High Quality option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should render Economy option', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
})
it('should render High Quality description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodHighQualityTip/)).toBeInTheDocument()
})
it('should render Economy description', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/form\.indexMethodEconomyTip/)).toBeInTheDocument()
})
it('should render recommended badge on High Quality', () => {
render(<IndexMethod {...defaultProps} />)
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
})
})
describe('Active State', () => {
it('should mark High Quality as active when value is QUALIFIED', () => {
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
it('should mark Economy as active when value is ECONOMICAL', () => {
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />)
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('User Interactions', () => {
it('should call onChange with QUALIFIED when High Quality is clicked', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />)
// Find and click High Quality option
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
})
it('should call onChange with ECONOMICAL when Economy is clicked', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} currentValue={IndexingType.ECONOMICAL} />)
// Find and click Economy option - use getAllByText and get the first one (title)
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
const economyTitle = economyTitles[0]
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
})
it('should not call onChange when clicking already active option', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />)
// Click on already active High Quality
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('Disabled State', () => {
it('should disable both options when disabled is true', () => {
const { container } = render(<IndexMethod {...defaultProps} disabled={true} />)
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
expect(disabledCards.length).toBeGreaterThan(0)
})
it('should disable Economy option when currentValue is QUALIFIED', () => {
const handleChange = vi.fn()
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} onChange={handleChange} value={IndexingType.ECONOMICAL} />)
// Try to click Economy option - use getAllByText and get the first one (title)
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
const economyTitle = economyTitles[0]
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
fireEvent.click(card!)
// Should not call onChange because Economy is disabled when current is QUALIFIED
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('KeywordNumber', () => {
it('should render KeywordNumber component inside Economy option', () => {
render(<IndexMethod {...defaultProps} />)
// KeywordNumber has a slider
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should pass keywordNumber to KeywordNumber component', () => {
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn()
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '30' } })
expect(handleKeywordChange).toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should show tooltip when hovering over disabled Economy option', () => {
// The tooltip is shown via PortalToFollowElem when hovering
// This is controlled by useHover hook
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
// The tooltip content should exist in DOM but may not be visible
// We just verify the component renders without error
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
})
})
describe('Effect Colors', () => {
it('should show orange effect color for High Quality option', () => {
const { container } = render(<IndexMethod {...defaultProps} />)
const orangeEffect = container.querySelector('.bg-util-colors-orange-orange-500')
expect(orangeEffect).toBeInTheDocument()
})
it('should show indigo effect color for Economy option', () => {
const { container } = render(<IndexMethod {...defaultProps} />)
const indigoEffect = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(indigoEffect).toBeInTheDocument()
})
})
describe('Props', () => {
it('should update active state when value prop changes', () => {
const { rerender, container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
rerender(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} currentValue={IndexingType.ECONOMICAL} />)
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
expect(activeCards).toHaveLength(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined currentValue', () => {
render(<IndexMethod {...defaultProps} currentValue={undefined} />)
// Should render without error
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
})
it('should handle keywordNumber of 0', () => {
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle max keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
})
})
@@ -0,0 +1,171 @@
import { fireEvent, render, screen } from '@testing-library/react'
import KeyWordNumber from './keyword-number'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('KeyWordNumber', () => {
const defaultProps = {
keywordNumber: 10,
onKeywordNumberChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
})
it('should render label text', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
})
it('should render tooltip with question icon', () => {
render(<KeyWordNumber {...defaultProps} />)
// RiQuestionLine renders as an svg
const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
const questionIcon = container?.querySelector('svg')
expect(questionIcon).toBeInTheDocument()
})
it('should render slider', () => {
render(<KeyWordNumber {...defaultProps} />)
// Slider has a slider role
expect(screen.getByRole('slider')).toBeInTheDocument()
})
it('should render input number field', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display correct keywordNumber value in input', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should display different keywordNumber values', () => {
const values = [1, 10, 25, 50]
values.forEach((value) => {
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(value)
unmount()
})
})
it('should pass correct value to slider', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuenow', '30')
})
})
describe('User Interactions', () => {
it('should render slider that accepts onChange', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const slider = screen.getByRole('slider')
// Verify slider is rendered and interactive
expect(slider).toBeInTheDocument()
expect(slider).not.toBeDisabled()
})
it('should call onKeywordNumberChange when input value changes', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '30' } })
expect(handleChange).toHaveBeenCalled()
})
it('should not call onKeywordNumberChange with undefined value', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '' } })
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
expect(handleChange).not.toHaveBeenCalled()
})
})
describe('Slider Configuration', () => {
it('should have max value of 50', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemax', '50')
})
it('should have min value of 0', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toHaveAttribute('aria-valuemin', '0')
})
})
describe('Edge Cases', () => {
it('should handle minimum value (0)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle maximum value (50)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
it('should handle value updates correctly', () => {
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
let input = screen.getByRole('spinbutton')
expect(input).toHaveValue(10)
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should handle rapid value changes', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton')
// Simulate rapid changes via input with different values
fireEvent.change(input, { target: { value: '15' } })
fireEvent.change(input, { target: { value: '25' } })
fireEvent.change(input, { target: { value: '35' } })
expect(handleChange).toHaveBeenCalledTimes(3)
})
})
describe('Accessibility', () => {
it('should have accessible slider', () => {
render(<KeyWordNumber {...defaultProps} />)
const slider = screen.getByRole('slider')
expect(slider).toBeInTheDocument()
})
it('should have accessible input', () => {
render(<KeyWordNumber {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
})
@@ -0,0 +1,317 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { EffectColor } from './chunk-structure/types'
import OptionCard from './option-card'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('OptionCard', () => {
const defaultProps = {
id: 'test-id',
title: 'Test Title',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should render title', () => {
render(<OptionCard {...defaultProps} title="Custom Title" />)
expect(screen.getByText('Custom Title')).toBeInTheDocument()
})
it('should render description when provided', () => {
render(<OptionCard {...defaultProps} description="Test Description" />)
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
it('should not render description when not provided', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
})
it('should render icon when provided', () => {
render(<OptionCard {...defaultProps} icon={<span data-testid="test-icon">Icon</span>} />)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('should not render icon container when icon is not provided', () => {
const { container } = render(<OptionCard {...defaultProps} />)
const iconContainers = container.querySelectorAll('.size-6')
expect(iconContainers).toHaveLength(0)
})
})
describe('Active State', () => {
it('should apply active styles when isActive is true', () => {
const { container } = render(<OptionCard {...defaultProps} isActive={true} />)
const card = container.firstChild
expect(card).toHaveClass('ring-[1px]')
})
it('should not apply active styles when isActive is false', () => {
const { container } = render(<OptionCard {...defaultProps} isActive={false} />)
const card = container.firstChild
expect(card).not.toHaveClass('ring-[1px]')
})
it('should apply iconActiveColor when isActive is true and icon is present', () => {
const { container } = render(
<OptionCard
{...defaultProps}
isActive={true}
icon={<span>Icon</span>}
iconActiveColor="text-red-500"
/>,
)
const iconContainer = container.querySelector('.text-red-500')
expect(iconContainer).toBeInTheDocument()
})
})
describe('Disabled State', () => {
it('should apply disabled styles when disabled is true', () => {
const { container } = render(<OptionCard {...defaultProps} disabled={true} />)
const card = container.firstChild
expect(card).toHaveClass('cursor-not-allowed')
expect(card).toHaveClass('opacity-50')
})
it('should not call onClick when disabled', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} disabled={true} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).not.toHaveBeenCalled()
})
it('should not call onClick when isActive', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} isActive={true} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('Recommended Badge', () => {
it('should render recommended badge when isRecommended is true', () => {
render(<OptionCard {...defaultProps} isRecommended={true} />)
// Badge uses translation key
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
})
it('should not render recommended badge when isRecommended is false', () => {
render(<OptionCard {...defaultProps} isRecommended={false} />)
expect(screen.queryByText(/stepTwo\.recommend/)).not.toBeInTheDocument()
})
})
describe('Effect Color', () => {
it('should render effect color when effectColor and showEffectColor are provided', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).toBeInTheDocument()
})
it('should not render effect color when showEffectColor is false', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={false} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).not.toBeInTheDocument()
})
it('should not render effect color when effectColor is not provided', () => {
const { container } = render(
<OptionCard {...defaultProps} showEffectColor={true} />,
)
const effectElement = container.querySelector('.blur-\\[80px\\]')
expect(effectElement).not.toBeInTheDocument()
})
it('should apply indigo effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
expect(effectElement).toBeInTheDocument()
})
it('should apply blueLight effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.blueLight} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
expect(effectElement).toBeInTheDocument()
})
it('should apply orange effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.orange} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-orange-orange-500')
expect(effectElement).toBeInTheDocument()
})
it('should apply purple effect color class', () => {
const { container } = render(
<OptionCard {...defaultProps} effectColor={EffectColor.purple} showEffectColor={true} />,
)
const effectElement = container.querySelector('.bg-util-colors-purple-purple-600')
expect(effectElement).toBeInTheDocument()
})
})
describe('Children', () => {
it('should render children when children and showChildren are provided', () => {
render(
<OptionCard {...defaultProps} showChildren={true}>
<div data-testid="child-content">Child Content</div>
</OptionCard>,
)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
})
it('should not render children when showChildren is false', () => {
render(
<OptionCard {...defaultProps} showChildren={false}>
<div data-testid="child-content">Child Content</div>
</OptionCard>,
)
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
})
it('should not render children container when children is not provided', () => {
const { container } = render(
<OptionCard {...defaultProps} showChildren={true} />,
)
const childContainer = container.querySelector('.bg-components-panel-bg')
expect(childContainer).not.toBeInTheDocument()
})
it('should render arrow shape when children are shown', () => {
const { container } = render(
<OptionCard {...defaultProps} showChildren={true}>
<div>Child</div>
</OptionCard>,
)
// ArrowShape renders an SVG
const childSection = container.querySelector('.bg-components-panel-bg')
expect(childSection).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick with id when clicked', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} id="my-id" onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).toHaveBeenCalledWith('my-id')
})
it('should have cursor-pointer class', () => {
const { container } = render(<OptionCard {...defaultProps} />)
const card = container.firstChild
expect(card).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<OptionCard {...defaultProps} className="custom-class" />)
const innerContainer = container.querySelector('.custom-class')
expect(innerContainer).toBeInTheDocument()
})
it('should forward ref', () => {
const ref = vi.fn()
render(<OptionCard {...defaultProps} ref={ref} />)
expect(ref).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
render(<OptionCard {...defaultProps} title="" />)
// Component should still render
const { container } = render(<OptionCard {...defaultProps} title="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle complex id types', () => {
const handleClick = vi.fn()
const complexId = { key: 'value' }
render(<OptionCard {...defaultProps} id={complexId} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).toHaveBeenCalledWith(complexId)
})
it('should handle numeric id', () => {
const handleClick = vi.fn()
render(<OptionCard {...defaultProps} id={123} onClick={handleClick} />)
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
fireEvent.click(card!)
expect(handleClick).toHaveBeenCalledWith(123)
})
it('should handle long title', () => {
const longTitle = 'A'.repeat(200)
render(<OptionCard {...defaultProps} title={longTitle} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle long description', () => {
const longDesc = 'B'.repeat(500)
render(<OptionCard {...defaultProps} description={longDesc} />)
expect(screen.getByText(longDesc)).toBeInTheDocument()
})
it('should handle all props together', () => {
const handleClick = vi.fn()
render(
<OptionCard
id="full-test"
title="Full Test"
description="Full Description"
icon={<span data-testid="full-icon">Icon</span>}
iconActiveColor="text-blue-500"
isActive={true}
isRecommended={true}
effectColor={EffectColor.indigo}
showEffectColor={true}
disabled={false}
onClick={handleClick}
className="full-class"
showChildren={true}
>
<div data-testid="full-children">Children</div>
</OptionCard>,
)
expect(screen.getByText('Full Test')).toBeInTheDocument()
expect(screen.getByText('Full Description')).toBeInTheDocument()
expect(screen.getByTestId('full-icon')).toBeInTheDocument()
expect(screen.getByTestId('full-children')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,512 @@
import type { Member } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DatasetPermission } from '@/models/datasets'
import PermissionSelector from './index'
// Mock app-context
vi.mock('@/context/app-context', () => ({
useSelector: () => ({
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
}),
}))
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('PermissionSelector', () => {
const mockMemberList: Member[] = [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'John Doe', email: 'john@example.com', avatar: '', avatar_url: '', role: 'admin', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-3', name: 'Jane Smith', email: 'jane@example.com', avatar: '', avatar_url: '', role: 'editor', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-4', name: 'Dataset Operator', email: 'operator@example.com', avatar: '', avatar_url: '', role: 'dataset_operator', last_login_at: '', created_at: '', status: 'active' },
]
const defaultProps = {
permission: DatasetPermission.onlyMe,
value: ['user-1'],
memberList: mockMemberList,
onChange: vi.fn(),
onMemberSelect: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<PermissionSelector {...defaultProps} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should render Only Me option when permission is onlyMe', () => {
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should render All Team Members option when permission is allTeamMembers', () => {
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
})
it('should render selected member names when permission is partialMembers', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should show member names
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
})
describe('Dropdown Toggle', () => {
it('should open dropdown when clicked', async () => {
render(<PermissionSelector {...defaultProps} />)
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
// Should show all permission options in dropdown
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBeGreaterThanOrEqual(1)
})
})
it('should not open dropdown when disabled', () => {
render(<PermissionSelector {...defaultProps} disabled={true} />)
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
// Dropdown should not open - only the trigger text should be visible
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBe(1)
})
})
describe('Permission Selection', () => {
it('should call onChange with onlyMe when Only Me is selected', async () => {
const handleChange = vi.fn()
render(<PermissionSelector {...defaultProps} onChange={handleChange} permission={DatasetPermission.allTeamMembers} />)
// Open dropdown
const trigger = screen.getByText(/form\.permissionsAllMember/)
fireEvent.click(trigger)
await waitFor(() => {
// Click Only Me option
const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/)
fireEvent.click(onlyMeOptions[0])
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.onlyMe)
})
it('should call onChange with allTeamMembers when All Team Members is selected', async () => {
const handleChange = vi.fn()
render(<PermissionSelector {...defaultProps} onChange={handleChange} />)
// Open dropdown
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
// Click All Team Members option
const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/)
fireEvent.click(allMemberOptions[0])
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
})
it('should call onChange with partialMembers when Invited Members is selected', async () => {
const handleChange = vi.fn()
const handleMemberSelect = vi.fn()
render(
<PermissionSelector
{...defaultProps}
onChange={handleChange}
onMemberSelect={handleMemberSelect}
/>,
)
// Open dropdown
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
// Click Invited Members option
const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/)
fireEvent.click(invitedOptions[0])
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
})
})
describe('Member Selection', () => {
it('should show member list when partialMembers is selected', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
// Should show member list
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
})
})
it('should call onMemberSelect when a member is clicked', async () => {
const handleMemberSelect = vi.fn()
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
onMemberSelect={handleMemberSelect}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
// Click on John Doe
const johnDoe = screen.getByText('John Doe')
fireEvent.click(johnDoe)
})
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1', 'user-2'])
})
it('should deselect member when clicked again', async () => {
const handleMemberSelect = vi.fn()
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
onMemberSelect={handleMemberSelect}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
// Click on John Doe to deselect
const johnDoe = screen.getByText('John Doe')
fireEvent.click(johnDoe)
})
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
})
})
describe('Search Functionality', () => {
it('should allow typing in search input', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'John' } })
expect(searchInput).toHaveValue('John')
})
it('should render search input in partial members mode', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open and search input to be available
const searchInput = await screen.findByRole('textbox')
expect(searchInput).toBeInTheDocument()
})
it('should filter members after debounce completes', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'John' } })
// Wait for debounce (500ms) + buffer
await waitFor(
() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should handle clear search functionality', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'test' } })
expect(searchInput).toHaveValue('test')
// Click the clear button using data-testid
const clearButton = screen.getByTestId('input-clear')
fireEvent.click(clearButton)
// After clicking clear, input should be empty
await waitFor(() => {
expect(searchInput).toHaveValue('')
})
})
it('should filter members by email', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search by email
fireEvent.change(searchInput, { target: { value: 'john@example' } })
// Wait for debounce
await waitFor(
() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should show no results message when search matches nothing', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for non-existent member
fireEvent.change(searchInput, { target: { value: 'nonexistent12345' } })
// Wait for debounce and no results message
await waitFor(
() => {
expect(screen.getByText(/form\.onSearchResults/)).toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should show current user when search matches user name', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for current user by name - partial match
fireEvent.change(searchInput, { target: { value: 'Current' } })
// Current user (showMe) should remain visible based on name match
// The component uses useMemo to check if userProfile.name.includes(searchKeywords)
expect(searchInput).toHaveValue('Current')
// Current User label appears multiple times (trigger + member list)
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
})
it('should show current user when search matches user email', async () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
/>,
)
// Open dropdown
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for current user by email
fireEvent.change(searchInput, { target: { value: 'current@' } })
// The component checks userProfile.email.includes(searchKeywords)
expect(searchInput).toHaveValue('current@')
// Current User should remain visible based on email match
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
})
})
describe('Disabled State', () => {
it('should apply disabled styles when disabled', () => {
const { container } = render(<PermissionSelector {...defaultProps} disabled={true} />)
// When disabled, the component has !cursor-not-allowed class (escaped in Tailwind)
const triggerElement = container.querySelector('[class*="cursor-not-allowed"]')
expect(triggerElement).toBeInTheDocument()
})
})
describe('Display Variations', () => {
it('should display single avatar when only one member selected', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1']}
/>,
)
// Should display single avatar
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
it('should display two avatars when two or more members selected', () => {
render(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should display member names
expect(screen.getByTitle(/Current User, John Doe/)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty member list', () => {
render(
<PermissionSelector
{...defaultProps}
memberList={[]}
/>,
)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should handle member list with only current user', () => {
render(
<PermissionSelector
{...defaultProps}
memberList={[mockMemberList[0]]}
/>,
)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
})
it('should only show members with allowed roles', () => {
// The component filters members by role in useMemo
// Allowed roles are: owner, admin, editor, dataset_operator
// This is tested indirectly through the memberList filtering
const memberListWithNormalUser: Member[] = [
...mockMemberList,
{ id: 'user-5', name: 'Normal User', email: 'normal@example.com', avatar: '', avatar_url: '', role: 'normal', last_login_at: '', created_at: '', status: 'active' },
]
render(
<PermissionSelector
{...defaultProps}
memberList={memberListWithNormalUser}
permission={DatasetPermission.partialMembers}
/>,
)
// The component renders - the filtering logic is internal
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should update when permission prop changes', () => {
const { rerender } = render(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
rerender(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
})
})
})
@@ -0,0 +1,195 @@
import { fireEvent, render, screen } from '@testing-library/react'
import MemberItem from './member-item'
// Note: react-i18next is globally mocked in vitest.setup.ts
describe('MemberItem', () => {
const defaultProps = {
leftIcon: <span data-testid="avatar-icon">Avatar</span>,
name: 'John Doe',
email: 'john@example.com',
isSelected: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render left icon (avatar)', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('should render member name', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should render member email', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
})
describe('Selection State', () => {
it('should show checkmark icon when selected', () => {
render(<MemberItem {...defaultProps} isSelected={true} />)
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
it('should not show checkmark icon when not selected', () => {
render(<MemberItem {...defaultProps} isSelected={false} />)
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).not.toBeInTheDocument()
})
it('should apply opacity class to checkmark when isMe is true', () => {
render(<MemberItem {...defaultProps} isSelected={true} isMe={true} />)
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toHaveClass('opacity-30')
})
})
describe('isMe Flag', () => {
it('should show me indicator when isMe is true', () => {
render(<MemberItem {...defaultProps} isMe={true} />)
// The translation key is 'form.me' which will be rendered by the mock
expect(screen.getByText(/form\.me/)).toBeInTheDocument()
})
it('should not show me indicator when isMe is false', () => {
render(<MemberItem {...defaultProps} isMe={false} />)
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
})
it('should not show me indicator by default', () => {
render(<MemberItem {...defaultProps} />)
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<MemberItem {...defaultProps} onClick={handleClick} />)
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not throw when onClick is not provided', () => {
render(<MemberItem {...defaultProps} />)
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(() => fireEvent.click(item!)).not.toThrow()
})
it('should have cursor-pointer class for interactivity', () => {
render(<MemberItem {...defaultProps} />)
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(item).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should render different names', () => {
const names = ['Alice', 'Bob', 'Charlie']
names.forEach((name) => {
const { unmount } = render(<MemberItem {...defaultProps} name={name} />)
expect(screen.getByText(name)).toBeInTheDocument()
unmount()
})
})
it('should render different emails', () => {
const emails = ['alice@test.com', 'bob@company.org', 'charlie@domain.net']
emails.forEach((email) => {
const { unmount } = render(<MemberItem {...defaultProps} email={email} />)
expect(screen.getByText(email)).toBeInTheDocument()
unmount()
})
})
it('should render different left icons', () => {
const customIcon = <img data-testid="custom-avatar" alt="avatar" />
render(<MemberItem {...defaultProps} leftIcon={customIcon} />)
expect(screen.getByTestId('custom-avatar')).toBeInTheDocument()
})
it('should handle isSelected toggle correctly', () => {
const { rerender } = render(<MemberItem {...defaultProps} isSelected={false} />)
// Initially not selected
let container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(container?.querySelector('svg')).not.toBeInTheDocument()
// Update to selected
rerender(<MemberItem {...defaultProps} isSelected={true} />)
container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
expect(container?.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<MemberItem {...defaultProps} name="" />)
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
it('should handle empty email', () => {
render(<MemberItem {...defaultProps} email="" />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
it('should handle long name with truncation', () => {
const longName = 'A'.repeat(100)
render(<MemberItem {...defaultProps} name={longName} />)
const nameElement = screen.getByText(longName)
expect(nameElement).toHaveClass('truncate')
})
it('should handle long email with truncation', () => {
const longEmail = `${'a'.repeat(50)}@${'b'.repeat(50)}.com`
render(<MemberItem {...defaultProps} email={longEmail} />)
const emailElement = screen.getByText(longEmail)
expect(emailElement).toHaveClass('truncate')
})
it('should handle special characters in name', () => {
const specialName = 'O\'Connor-Smith'
render(<MemberItem {...defaultProps} name={specialName} />)
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle unicode characters', () => {
const unicodeName = '张三'
const unicodeEmail = '张三@example.com'
render(<MemberItem {...defaultProps} name={unicodeName} email={unicodeEmail} />)
expect(screen.getByText(unicodeName)).toBeInTheDocument()
expect(screen.getByText(unicodeEmail)).toBeInTheDocument()
})
it('should render both isMe and isSelected together', () => {
render(<MemberItem {...defaultProps} isMe={true} isSelected={true} />)
expect(screen.getByText(/form\.me/)).toBeInTheDocument()
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
expect(checkIcon).toHaveClass('opacity-30')
})
})
})
@@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react'
import PermissionItem from './permission-item'
describe('PermissionItem', () => {
const defaultProps = {
leftIcon: <span data-testid="left-icon">Icon</span>,
text: 'Test Permission',
onClick: vi.fn(),
isSelected: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<PermissionItem {...defaultProps} />)
expect(screen.getByText('Test Permission')).toBeInTheDocument()
})
it('should render left icon', () => {
render(<PermissionItem {...defaultProps} />)
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
})
it('should render text content', () => {
const text = 'Custom Permission Text'
render(<PermissionItem {...defaultProps} text={text} />)
expect(screen.getByText(text)).toBeInTheDocument()
})
})
describe('Selection State', () => {
it('should show checkmark icon when selected', () => {
render(<PermissionItem {...defaultProps} isSelected={true} />)
// RiCheckLine renders as an svg element
const container = screen.getByText('Test Permission').closest('div')?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
it('should not show checkmark icon when not selected', () => {
render(<PermissionItem {...defaultProps} isSelected={false} />)
const container = screen.getByText('Test Permission').closest('div')?.parentElement
const checkIcon = container?.querySelector('svg')
expect(checkIcon).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<PermissionItem {...defaultProps} onClick={handleClick} />)
const item = screen.getByText('Test Permission').closest('div')?.parentElement
fireEvent.click(item!)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should have cursor-pointer class for interactivity', () => {
render(<PermissionItem {...defaultProps} />)
const item = screen.getByText('Test Permission').closest('div')?.parentElement
expect(item).toHaveClass('cursor-pointer')
})
})
describe('Props', () => {
it('should render different left icons', () => {
const customIcon = <span data-testid="custom-icon">Custom</span>
render(<PermissionItem {...defaultProps} leftIcon={customIcon} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should handle different text values', () => {
const texts = ['Only Me', 'All Team Members', 'Invited Members']
texts.forEach((text) => {
const { unmount } = render(<PermissionItem {...defaultProps} text={text} />)
expect(screen.getByText(text)).toBeInTheDocument()
unmount()
})
})
it('should handle isSelected toggle correctly', () => {
const { rerender } = render(<PermissionItem {...defaultProps} isSelected={false} />)
// Initially not selected - no checkmark
let container = screen.getByText('Test Permission').closest('div')?.parentElement
expect(container?.querySelector('svg')).not.toBeInTheDocument()
// Update to selected
rerender(<PermissionItem {...defaultProps} isSelected={true} />)
container = screen.getByText('Test Permission').closest('div')?.parentElement
expect(container?.querySelector('svg')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty text', () => {
render(<PermissionItem {...defaultProps} text="" />)
// The component should still render
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
})
it('should handle long text content', () => {
const longText = 'A'.repeat(200)
render(<PermissionItem {...defaultProps} text={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
it('should handle special characters in text', () => {
const specialText = '<script>alert("xss")</script>'
render(<PermissionItem {...defaultProps} text={specialText} />)
expect(screen.getByText(specialText)).toBeInTheDocument()
})
it('should handle complex left icon nodes', () => {
const complexIcon = (
<div data-testid="complex-icon">
<span>Nested</span>
<div>Content</div>
</div>
)
render(<PermissionItem {...defaultProps} leftIcon={complexIcon} />)
expect(screen.getByTestId('complex-icon')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,297 @@
import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { IndexingType } from '../../create/step-two'
import { checkShowMultiModalTip } from './index'
describe('checkShowMultiModalTip', () => {
// Helper to create a model item with specific features
const createModelItem = (model: string, features: ModelFeatureEnum[] = []): ModelItem => ({
model,
label: { en_US: model, zh_Hans: model },
model_type: ModelTypeEnum.textEmbedding,
features,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
deprecated: false,
})
// Helper to create a model provider
const createModelProvider = (provider: string, models: ModelItem[]): Model => ({
provider,
label: { en_US: provider, zh_Hans: provider },
icon_small: { en_US: '', zh_Hans: '' },
status: ModelStatusEnum.active,
models,
})
const defaultProps = {
embeddingModel: {
provider: 'openai',
model: 'text-embedding-ada-002',
} as DefaultModel,
rerankingEnable: true,
rerankModel: {
rerankingProviderName: 'cohere',
rerankingModelName: 'rerank-english-v2.0',
},
indexMethod: IndexingType.QUALIFIED,
embeddingModelList: [
createModelProvider('openai', [
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
]),
],
rerankModelList: [
createModelProvider('cohere', [
createModelItem('rerank-english-v2.0', []),
]),
],
}
describe('Return false conditions', () => {
it('should return false when indexMethod is not QUALIFIED', () => {
const result = checkShowMultiModalTip({
...defaultProps,
indexMethod: IndexingType.ECONOMICAL,
})
expect(result).toBe(false)
})
it('should return false when indexMethod is undefined', () => {
const result = checkShowMultiModalTip({
...defaultProps,
indexMethod: undefined,
})
expect(result).toBe(false)
})
it('should return false when embeddingModel.provider is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: '', model: 'text-embedding-ada-002' },
})
expect(result).toBe(false)
})
it('should return false when embeddingModel.model is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'openai', model: '' },
})
expect(result).toBe(false)
})
it('should return false when embedding model provider is not found', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'unknown-provider', model: 'text-embedding-ada-002' },
})
expect(result).toBe(false)
})
it('should return false when embedding model is not found in provider', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'openai', model: 'unknown-model' },
})
expect(result).toBe(false)
})
it('should return false when embedding model does not support vision', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [
createModelItem('text-embedding-ada-002', []), // No vision feature
]),
],
})
expect(result).toBe(false)
})
it('should return false when rerankingEnable is false', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankingEnable: false,
})
expect(result).toBe(false)
})
it('should return false when rerankingModelName is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: 'cohere',
rerankingModelName: '',
},
})
expect(result).toBe(false)
})
it('should return false when rerankingProviderName is empty', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: '',
rerankingModelName: 'rerank-english-v2.0',
},
})
expect(result).toBe(false)
})
it('should return false when reranking model provider is not found', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: 'unknown-provider',
rerankingModelName: 'rerank-english-v2.0',
},
})
expect(result).toBe(false)
})
it('should return false when reranking model is not found in provider', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModel: {
rerankingProviderName: 'cohere',
rerankingModelName: 'unknown-model',
},
})
expect(result).toBe(false)
})
it('should return false when reranking model supports vision', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModelList: [
createModelProvider('cohere', [
createModelItem('rerank-english-v2.0', [ModelFeatureEnum.vision]), // Has vision feature
]),
],
})
expect(result).toBe(false)
})
})
describe('Return true condition', () => {
it('should return true when embedding model supports vision but reranking model does not', () => {
const result = checkShowMultiModalTip(defaultProps)
expect(result).toBe(true)
})
it('should return true with different providers', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModel: { provider: 'azure', model: 'azure-embedding' },
rerankModel: {
rerankingProviderName: 'jina',
rerankingModelName: 'jina-reranker',
},
embeddingModelList: [
createModelProvider('azure', [
createModelItem('azure-embedding', [ModelFeatureEnum.vision]),
]),
],
rerankModelList: [
createModelProvider('jina', [
createModelItem('jina-reranker', []),
]),
],
})
expect(result).toBe(true)
})
})
describe('Edge cases', () => {
it('should handle empty embeddingModelList', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [],
})
expect(result).toBe(false)
})
it('should handle empty rerankModelList', () => {
const result = checkShowMultiModalTip({
...defaultProps,
rerankModelList: [],
})
expect(result).toBe(false)
})
it('should handle model with undefined features', () => {
const modelItem: ModelItem = {
model: 'test-model',
label: { en_US: 'test', zh_Hans: 'test' },
model_type: ModelTypeEnum.textEmbedding,
features: undefined as unknown as ModelFeatureEnum[],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
deprecated: false,
}
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [modelItem]),
],
})
expect(result).toBe(false)
})
it('should handle model with null features', () => {
const modelItem: ModelItem = {
model: 'text-embedding-ada-002',
label: { en_US: 'test', zh_Hans: 'test' },
model_type: ModelTypeEnum.textEmbedding,
features: null as unknown as ModelFeatureEnum[],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
deprecated: false,
}
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [modelItem]),
],
})
expect(result).toBe(false)
})
it('should handle multiple models in provider', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('openai', [
createModelItem('text-embedding-1', []),
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
createModelItem('text-embedding-3', []),
]),
],
})
expect(result).toBe(true)
})
it('should handle multiple providers in list', () => {
const result = checkShowMultiModalTip({
...defaultProps,
embeddingModelList: [
createModelProvider('azure', [
createModelItem('azure-model', []),
]),
createModelProvider('openai', [
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
]),
],
})
expect(result).toBe(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()
})
})
})

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